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