Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions docs/api/status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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. |
Expand Down
8 changes: 5 additions & 3 deletions docs/cli/status.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,15 @@ 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
```

| Line | Meaning |
| ---- | ------- |
| `running` | `yes (pid <n>)` while a process owns the Lock, else `no` |
| `running` | `yes (pid <n>, journal seq <n>)` 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 |
Expand All @@ -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.
Expand Down
4 changes: 2 additions & 2 deletions packages/loop-js/src/cli/status.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down
4 changes: 3 additions & 1 deletion packages/loop-js/src/cli/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"}`,
Expand Down
13 changes: 13 additions & 0 deletions packages/loop-js/src/engine/journal.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
14 changes: 12 additions & 2 deletions packages/loop-js/src/engine/journal.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}. */
Expand All @@ -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. */
Expand All @@ -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++
Expand All @@ -103,8 +110,11 @@ export class Journal {
* {@link flushed} — the ReplaySource contract rests on that. */
write(evt: JournaledEvent): Promise<void> {
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
}

/**
Expand Down
2 changes: 1 addition & 1 deletion packages/loop-js/src/engine/lock.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})

Expand Down
2 changes: 1 addition & 1 deletion packages/loop-js/src/engine/lock.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
}
Expand Down
2 changes: 2 additions & 0 deletions packages/loop-js/src/engine/loop.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
10 changes: 7 additions & 3 deletions packages/loop-js/src/engine/loop.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand All @@ -146,7 +146,11 @@ export function define(config: LoopConfig, executor: Executor = claudeExecutor()

const drive = async (): Promise<void> => {
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" }
Expand Down Expand Up @@ -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,
Expand Down
9 changes: 7 additions & 2 deletions packages/loop-js/src/engine/record.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }

Expand Down
2 changes: 2 additions & 0 deletions packages/loop-js/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Loading