From bde1e0c82add5729edc1eee48d14c1d40df1b4f4 Mon Sep 17 00:00:00 2001 From: edenbuilds <279970382+edenbuilds@users.noreply.github.com> Date: Mon, 31 Aug 2026 19:31:04 +0530 Subject: [PATCH] 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 #5 --- docs/api/status.mdx | 2 ++ docs/cli/status.mdx | 8 +++++--- packages/loop-js/src/cli/status.test.ts | 4 ++-- packages/loop-js/src/cli/status.ts | 4 +++- packages/loop-js/src/engine/journal.test.ts | 13 +++++++++++++ packages/loop-js/src/engine/journal.ts | 14 ++++++++++++-- packages/loop-js/src/engine/lock.test.ts | 2 +- packages/loop-js/src/engine/lock.ts | 2 +- packages/loop-js/src/engine/loop.test.ts | 2 ++ packages/loop-js/src/engine/loop.ts | 10 +++++++--- packages/loop-js/src/engine/record.ts | 9 +++++++-- packages/loop-js/src/protocol.ts | 2 ++ 12 files changed, 57 insertions(+), 15 deletions(-) diff --git a/docs/api/status.mdx b/docs/api/status.mdx index 5410a17..e782a63 100644 --- a/docs/api/status.mdx +++ b/docs/api/status.mdx @@ -21,6 +21,7 @@ if (status.running) console.log(`pid ${status.pid} is on round ${status.round}`) type LoopStatus = { running: boolean pid?: number + progressSeq?: number | null round: number usd: number lastExit: Exit | null @@ -32,6 +33,7 @@ type LoopStatus = { | ----- | ------- | | `running` | Whether a Run currently holds the Lock. | | `pid` | The Lock owner's process id — present iff `running`. | +| `progressSeq` | The owner's latest durable journal sequence — present iff `running`; `null` before its first journaled event. | | `round` | The resume cursor — where the next Run picks up. | | `usd` | Total spend across the whole Loop so far. | | `lastExit` | How the last Run ended, or `null` if none has. | diff --git a/docs/cli/status.mdx b/docs/cli/status.mdx index cca040b..1677ec9 100644 --- a/docs/cli/status.mdx +++ b/docs/cli/status.mdx @@ -20,7 +20,7 @@ Four lines, at a glance: ```bash $ loop status -running: yes (pid 41250) +running: yes (pid 41250, journal seq 27) round: 3 spend: $1.42 verdict: not met — the report is missing the summary section @@ -28,7 +28,7 @@ verdict: not met — the report is missing the summary section | Line | Meaning | | ---- | ------- | -| `running` | `yes (pid )` while a process owns the Lock, else `no` | +| `running` | `yes (pid , journal seq )` while a process owns the Lock, including its latest durable progress; else `no` | | `round` | the Round count so far | | `spend` | dollars spent, e.g. `$1.42` | | `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 } ``` -`pid` is present only while the Loop is running. `lastExit` is how the last Run +`pid` and `progressSeq` are present only while the Loop is running. `progressSeq` +is the latest durable journal sequence observed by its heartbeat (`null` before the +first event), so repeated snapshots distinguish liveness from progress. `lastExit` is how the last Run ended — `{ settled: true, verdict }` or `{ settled: false, cause, reason }` — and is `null` before the first Run completes. `verdicts` carries every Verdict so far, each tagged with its Round. diff --git a/packages/loop-js/src/cli/status.test.ts b/packages/loop-js/src/cli/status.test.ts index 1c64f10..17fb024 100644 --- a/packages/loop-js/src/cli/status.test.ts +++ b/packages/loop-js/src/cli/status.test.ts @@ -75,9 +75,9 @@ test("prints the standing: round, spend, and the last verdict with its reason", test("a live owner shows as running, with its pid", async () => { await writeConfig() - writeRecord(join(root, ".loop"), { ...settled(), status: "running", heartbeat: { pid: 4242, ts: Date.now() } }) + writeRecord(join(root, ".loop"), { ...settled(), status: "running", heartbeat: { pid: 4242, ts: Date.now(), seq: 17 } }) const { out } = await run([]) - expect(out).toContain("running: yes (pid 4242)\n") + expect(out).toContain("running: yes (pid 4242, journal seq 17)\n") }) test("--json prints the LoopStatus snapshot, parseable by a wrapper", async () => { diff --git a/packages/loop-js/src/cli/status.ts b/packages/loop-js/src/cli/status.ts index 017b4c9..2476a22 100644 --- a/packages/loop-js/src/cli/status.ts +++ b/packages/loop-js/src/cli/status.ts @@ -28,7 +28,9 @@ export type StatusOptions = { function statusLines(s: LoopStatus): string[] { const last = s.verdicts.at(-1) return [ - s.running ? `running: yes (pid ${s.pid})` : "running: no", + s.running + ? `running: yes (pid ${s.pid}, journal seq ${s.progressSeq === null ? "none" : (s.progressSeq ?? "unknown")})` + : "running: no", `round: ${s.round}`, `spend: $${s.usd.toFixed(2)}`, `verdict: ${last ? verdictText(last) : "none"}`, diff --git a/packages/loop-js/src/engine/journal.test.ts b/packages/loop-js/src/engine/journal.test.ts index 4269c5e..1dbfffd 100644 --- a/packages/loop-js/src/engine/journal.test.ts +++ b/packages/loop-js/src/engine/journal.test.ts @@ -32,6 +32,19 @@ test("seq resumes from the last line on reopen (replay key survives)", async () expect((await j2.append({ type: "text", round: 1, phase: "verify", text: "x" })).seq).toBe(2) }) +test("lastSeq reports durable journal progress, not merely reserved sequence numbers", async () => { + const j = Journal.open(dir) + expect(j.lastSeq).toBeNull() + + j.reserveSeq() // a stream-only observation: assigned, but never journaled + expect(j.lastSeq).toBeNull() + + const written = await j.append({ type: "text", round: 1, phase: "execute", text: "durable" }) + expect(written.seq).toBe(1) + expect(j.lastSeq).toBe(1) + expect(Journal.open(dir).lastSeq).toBe(1) +}) + test("foldPartial folds a stranded sidecar as text{partial:true}, then clears it", async () => { const j = Journal.open(dir) await j.pushDelta(2, "execute", "half a sen") diff --git a/packages/loop-js/src/engine/journal.ts b/packages/loop-js/src/engine/journal.ts index 375ed82..4740e34 100644 --- a/packages/loop-js/src/engine/journal.ts +++ b/packages/loop-js/src/engine/journal.ts @@ -71,6 +71,7 @@ export async function* readJournal(loopDir: string, sinceSeq = 0): AsyncGenerato export class Journal { private seqCounter: number + private lastSeqCounter: number | null private deltaRound = 0 private deltaPhase: JournaledEvent["phase"] = "execute" /** Settles when every append started so far has hit the disk — see {@link flushed}. */ @@ -81,6 +82,7 @@ export class Journal { startSeq: number, ) { this.seqCounter = startSeq + this.lastSeqCounter = startSeq === 0 ? null : startSeq - 1 } /** Synchronous, so `run()` can open the Journal and pin the Run's start seq before returning. */ @@ -93,6 +95,11 @@ export class Journal { return this.seqCounter } + /** The latest sequence number known to be durable in the journal; null before its first event. */ + get lastSeq(): number | null { + return this.lastSeqCounter + } + /** Reserve the next `seq` without writing — for observations that emit live before persisting. */ reserveSeq(): number { return this.seqCounter++ @@ -103,8 +110,11 @@ export class Journal { * {@link flushed} — the ReplaySource contract rests on that. */ write(evt: JournaledEvent): Promise { const op = appendFile(join(this.loopDir, JOURNAL_FILE), JSON.stringify(evt) + "\n", "utf8") - this.tail = Promise.allSettled([this.tail, op]) // never rejects — one failed append cannot poison the chain - return op + const written = op.then(() => { + this.lastSeqCounter = Math.max(this.lastSeqCounter ?? -1, evt.seq) + }) + this.tail = Promise.allSettled([this.tail, written]) // never rejects — one failed append cannot poison the chain + return written } /** diff --git a/packages/loop-js/src/engine/lock.test.ts b/packages/loop-js/src/engine/lock.test.ts index cd0adb0..617dbd9 100644 --- a/packages/loop-js/src/engine/lock.test.ts +++ b/packages/loop-js/src/engine/lock.test.ts @@ -51,7 +51,7 @@ describe("Lock.acquire (synchronous CAS claim)", () => { const { record, tookOver } = new Lock({ loopDir: dir, pid: 42, now }).acquire() expect(tookOver).toBe(false) expect(record.status).toBe("running") - expect(record.heartbeat).toEqual({ pid: 42, ts: clock }) + expect(record.heartbeat).toEqual({ pid: 42, ts: clock, seq: null }) expect(readRecord(dir)?.heartbeat?.pid).toBe(42) }) diff --git a/packages/loop-js/src/engine/lock.ts b/packages/loop-js/src/engine/lock.ts index edcb499..1d2b7af 100644 --- a/packages/loop-js/src/engine/lock.ts +++ b/packages/loop-js/src/engine/lock.ts @@ -73,7 +73,7 @@ export class Lock { ...base, epoch: base.epoch + 1, status: "running", - heartbeat: { pid: this.pid, ts: this.now() }, + heartbeat: { pid: this.pid, ts: this.now(), seq: existing?.heartbeat?.seq ?? null }, } return { claimed, tookOver: decision.kind === "takeover" } } diff --git a/packages/loop-js/src/engine/loop.test.ts b/packages/loop-js/src/engine/loop.test.ts index 785a93d..dd75027 100644 --- a/packages/loop-js/src/engine/loop.test.ts +++ b/packages/loop-js/src/engine/loop.test.ts @@ -357,6 +357,8 @@ test("cost commits to the Record per step — a crash loses at most one step's s if (Date.now() - start > 5000) throw new Error("ledger never saw the step") await new Promise((res) => setTimeout(res, 10)) } + expect(readRecord(loopDir)?.heartbeat?.seq).toBe(1) // phase-start=0, durable cost event=1 + expect((await definition.status()).progressSeq).toBe(1) release() await r.done() expect(readRecord(loopDir)?.cost.usd).toBe(4) diff --git a/packages/loop-js/src/engine/loop.ts b/packages/loop-js/src/engine/loop.ts index a4198d5..283679c 100644 --- a/packages/loop-js/src/engine/loop.ts +++ b/packages/loop-js/src/engine/loop.ts @@ -123,7 +123,7 @@ export function define(config: LoopConfig, executor: Executor = claudeExecutor() // The wipe happens under the Lock we now hold — a live owner was refused above, so // `fresh` can never clear a Workspace out from under a running Loop. applyFresh(paths) - record = { ...freshRecord(), epoch: 1, status: "running", heartbeat: { pid: lock.pid, ts: Date.now() } } + record = { ...freshRecord(), epoch: 1, status: "running", heartbeat: { pid: lock.pid, ts: Date.now(), seq: null } } writeRecord(paths.loopDir, record) tookOver = false } @@ -146,7 +146,11 @@ export function define(config: LoopConfig, executor: Executor = claudeExecutor() const drive = async (): Promise => { await journal.foldPartial() // fold any partial stranded by a crash before we resume - const commit = (mutate?: (r: Record) => void): void => commitRecord(paths.loopDir, record, Date.now, mutate) + const commit = (mutate?: (r: Record) => void): void => + commitRecord(paths.loopDir, record, Date.now, (r) => { + mutate?.(r) + if (r.heartbeat) r.heartbeat.seq = journal.lastSeq + }) if (tookOver) { commit((r) => { 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() const claim = decideClaim(rec, Date.now(), DEFAULT_STALENESS_MS) return { running: claim.kind === "busy", - ...(claim.kind === "busy" ? { pid: claim.pid } : {}), + ...(claim.kind === "busy" ? { pid: claim.pid, progressSeq: rec.heartbeat?.seq ?? null } : {}), round: rec.cursor, usd: rec.cost.usd, lastExit: rec.lastExit, diff --git a/packages/loop-js/src/engine/record.ts b/packages/loop-js/src/engine/record.ts index 543d393..99f644b 100644 --- a/packages/loop-js/src/engine/record.ts +++ b/packages/loop-js/src/engine/record.ts @@ -19,8 +19,13 @@ import type { Exit, Verdict } from "../protocol.ts" export type RunStatus = "running" | "stopped" -/** The Lock's liveness signal: which process holds the Workspace, and when it last proved alive. */ -export type Heartbeat = { pid: number; ts: number } +/** The Lock's liveness signal: owner, last proof of life, and its latest durable journal progress. */ +export type Heartbeat = { + pid: number + ts: number + /** Optional only for Records written before progress-bearing heartbeats shipped. */ + seq?: number | null +} export type VerdictLogEntry = { round: number; verdict: Verdict } diff --git a/packages/loop-js/src/protocol.ts b/packages/loop-js/src/protocol.ts index 2d4730d..e362b4d 100644 --- a/packages/loop-js/src/protocol.ts +++ b/packages/loop-js/src/protocol.ts @@ -60,6 +60,8 @@ export type AgentExit = export type LoopStatus = { running: boolean pid?: number + /** Latest durable journal sequence observed by the owner; present iff running, null before progress. */ + progressSeq?: number | null round: number usd: number lastExit: Exit | null