@namzu/sdk 20.4.0 → 21.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (98) hide show
  1. package/CHANGELOG.md +223 -0
  2. package/dist/bridge/sse/mapper.d.ts +19 -0
  3. package/dist/bridge/sse/mapper.d.ts.map +1 -1
  4. package/dist/bridge/sse/mapper.js +9 -1
  5. package/dist/bridge/sse/mapper.js.map +1 -1
  6. package/dist/contracts/schemas.d.ts +10 -10
  7. package/dist/manager/run/persistence.d.ts +26 -0
  8. package/dist/manager/run/persistence.d.ts.map +1 -1
  9. package/dist/manager/run/persistence.js +40 -0
  10. package/dist/manager/run/persistence.js.map +1 -1
  11. package/dist/public-runtime.d.ts +8 -0
  12. package/dist/public-runtime.d.ts.map +1 -1
  13. package/dist/public-runtime.js +31 -0
  14. package/dist/public-runtime.js.map +1 -1
  15. package/dist/run/command-gate.d.ts +107 -0
  16. package/dist/run/command-gate.d.ts.map +1 -0
  17. package/dist/run/command-gate.js +157 -0
  18. package/dist/run/command-gate.js.map +1 -0
  19. package/dist/run/drain.d.ts +194 -0
  20. package/dist/run/drain.d.ts.map +1 -0
  21. package/dist/run/drain.js +228 -0
  22. package/dist/run/drain.js.map +1 -0
  23. package/dist/run/index.d.ts +8 -0
  24. package/dist/run/index.d.ts.map +1 -1
  25. package/dist/run/index.js +4 -0
  26. package/dist/run/index.js.map +1 -1
  27. package/dist/run/memory-promoter.d.ts +70 -0
  28. package/dist/run/memory-promoter.d.ts.map +1 -0
  29. package/dist/run/memory-promoter.js +117 -0
  30. package/dist/run/memory-promoter.js.map +1 -0
  31. package/dist/run/workspace-fingerprint.d.ts +105 -0
  32. package/dist/run/workspace-fingerprint.d.ts.map +1 -0
  33. package/dist/run/workspace-fingerprint.js +147 -0
  34. package/dist/run/workspace-fingerprint.js.map +1 -0
  35. package/dist/runtime/query/events.d.ts +12 -0
  36. package/dist/runtime/query/events.d.ts.map +1 -1
  37. package/dist/runtime/query/events.js +61 -3
  38. package/dist/runtime/query/events.js.map +1 -1
  39. package/dist/runtime/query/index.d.ts +36 -0
  40. package/dist/runtime/query/index.d.ts.map +1 -1
  41. package/dist/runtime/query/index.js +32 -0
  42. package/dist/runtime/query/index.js.map +1 -1
  43. package/dist/runtime/query/resume-run.d.ts +25 -0
  44. package/dist/runtime/query/resume-run.d.ts.map +1 -1
  45. package/dist/runtime/query/resume-run.js +15 -3
  46. package/dist/runtime/query/resume-run.js.map +1 -1
  47. package/dist/store/index.d.ts +1 -1
  48. package/dist/store/index.d.ts.map +1 -1
  49. package/dist/store/index.js +1 -1
  50. package/dist/store/index.js.map +1 -1
  51. package/dist/store/run/conformance.d.ts +159 -0
  52. package/dist/store/run/conformance.d.ts.map +1 -0
  53. package/dist/store/run/conformance.js +451 -0
  54. package/dist/store/run/conformance.js.map +1 -0
  55. package/dist/store/run/disk.d.ts +30 -2
  56. package/dist/store/run/disk.d.ts.map +1 -1
  57. package/dist/store/run/disk.js +101 -0
  58. package/dist/store/run/disk.js.map +1 -1
  59. package/dist/store/run/memory.d.ts +5 -4
  60. package/dist/store/run/memory.d.ts.map +1 -1
  61. package/dist/store/run/memory.js +30 -1
  62. package/dist/store/run/memory.js.map +1 -1
  63. package/dist/types/doctor/check.d.ts +28 -2
  64. package/dist/types/doctor/check.d.ts.map +1 -1
  65. package/dist/types/run/event-cursor.d.ts +90 -0
  66. package/dist/types/run/event-cursor.d.ts.map +1 -0
  67. package/dist/types/run/event-cursor.js +47 -0
  68. package/dist/types/run/event-cursor.js.map +1 -0
  69. package/dist/types/run/events.d.ts +71 -1
  70. package/dist/types/run/events.d.ts.map +1 -1
  71. package/dist/types/run/events.js.map +1 -1
  72. package/dist/types/run/index.d.ts +1 -0
  73. package/dist/types/run/index.d.ts.map +1 -1
  74. package/dist/types/run/index.js +1 -0
  75. package/dist/types/run/index.js.map +1 -1
  76. package/dist/types/run/store.d.ts +41 -1
  77. package/dist/types/run/store.d.ts.map +1 -1
  78. package/package.json +6 -1
  79. package/src/bridge/sse/mapper.ts +28 -1
  80. package/src/manager/run/persistence.ts +43 -0
  81. package/src/public-runtime.ts +53 -0
  82. package/src/run/command-gate.ts +234 -0
  83. package/src/run/drain.ts +393 -0
  84. package/src/run/index.ts +20 -0
  85. package/src/run/memory-promoter.ts +155 -0
  86. package/src/run/workspace-fingerprint.ts +193 -0
  87. package/src/runtime/query/events.ts +68 -4
  88. package/src/runtime/query/index.ts +87 -0
  89. package/src/runtime/query/resume-run.ts +67 -12
  90. package/src/store/index.ts +1 -1
  91. package/src/store/run/conformance.ts +705 -0
  92. package/src/store/run/disk.ts +106 -2
  93. package/src/store/run/memory.ts +35 -5
  94. package/src/types/doctor/check.ts +28 -2
  95. package/src/types/run/event-cursor.ts +118 -0
  96. package/src/types/run/events.ts +80 -3
  97. package/src/types/run/index.ts +1 -0
  98. package/src/types/run/store.ts +43 -1
@@ -0,0 +1,193 @@
1
+ /**
2
+ * A hash of everything a run could have changed in its working tree.
3
+ *
4
+ * It exists to answer one question, asked between two attempts at the same
5
+ * verification: **did anything happen since it last failed?** A verify-then-fix
6
+ * loop that re-runs the build after a turn which edited nothing spends a full
7
+ * command execution to learn what a comparison already knew, and does it once
8
+ * per remaining attempt — so a model that has stopped making progress burns
9
+ * the entire budget confirming the same failure.
10
+ *
11
+ * ## What is hashed, and why each part
12
+ *
13
+ * Three sources, because no one of them is complete:
14
+ *
15
+ * 1. **`git status --porcelain`** — which paths differ from the index at all.
16
+ * Cheap, and it catches additions, deletions and mode changes. On its own
17
+ * it is not enough: editing a tracked file that was ALREADY modified
18
+ * leaves the status output byte-identical.
19
+ * 2. **`git diff --binary HEAD`** — the content of every tracked change.
20
+ * `--binary` so an edit to a file git treats as binary is a real diff
21
+ * rather than the constant line `Binary files … differ`, which would make
22
+ * every edit to such a file invisible.
23
+ * 3. **Untracked file contents**, which no `git diff` covers. A new file is
24
+ * named by `status` but its CONTENT is not, so successive edits to a
25
+ * brand-new file would otherwise look like no change at all.
26
+ *
27
+ * ### Symlinks are recorded as their target, not read through
28
+ *
29
+ * Reading a link follows it, so a link repointed from one file to another
30
+ * with identical contents hashes the same — while the thing the workspace
31
+ * actually resolves has changed. The link's target path is the fact that
32
+ * moved, so that is what goes in.
33
+ *
34
+ * ## Failing open, on the cheap side
35
+ *
36
+ * Every uncertainty returns `null`, meaning *no fingerprint*, and a caller
37
+ * that cannot fingerprint re-runs its command. That is the correct direction:
38
+ * the cost of a wrong `null` is one command execution, and the cost of a
39
+ * wrong MATCH is a verification silently skipped — the loop would report
40
+ * "nothing changed" about a workspace that did change, and the model would be
41
+ * told to edit something it had already edited.
42
+ *
43
+ * So: a non-zero exit from any git invocation, a repository with no commits,
44
+ * a timeout, or output past the size cap all produce `null` rather than a
45
+ * partial hash. A truncated diff that hashed successfully would be the worst
46
+ * outcome available here, because two different workspaces truncated at the
47
+ * same point collide.
48
+ */
49
+
50
+ import { createHash } from 'node:crypto'
51
+ import { lstat, readFile, readlink } from 'node:fs/promises'
52
+ import { join } from 'node:path'
53
+
54
+ import type { CommandOptions, CommandResult } from '../types/execution/index.js'
55
+
56
+ /** How a fingerprint runs git. Injected so a test needs no repository. */
57
+ export type FingerprintExec = (
58
+ command: string,
59
+ args: string[],
60
+ options?: CommandOptions,
61
+ ) => Promise<CommandResult>
62
+
63
+ /**
64
+ * The three filesystem reads an untracked entry needs.
65
+ *
66
+ * Injectable for one specific reason, written down because a seam that
67
+ * exists only for tests is usually a smell: **creating a symlink requires a
68
+ * privilege that is not granted by default on Windows**, so the symlink rule
69
+ * below — the one that says a repointed link changes the fingerprint even
70
+ * when the bytes behind it do not — cannot be exercised on a developer
71
+ * machine without it. A rule that can only be checked on some machines is a
72
+ * rule nobody checks.
73
+ *
74
+ * The default is `node:fs/promises` and every other test uses it against a
75
+ * real repository, so this is not a fixture standing in for production; it is
76
+ * one branch of one function reached without a privilege.
77
+ */
78
+ export interface FingerprintFs {
79
+ lstat(path: string): Promise<{ isSymbolicLink(): boolean; isFile(): boolean }>
80
+ readlink(path: string): Promise<string>
81
+ readFile(path: string): Promise<Buffer>
82
+ }
83
+
84
+ const NODE_FS: FingerprintFs = { lstat, readlink, readFile }
85
+
86
+ /**
87
+ * Cap on the bytes any single git invocation may produce.
88
+ *
89
+ * Past it the fingerprint is abandoned rather than hashed. A diff big enough
90
+ * to hit this is a diff nobody is going to iterate on anyway, and hashing a
91
+ * clipped one would let two different trees agree.
92
+ */
93
+ export const FINGERPRINT_MAX_BYTES = 4 * 1024 * 1024
94
+
95
+ /** Default deadline per git invocation. */
96
+ export const FINGERPRINT_TIMEOUT_MS = 20_000
97
+
98
+ export interface WorkspaceFingerprintOptions {
99
+ /** Repository root, or any directory inside it. */
100
+ readonly cwd: string
101
+ /** How to run git. */
102
+ readonly exec: FingerprintExec
103
+ /** Per-invocation deadline. See {@link FINGERPRINT_TIMEOUT_MS}. */
104
+ readonly timeoutMs?: number
105
+ /** See {@link FINGERPRINT_MAX_BYTES}. */
106
+ readonly maxBytes?: number
107
+ /** Filesystem reads. See {@link FingerprintFs}. */
108
+ readonly fs?: FingerprintFs
109
+ }
110
+
111
+ /** One untracked path's contribution, or `null` when it could not be read. */
112
+ async function untrackedEntry(cwd: string, rel: string, fs: FingerprintFs): Promise<string | null> {
113
+ const abs = join(cwd, rel)
114
+ try {
115
+ const stats = await fs.lstat(abs)
116
+ if (stats.isSymbolicLink()) {
117
+ // The TARGET, not what is behind it. Following the link would hash a
118
+ // repointed link to the same value whenever the new target happens
119
+ // to hold the same bytes, and a repoint is a change to the workspace
120
+ // by any reading that matters.
121
+ return `L ${rel}\0${await fs.readlink(abs)}`
122
+ }
123
+ if (!stats.isFile()) return `? ${rel}`
124
+ const body = await fs.readFile(abs)
125
+ return `F ${rel}\0${createHash('sha256').update(body).digest('hex')}`
126
+ } catch {
127
+ // Vanished between the listing and the read, or unreadable. Neither is
128
+ // a fingerprint this function may guess at.
129
+ return null
130
+ }
131
+ }
132
+
133
+ /**
134
+ * A hash of the working tree's uncommitted state, or `null` when it cannot be
135
+ * established.
136
+ *
137
+ * **`null` is never "unchanged".** It means "I cannot tell", and the caller
138
+ * must treat it as a reason to do the work rather than to skip it.
139
+ */
140
+ export async function fingerprintWorkspace(
141
+ options: WorkspaceFingerprintOptions,
142
+ ): Promise<string | null> {
143
+ const { cwd, exec } = options
144
+ const timeoutMs = options.timeoutMs ?? FINGERPRINT_TIMEOUT_MS
145
+ const maxBytes = options.maxBytes ?? FINGERPRINT_MAX_BYTES
146
+ const fs = options.fs ?? NODE_FS
147
+
148
+ const git = async (args: string[]): Promise<string | null> => {
149
+ let result: CommandResult
150
+ try {
151
+ result = await exec('git', args, { cwd, timeoutMs })
152
+ } catch {
153
+ return null
154
+ }
155
+ // A timeout surfaces here as a non-zero exit, and so does "not a
156
+ // repository" and "no commits yet". All three mean the same thing to
157
+ // this function: it has no basis for a comparison.
158
+ if (result.exitCode !== 0) return null
159
+ if (Buffer.byteLength(result.stdout, 'utf8') > maxBytes) return null
160
+ return result.stdout
161
+ }
162
+
163
+ const status = await git(['status', '--porcelain'])
164
+ if (status === null) return null
165
+
166
+ const diff = await git(['diff', '--binary', 'HEAD'])
167
+ if (diff === null) return null
168
+
169
+ const untracked = await git(['ls-files', '--others', '--exclude-standard', '-z'])
170
+ if (untracked === null) return null
171
+
172
+ const parts = [`status ${status}`, `diff ${diff}`]
173
+ // Split on NUL, which is what `-z` is for: a path may contain a newline,
174
+ // and splitting on one would turn a single strange filename into two
175
+ // ordinary-looking ones.
176
+ //
177
+ // Sorted, because `ls-files` order is not part of any contract and a
178
+ // fingerprint that moved when the listing order did would report a change
179
+ // nobody made.
180
+ for (const rel of untracked.split('\0').filter(Boolean).sort()) {
181
+ const entry = await untrackedEntry(cwd, rel, fs)
182
+ if (entry === null) return null
183
+ parts.push(entry)
184
+ }
185
+
186
+ // Length-prefixed rather than delimiter-joined. A diff can contain any
187
+ // byte, so any separator is a separator the content can forge — and two
188
+ // different trees that agreed after forgery would be reported as
189
+ // unchanged, which is the one wrong answer this file is arranged to avoid.
190
+ const hash = createHash('sha256')
191
+ for (const part of parts) hash.update(`${Buffer.byteLength(part, 'utf8')}:${part}`)
192
+ return hash.digest('hex')
193
+ }
@@ -4,6 +4,7 @@ import { buildProbeContext } from '../../probe/context.js'
4
4
  import { type ProbeRegistry, probe as defaultProbeRegistry } from '../../probe/registry.js'
5
5
  import type { ActivityEvent, ActivityStore } from '../../store/activity/memory.js'
6
6
  import type { RunId } from '../../types/ids/index.js'
7
+ import type { ClaimFence } from '../../types/run/checkpoint-store.js'
7
8
  import { isEphemeralEvent } from '../../types/run/events.js'
8
9
  import type { RunEvent } from '../../types/run/index.js'
9
10
  import type { TaskEvent, TaskStore } from '../../types/task/index.js'
@@ -37,6 +38,22 @@ export class EventTranslator {
37
38
  this.probes = probeRegistry
38
39
  }
39
40
 
41
+ /**
42
+ * The claim this run is being written under, when it holds one.
43
+ *
44
+ * Stamped on every durable event as its `generation`, so a consumer whose
45
+ * cursor predates a takeover is told its sequence space changed instead of
46
+ * being handed a splice from a different writer's log.
47
+ */
48
+ private generation: ClaimFence | undefined
49
+
50
+ /** Serializes sequence assignment against the append. See {@link emitEvent}. */
51
+ private appendChain: Promise<void> = Promise.resolve()
52
+
53
+ setGeneration(fence: ClaimFence | undefined): void {
54
+ this.generation = fence
55
+ }
56
+
40
57
  readonly emitEvent: EmitEvent = async (event: RunEvent): Promise<void> => {
41
58
  this.probes.dispatch(event, buildProbeContext({ runId: event.runId }))
42
59
 
@@ -64,15 +81,62 @@ export class EventTranslator {
64
81
  // briefly than to drop a state transition.
65
82
  }
66
83
 
67
- this.pendingEvents.push(event)
68
-
69
84
  // D1 middle path: ephemeral events never enter `transcript.jsonl`.
70
85
  // They live only on the in-memory bus for live UI rendering.
71
86
  // Replay (`runtime/query/replay/prepare.ts`) reads checkpoints
72
87
  // not transcripts, so this preserves replay fidelity while
73
88
  // eliminating the durable bloat review flagged.
74
- if (!isEphemeralEvent(event)) {
75
- await this.runMgr.getRunStore().appendEvent(event)
89
+ if (isEphemeralEvent(event)) {
90
+ // No number, and that is the honest statement: nothing will
91
+ // persist this, so a consumer must never advance a cursor to it.
92
+ this.pendingEvents.push(event)
93
+ return
94
+ }
95
+
96
+ // One appender at a time, and this is not a precaution — it is the fix
97
+ // for a measured defect. Taking the number, awaiting the write and then
98
+ // committing is a read-modify-write, and emits genuinely interleave:
99
+ // the task store, the plan manager and a batch of parallel tools all
100
+ // emit into this one funnel. Measured on a two-tool run, three events
101
+ // took the number 15 and two took 12. A duplicated sequence is worse
102
+ // than a missing one — a consumer asking for everything above 15 is
103
+ // handed part of the run it already had, spliced in as if it were new.
104
+ const previous = this.appendChain
105
+ let release!: () => void
106
+ this.appendChain = new Promise<void>((resolve) => {
107
+ release = resolve
108
+ })
109
+
110
+ try {
111
+ await previous
112
+
113
+ // The number is a claim that the event is IN the log, so it is taken
114
+ // against the append and not before it. The candidate goes to the
115
+ // store first; only a write that landed advances the counter and
116
+ // reaches the live stream carrying it.
117
+ //
118
+ // The failure path still delivers the event — unstamped. A store
119
+ // that cannot record a `run_failed` must not also swallow it, and an
120
+ // unstamped event says exactly what is true of it: it happened, and
121
+ // it is not recoverable.
122
+ const seq = this.runMgr.nextEventSeq()
123
+ const stamped = {
124
+ ...event,
125
+ seq,
126
+ ...(this.generation !== undefined ? { generation: this.generation } : {}),
127
+ } as RunEvent
128
+
129
+ try {
130
+ await this.runMgr.getRunStore().appendEvent(stamped)
131
+ } catch (err) {
132
+ this.pendingEvents.push(event)
133
+ throw err
134
+ }
135
+
136
+ this.runMgr.commitEventSeq(seq)
137
+ this.pendingEvents.push(stamped)
138
+ } finally {
139
+ release()
76
140
  }
77
141
  };
78
142
 
@@ -15,6 +15,7 @@ import { restoreWorkingState, snapshotWorkingState } from '../../compaction/wire
15
15
  import type { CompactionConfig } from '../../config/runtime.js'
16
16
  import { TOOL_OUTPUT_DIR_NAME } from '../../constants/tools/index.js'
17
17
  import { EmergencySaveManager } from '../../manager/run/emergency.js'
18
+ import type { RunPersistence } from '../../manager/run/persistence.js'
18
19
  import { resolveProviderCapabilities } from '../../provider/capabilities.js'
19
20
  import {
20
21
  type ProviderChainMember,
@@ -59,6 +60,8 @@ import type { LLMProvider } from '../../types/provider/index.js'
59
60
  import type { TaskRouterConfig } from '../../types/router/index.js'
60
61
  import type { ReviewAnswer } from '../../types/run/answer-review.js'
61
62
  import type { CheckpointStore, ClaimFence } from '../../types/run/checkpoint-store.js'
63
+ import type { RunEventCursor, RunEventReplay } from '../../types/run/event-cursor.js'
64
+ import { resolveRunEventReplay } from '../../types/run/event-cursor.js'
62
65
  import type {
63
66
  AgentRunConfig,
64
67
  PrepareStepChain,
@@ -445,6 +448,43 @@ export interface QueryParams {
445
448
  */
446
449
  runStore?: RunStore
447
450
 
451
+ /**
452
+ * Where a reconnecting consumer left off, so this run's stream can start by
453
+ * handing back what it missed.
454
+ *
455
+ * The case this serves is the one that exists without a network hop: the
456
+ * process holding the run died, and the consumer watching it is coming back
457
+ * to a run that has to be resumed. Pair it with `resumeFromCheckpoint` — or
458
+ * reach it through {@link import('./resume-run.js').resumeRun}, which is the
459
+ * surface that does both — and the missed durable events are yielded, in
460
+ * order, before the resumed run emits anything of its own.
461
+ *
462
+ * On a run with no log to catch up on the cursor is answered honestly rather
463
+ * than ignored: a `sinceSeq` above what exists is `cursor_ahead`, not
464
+ * silence.
465
+ *
466
+ * What comes back is message-granular. Streaming deltas are never persisted
467
+ * — see {@link import('../../types/run/store.js').RunStore.appendEvent} —
468
+ * so a late subscriber recovers the assistant text, the tool results and the
469
+ * lifecycle, not the keystroke cadence that produced them.
470
+ */
471
+ eventCursor?: RunEventCursor
472
+
473
+ /**
474
+ * What became of {@link QueryParams.eventCursor}.
475
+ *
476
+ * A callback rather than an event on the stream, because the answer is about
477
+ * the SUBSCRIPTION and not about the run — and rather than a throw, because
478
+ * a stale cursor is a client's problem and must not be able to stop a run
479
+ * from continuing. A host that receives `unavailable` re-derives from the
480
+ * transcript; one that receives nothing at all would splice a hole into its
481
+ * state and never know.
482
+ *
483
+ * Called once, before the run's first event, and only when a cursor was
484
+ * supplied.
485
+ */
486
+ onEventReplay?: (replay: RunEventReplay) => void
487
+
448
488
  runId?: RunId
449
489
 
450
490
  parentRunId?: RunId
@@ -1038,6 +1078,10 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
1038
1078
  // fence exists, the refusal exists, and no checkpoint a RUN writes ever
1039
1079
  // carries a number — so a stalled worker is refused nowhere.
1040
1080
  checkpointMgr.setClaimFence(params.claimFence)
1081
+ // And every EVENT it records carries the same fence as its generation,
1082
+ // so a consumer whose cursor was minted under an older holding is told
1083
+ // the sequence space changed rather than handed a splice from it.
1084
+ eventTranslator.setGeneration(params.claimFence)
1041
1085
 
1042
1086
  // A question raised from inside a tool becomes a real checkpoint
1043
1087
  // here. It used to park under a synthetic id nothing ever wrote, so
@@ -1115,6 +1159,20 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
1115
1159
  try {
1116
1160
  await ctx.runMgr.init()
1117
1161
 
1162
+ // A consumer coming back gets what it missed BEFORE the run says
1163
+ // anything new, which is the only order that lets it fold one
1164
+ // stream into one state. It has to follow `init()` — that is what
1165
+ // binds the store and reads the log's head — and precede every
1166
+ // emit below.
1167
+ if (params.eventCursor) {
1168
+ yield* catchUpFromCursor(
1169
+ ctx.runMgr,
1170
+ params.eventCursor,
1171
+ params.onEventReplay,
1172
+ params.claimFence,
1173
+ )
1174
+ }
1175
+
1118
1176
  // Handed over here, and the position is load-bearing in BOTH
1119
1177
  // directions. It has to follow `wirePlanManager`, or a host that
1120
1178
  // builds its plan in this callback — which is what the callback is
@@ -1594,6 +1652,35 @@ export async function* query(params: QueryParams): AsyncGenerator<RunEvent, Run>
1594
1652
  })()
1595
1653
  }
1596
1654
 
1655
+ /**
1656
+ * Hand a returning consumer what it missed, or tell it why it cannot have it.
1657
+ *
1658
+ * Yields NOTHING on a refusal. A partial catch-up is the failure this exists to
1659
+ * prevent: a consumer that receives some of the gap folds it into its state and
1660
+ * cannot tell the state is wrong, where one that receives an explicit
1661
+ * `unavailable` re-derives from the transcript and is right. The run continues
1662
+ * either way — a stale cursor belongs to the client, and must not be able to
1663
+ * stop the work.
1664
+ */
1665
+ async function* catchUpFromCursor(
1666
+ runMgr: RunPersistence,
1667
+ cursor: RunEventCursor,
1668
+ onEventReplay: ((replay: RunEventReplay) => void) | undefined,
1669
+ generation: ClaimFence | undefined,
1670
+ ): AsyncGenerator<RunEvent, void> {
1671
+ const missed = await runMgr.getRunStore().readEvents({ sinceSeq: cursor.sinceSeq })
1672
+ const replay = resolveRunEventReplay(
1673
+ cursor,
1674
+ { lastSeq: runMgr.lastEventSeq, ...(generation !== undefined ? { generation } : {}) },
1675
+ missed,
1676
+ )
1677
+
1678
+ onEventReplay?.(replay)
1679
+
1680
+ if (replay.status !== 'replayed') return
1681
+ for (const event of replay.events) yield event
1682
+ }
1683
+
1597
1684
  export async function drainQuery(
1598
1685
  params: Omit<QueryParams, 'resumeHandler'> & { resumeHandler?: ResumeHandler },
1599
1686
  listener?: RunEventListener,
@@ -1,6 +1,8 @@
1
1
  import type { PendingDecision } from '../../types/hitl/index.js'
2
2
  import type { CheckpointStore, ClaimFence } from '../../types/run/checkpoint-store.js'
3
3
  import type { Run } from '../../types/run/entity.js'
4
+ import type { RunEventReplay } from '../../types/run/event-cursor.js'
5
+ import type { RunEventListener } from '../../types/run/events.js'
4
6
  import type { RunState } from '../../types/run/state.js'
5
7
  import { type QueryParams, drainQuery } from './index.js'
6
8
  import { type RunStateScope, loadRunState } from './run-state.js'
@@ -23,7 +25,21 @@ export type ResumeOutcome =
23
25
  readonly pending: PendingDecision
24
26
  readonly state: RunState
25
27
  }
26
- | { readonly resumed: true; readonly run: Run; readonly state: RunState }
28
+ | {
29
+ readonly resumed: true
30
+ readonly run: Run
31
+ readonly state: RunState
32
+ /**
33
+ * What became of {@link ResumeRunParams.eventCursor}. Absent when no
34
+ * cursor was supplied.
35
+ *
36
+ * Reported rather than thrown: a stale cursor is the client's
37
+ * problem and the run still had to be resumed. A caller that sees
38
+ * `unavailable` re-derives its view from the run's transcript
39
+ * instead of folding a hole into it.
40
+ */
41
+ readonly replay?: RunEventReplay
42
+ }
27
43
 
28
44
  /**
29
45
  * The half of a run that cannot be serialized, plus where to look.
@@ -52,6 +68,20 @@ export interface ResumeRunParams
52
68
  * Absent means the parked checkpoint if there is one, else the newest.
53
69
  */
54
70
  readonly checkpointId?: RunState['checkpointId']
71
+
72
+ /**
73
+ * Where to send the resumed run's events.
74
+ *
75
+ * This call had none, and the consequence was total: it drains the run to
76
+ * completion and every event the run emits — every tool call, every park,
77
+ * every token update — was discarded, because `drainQuery` forwards to a
78
+ * listener and none was ever passed. So the one API for continuing a run
79
+ * another process started could not show anybody what the run was doing.
80
+ *
81
+ * It is also what makes {@link ResumeRunParams.eventCursor} mean anything:
82
+ * a catch-up delivered into a stream nobody receives is not a catch-up.
83
+ */
84
+ readonly listener?: RunEventListener
55
85
  }
56
86
 
57
87
  /**
@@ -76,7 +106,16 @@ export interface ResumeRunParams
76
106
  * resumed past without the answer it is waiting for.
77
107
  */
78
108
  export async function resumeRun(params: ResumeRunParams): Promise<ResumeOutcome> {
79
- const { scope, checkpointStore, checkpointId, pendingDecision, claimFence, ...rest } = params
109
+ const {
110
+ scope,
111
+ checkpointStore,
112
+ checkpointId,
113
+ pendingDecision,
114
+ claimFence,
115
+ listener,
116
+ onEventReplay,
117
+ ...rest
118
+ } = params
80
119
 
81
120
  const state = await loadRunState(checkpointStore, scope, checkpointId)
82
121
  if (!state?.checkpointId) return { resumed: false, reason: 'no-checkpoint' }
@@ -89,15 +128,31 @@ export async function resumeRun(params: ResumeRunParams): Promise<ResumeOutcome>
89
128
  return { resumed: false, reason: 'awaiting-decision', pending: outstanding, state }
90
129
  }
91
130
 
92
- const run = await drainQuery({
93
- ...rest,
94
- messages: [],
95
- runId: state.runId,
96
- resumeFromCheckpoint: state.checkpointId,
97
- checkpointStore,
98
- ...(claimFence !== undefined ? { claimFence } : {}),
99
- ...(pendingDecision ? { pendingDecision } : {}),
100
- } as QueryParams)
131
+ let replay: RunEventReplay | undefined
132
+
133
+ const run = await drainQuery(
134
+ {
135
+ ...rest,
136
+ messages: [],
137
+ runId: state.runId,
138
+ // Forwarded, and it is not cosmetic: the run store nests a sub-run's
139
+ // evidence under `<parent>/children/<run>`, so resuming a sub-run
140
+ // without this binds `<base>/<run>` instead — a second, empty
141
+ // transcript under a run id that already has one, a sequence that
142
+ // restarts at 1, and a catch-up that reports a live run as having
143
+ // produced nothing.
144
+ ...(state.parentRunId !== undefined ? { parentRunId: state.parentRunId } : {}),
145
+ resumeFromCheckpoint: state.checkpointId,
146
+ checkpointStore,
147
+ ...(claimFence !== undefined ? { claimFence } : {}),
148
+ ...(pendingDecision ? { pendingDecision } : {}),
149
+ onEventReplay: (verdict: RunEventReplay) => {
150
+ replay = verdict
151
+ onEventReplay?.(verdict)
152
+ },
153
+ } as QueryParams,
154
+ listener,
155
+ )
101
156
 
102
- return { resumed: true, run, state }
157
+ return { resumed: true, run, state, ...(replay !== undefined ? { replay } : {}) }
103
158
  }
@@ -1,7 +1,7 @@
1
1
  export { InMemoryStore } from './InMemoryStore.js'
2
2
  export type { Identifiable, Timestamped } from './InMemoryStore.js'
3
3
 
4
- export { RunDiskStore } from './run/disk.js'
4
+ export { RunDiskStore, readRunEventsIn } from './run/disk.js'
5
5
  export { InMemoryRunStore } from './run/memory.js'
6
6
  export { DiskCheckpointStore } from './run/checkpoint-disk.js'
7
7
  export type { DiskCheckpointStoreAttribution } from './run/checkpoint-disk.js'