Skip to content

Commit bde1e0c

Browse files
committed
feat(status): expose heartbeat journal progress
Track the latest durable journal sequence on the run heartbeat and expose it through LoopStatus and the CLI so repeated snapshots distinguish liveness from progress. Preserve compatibility with records written before progress-bearing heartbeats. Fixes loop-js#5
1 parent 7b763e3 commit bde1e0c

12 files changed

Lines changed: 57 additions & 15 deletions

File tree

‎docs/api/status.mdx‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ if (status.running) console.log(`pid ${status.pid} is on round ${status.round}`)
2121
type LoopStatus = {
2222
running: boolean
2323
pid?: number
24+
progressSeq?: number | null
2425
round: number
2526
usd: number
2627
lastExit: Exit | null
@@ -32,6 +33,7 @@ type LoopStatus = {
3233
| ----- | ------- |
3334
| `running` | Whether a Run currently holds the Lock. |
3435
| `pid` | The Lock owner's process id — present iff `running`. |
36+
| `progressSeq` | The owner's latest durable journal sequence — present iff `running`; `null` before its first journaled event. |
3537
| `round` | The resume cursor — where the next Run picks up. |
3638
| `usd` | Total spend across the whole Loop so far. |
3739
| `lastExit` | How the last Run ended, or `null` if none has. |

‎docs/cli/status.mdx‎

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,15 +20,15 @@ Four lines, at a glance:
2020

2121
```bash
2222
$ loop status
23-
running: yes (pid 41250)
23+
running: yes (pid 41250, journal seq 27)
2424
round: 3
2525
spend: $1.42
2626
verdict: not met — the report is missing the summary section
2727
```
2828

2929
| Line | Meaning |
3030
| ---- | ------- |
31-
| `running` | `yes (pid <n>)` while a process owns the Lock, else `no` |
31+
| `running` | `yes (pid <n>, journal seq <n>)` while a process owns the Lock, including its latest durable progress; else `no` |
3232
| `round` | the Round count so far |
3333
| `spend` | dollars spent, e.g. `$1.42` |
3434
| `verdict` | the last Verdict with its reason — `met`, `not met`, or `impossible`; `none` when the Loop has never been judged |
@@ -51,7 +51,9 @@ $ loop status --json
5151
}
5252
```
5353
54-
`pid` is present only while the Loop is running. `lastExit` is how the last Run
54+
`pid` and `progressSeq` are present only while the Loop is running. `progressSeq`
55+
is the latest durable journal sequence observed by its heartbeat (`null` before the
56+
first event), so repeated snapshots distinguish liveness from progress. `lastExit` is how the last Run
5557
ended — `{ settled: true, verdict }` or `{ settled: false, cause, reason }` —
5658
and is `null` before the first Run completes. `verdicts` carries every Verdict
5759
so far, each tagged with its Round.

‎packages/loop-js/src/cli/status.test.ts‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,9 +75,9 @@ test("prints the standing: round, spend, and the last verdict with its reason",
7575

7676
test("a live owner shows as running, with its pid", async () => {
7777
await writeConfig()
78-
writeRecord(join(root, ".loop"), { ...settled(), status: "running", heartbeat: { pid: 4242, ts: Date.now() } })
78+
writeRecord(join(root, ".loop"), { ...settled(), status: "running", heartbeat: { pid: 4242, ts: Date.now(), seq: 17 } })
7979
const { out } = await run([])
80-
expect(out).toContain("running: yes (pid 4242)\n")
80+
expect(out).toContain("running: yes (pid 4242, journal seq 17)\n")
8181
})
8282

8383
test("--json prints the LoopStatus snapshot, parseable by a wrapper", async () => {

‎packages/loop-js/src/cli/status.ts‎

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,9 @@ export type StatusOptions = {
2828
function statusLines(s: LoopStatus): string[] {
2929
const last = s.verdicts.at(-1)
3030
return [
31-
s.running ? `running: yes (pid ${s.pid})` : "running: no",
31+
s.running
32+
? `running: yes (pid ${s.pid}, journal seq ${s.progressSeq === null ? "none" : (s.progressSeq ?? "unknown")})`
33+
: "running: no",
3234
`round: ${s.round}`,
3335
`spend: $${s.usd.toFixed(2)}`,
3436
`verdict: ${last ? verdictText(last) : "none"}`,

‎packages/loop-js/src/engine/journal.test.ts‎

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,19 @@ test("seq resumes from the last line on reopen (replay key survives)", async ()
3232
expect((await j2.append({ type: "text", round: 1, phase: "verify", text: "x" })).seq).toBe(2)
3333
})
3434

35+
test("lastSeq reports durable journal progress, not merely reserved sequence numbers", async () => {
36+
const j = Journal.open(dir)
37+
expect(j.lastSeq).toBeNull()
38+
39+
j.reserveSeq() // a stream-only observation: assigned, but never journaled
40+
expect(j.lastSeq).toBeNull()
41+
42+
const written = await j.append({ type: "text", round: 1, phase: "execute", text: "durable" })
43+
expect(written.seq).toBe(1)
44+
expect(j.lastSeq).toBe(1)
45+
expect(Journal.open(dir).lastSeq).toBe(1)
46+
})
47+
3548
test("foldPartial folds a stranded sidecar as text{partial:true}, then clears it", async () => {
3649
const j = Journal.open(dir)
3750
await j.pushDelta(2, "execute", "half a sen")

‎packages/loop-js/src/engine/journal.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ export async function* readJournal(loopDir: string, sinceSeq = 0): AsyncGenerato
7171

7272
export class Journal {
7373
private seqCounter: number
74+
private lastSeqCounter: number | null
7475
private deltaRound = 0
7576
private deltaPhase: JournaledEvent["phase"] = "execute"
7677
/** Settles when every append started so far has hit the disk — see {@link flushed}. */
@@ -81,6 +82,7 @@ export class Journal {
8182
startSeq: number,
8283
) {
8384
this.seqCounter = startSeq
85+
this.lastSeqCounter = startSeq === 0 ? null : startSeq - 1
8486
}
8587

8688
/** Synchronous, so `run()` can open the Journal and pin the Run's start seq before returning. */
@@ -93,6 +95,11 @@ export class Journal {
9395
return this.seqCounter
9496
}
9597

98+
/** The latest sequence number known to be durable in the journal; null before its first event. */
99+
get lastSeq(): number | null {
100+
return this.lastSeqCounter
101+
}
102+
96103
/** Reserve the next `seq` without writing — for observations that emit live before persisting. */
97104
reserveSeq(): number {
98105
return this.seqCounter++
@@ -103,8 +110,11 @@ export class Journal {
103110
* {@link flushed} — the ReplaySource contract rests on that. */
104111
write(evt: JournaledEvent): Promise<void> {
105112
const op = appendFile(join(this.loopDir, JOURNAL_FILE), JSON.stringify(evt) + "\n", "utf8")
106-
this.tail = Promise.allSettled([this.tail, op]) // never rejects — one failed append cannot poison the chain
107-
return op
113+
const written = op.then(() => {
114+
this.lastSeqCounter = Math.max(this.lastSeqCounter ?? -1, evt.seq)
115+
})
116+
this.tail = Promise.allSettled([this.tail, written]) // never rejects — one failed append cannot poison the chain
117+
return written
108118
}
109119

110120
/**

‎packages/loop-js/src/engine/lock.test.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -51,7 +51,7 @@ describe("Lock.acquire (synchronous CAS claim)", () => {
5151
const { record, tookOver } = new Lock({ loopDir: dir, pid: 42, now }).acquire()
5252
expect(tookOver).toBe(false)
5353
expect(record.status).toBe("running")
54-
expect(record.heartbeat).toEqual({ pid: 42, ts: clock })
54+
expect(record.heartbeat).toEqual({ pid: 42, ts: clock, seq: null })
5555
expect(readRecord(dir)?.heartbeat?.pid).toBe(42)
5656
})
5757

‎packages/loop-js/src/engine/lock.ts‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ export class Lock {
7373
...base,
7474
epoch: base.epoch + 1,
7575
status: "running",
76-
heartbeat: { pid: this.pid, ts: this.now() },
76+
heartbeat: { pid: this.pid, ts: this.now(), seq: existing?.heartbeat?.seq ?? null },
7777
}
7878
return { claimed, tookOver: decision.kind === "takeover" }
7979
}

‎packages/loop-js/src/engine/loop.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -357,6 +357,8 @@ test("cost commits to the Record per step — a crash loses at most one step's s
357357
if (Date.now() - start > 5000) throw new Error("ledger never saw the step")
358358
await new Promise((res) => setTimeout(res, 10))
359359
}
360+
expect(readRecord(loopDir)?.heartbeat?.seq).toBe(1) // phase-start=0, durable cost event=1
361+
expect((await definition.status()).progressSeq).toBe(1)
360362
release()
361363
await r.done()
362364
expect(readRecord(loopDir)?.cost.usd).toBe(4)

‎packages/loop-js/src/engine/loop.ts‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,7 +123,7 @@ export function define(config: LoopConfig, executor: Executor = claudeExecutor()
123123
// The wipe happens under the Lock we now hold — a live owner was refused above, so
124124
// `fresh` can never clear a Workspace out from under a running Loop.
125125
applyFresh(paths)
126-
record = { ...freshRecord(), epoch: 1, status: "running", heartbeat: { pid: lock.pid, ts: Date.now() } }
126+
record = { ...freshRecord(), epoch: 1, status: "running", heartbeat: { pid: lock.pid, ts: Date.now(), seq: null } }
127127
writeRecord(paths.loopDir, record)
128128
tookOver = false
129129
}
@@ -146,7 +146,11 @@ export function define(config: LoopConfig, executor: Executor = claudeExecutor()
146146

147147
const drive = async (): Promise<void> => {
148148
await journal.foldPartial() // fold any partial stranded by a crash before we resume
149-
const commit = (mutate?: (r: Record) => void): void => commitRecord(paths.loopDir, record, Date.now, mutate)
149+
const commit = (mutate?: (r: Record) => void): void =>
150+
commitRecord(paths.loopDir, record, Date.now, (r) => {
151+
mutate?.(r)
152+
if (r.heartbeat) r.heartbeat.seq = journal.lastSeq
153+
})
150154
if (tookOver) {
151155
commit((r) => {
152156
r.lastExit = { settled: false, cause: "error", reason: "previous Run interrupted mid-Round; taken over" }
@@ -284,7 +288,7 @@ export function define(config: LoopConfig, executor: Executor = claudeExecutor()
284288
const claim = decideClaim(rec, Date.now(), DEFAULT_STALENESS_MS)
285289
return {
286290
running: claim.kind === "busy",
287-
...(claim.kind === "busy" ? { pid: claim.pid } : {}),
291+
...(claim.kind === "busy" ? { pid: claim.pid, progressSeq: rec.heartbeat?.seq ?? null } : {}),
288292
round: rec.cursor,
289293
usd: rec.cost.usd,
290294
lastExit: rec.lastExit,

0 commit comments

Comments
 (0)