@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.
- package/CHANGELOG.md +223 -0
- package/dist/bridge/sse/mapper.d.ts +19 -0
- package/dist/bridge/sse/mapper.d.ts.map +1 -1
- package/dist/bridge/sse/mapper.js +9 -1
- package/dist/bridge/sse/mapper.js.map +1 -1
- package/dist/contracts/schemas.d.ts +10 -10
- package/dist/manager/run/persistence.d.ts +26 -0
- package/dist/manager/run/persistence.d.ts.map +1 -1
- package/dist/manager/run/persistence.js +40 -0
- package/dist/manager/run/persistence.js.map +1 -1
- package/dist/public-runtime.d.ts +8 -0
- package/dist/public-runtime.d.ts.map +1 -1
- package/dist/public-runtime.js +31 -0
- package/dist/public-runtime.js.map +1 -1
- package/dist/run/command-gate.d.ts +107 -0
- package/dist/run/command-gate.d.ts.map +1 -0
- package/dist/run/command-gate.js +157 -0
- package/dist/run/command-gate.js.map +1 -0
- package/dist/run/drain.d.ts +194 -0
- package/dist/run/drain.d.ts.map +1 -0
- package/dist/run/drain.js +228 -0
- package/dist/run/drain.js.map +1 -0
- package/dist/run/index.d.ts +8 -0
- package/dist/run/index.d.ts.map +1 -1
- package/dist/run/index.js +4 -0
- package/dist/run/index.js.map +1 -1
- package/dist/run/memory-promoter.d.ts +70 -0
- package/dist/run/memory-promoter.d.ts.map +1 -0
- package/dist/run/memory-promoter.js +117 -0
- package/dist/run/memory-promoter.js.map +1 -0
- package/dist/run/workspace-fingerprint.d.ts +105 -0
- package/dist/run/workspace-fingerprint.d.ts.map +1 -0
- package/dist/run/workspace-fingerprint.js +147 -0
- package/dist/run/workspace-fingerprint.js.map +1 -0
- package/dist/runtime/query/events.d.ts +12 -0
- package/dist/runtime/query/events.d.ts.map +1 -1
- package/dist/runtime/query/events.js +61 -3
- package/dist/runtime/query/events.js.map +1 -1
- package/dist/runtime/query/index.d.ts +36 -0
- package/dist/runtime/query/index.d.ts.map +1 -1
- package/dist/runtime/query/index.js +32 -0
- package/dist/runtime/query/index.js.map +1 -1
- package/dist/runtime/query/resume-run.d.ts +25 -0
- package/dist/runtime/query/resume-run.d.ts.map +1 -1
- package/dist/runtime/query/resume-run.js +15 -3
- package/dist/runtime/query/resume-run.js.map +1 -1
- package/dist/store/index.d.ts +1 -1
- package/dist/store/index.d.ts.map +1 -1
- package/dist/store/index.js +1 -1
- package/dist/store/index.js.map +1 -1
- package/dist/store/run/conformance.d.ts +159 -0
- package/dist/store/run/conformance.d.ts.map +1 -0
- package/dist/store/run/conformance.js +451 -0
- package/dist/store/run/conformance.js.map +1 -0
- package/dist/store/run/disk.d.ts +30 -2
- package/dist/store/run/disk.d.ts.map +1 -1
- package/dist/store/run/disk.js +101 -0
- package/dist/store/run/disk.js.map +1 -1
- package/dist/store/run/memory.d.ts +5 -4
- package/dist/store/run/memory.d.ts.map +1 -1
- package/dist/store/run/memory.js +30 -1
- package/dist/store/run/memory.js.map +1 -1
- package/dist/types/doctor/check.d.ts +28 -2
- package/dist/types/doctor/check.d.ts.map +1 -1
- package/dist/types/run/event-cursor.d.ts +90 -0
- package/dist/types/run/event-cursor.d.ts.map +1 -0
- package/dist/types/run/event-cursor.js +47 -0
- package/dist/types/run/event-cursor.js.map +1 -0
- package/dist/types/run/events.d.ts +71 -1
- package/dist/types/run/events.d.ts.map +1 -1
- package/dist/types/run/events.js.map +1 -1
- package/dist/types/run/index.d.ts +1 -0
- package/dist/types/run/index.d.ts.map +1 -1
- package/dist/types/run/index.js +1 -0
- package/dist/types/run/index.js.map +1 -1
- package/dist/types/run/store.d.ts +41 -1
- package/dist/types/run/store.d.ts.map +1 -1
- package/package.json +6 -1
- package/src/bridge/sse/mapper.ts +28 -1
- package/src/manager/run/persistence.ts +43 -0
- package/src/public-runtime.ts +53 -0
- package/src/run/command-gate.ts +234 -0
- package/src/run/drain.ts +393 -0
- package/src/run/index.ts +20 -0
- package/src/run/memory-promoter.ts +155 -0
- package/src/run/workspace-fingerprint.ts +193 -0
- package/src/runtime/query/events.ts +68 -4
- package/src/runtime/query/index.ts +87 -0
- package/src/runtime/query/resume-run.ts +67 -12
- package/src/store/index.ts +1 -1
- package/src/store/run/conformance.ts +705 -0
- package/src/store/run/disk.ts +106 -2
- package/src/store/run/memory.ts +35 -5
- package/src/types/doctor/check.ts +28 -2
- package/src/types/run/event-cursor.ts +118 -0
- package/src/types/run/events.ts +80 -3
- package/src/types/run/index.ts +1 -0
- 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 (
|
|
75
|
-
|
|
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
|
-
| {
|
|
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 {
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
}
|
package/src/store/index.ts
CHANGED
|
@@ -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'
|