@itookit/dsht 0.5.1 → 0.5.2

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 (66) hide show
  1. package/README.i18n.yaml +2 -2
  2. package/README.md +4 -2
  3. package/README.zh.md +6 -4
  4. package/dist/catalog/controller.d.ts +26 -6
  5. package/dist/catalog/controller.js +73 -45
  6. package/dist/catalog/index.d.ts +1 -0
  7. package/dist/cli/dsht.js +4 -1
  8. package/dist/cli/startup.js +30 -11
  9. package/dist/cli/verifier.d.ts +4 -0
  10. package/dist/cli/verifier.js +28 -5
  11. package/dist/contracts.d.ts +20 -5
  12. package/dist/controller/connection-streams.d.ts +22 -0
  13. package/dist/controller/connection-streams.js +105 -0
  14. package/dist/controller/connection.d.ts +14 -3
  15. package/dist/controller/connection.js +40 -69
  16. package/dist/controller/controller.d.ts +17 -233
  17. package/dist/controller/controller.js +108 -811
  18. package/dist/controller/foreground.d.ts +44 -0
  19. package/dist/controller/foreground.js +79 -0
  20. package/dist/controller/loop-coordinator.d.ts +48 -0
  21. package/dist/controller/loop-coordinator.js +647 -0
  22. package/dist/controller/verifier.d.ts +4 -0
  23. package/dist/cost/controller.d.ts +1 -1
  24. package/dist/cost/controller.js +12 -5
  25. package/dist/cost/scanner.js +1 -0
  26. package/dist/session/controller.d.ts +23 -35
  27. package/dist/session/controller.js +113 -363
  28. package/dist/session/history-reader.d.ts +32 -0
  29. package/dist/session/history-reader.js +170 -0
  30. package/dist/session/index.d.ts +1 -1
  31. package/dist/session/info.d.ts +3 -38
  32. package/dist/session/info.js +14 -1
  33. package/dist/session/interactions.d.ts +26 -0
  34. package/dist/session/interactions.js +75 -0
  35. package/dist/session/navigator.d.ts +47 -0
  36. package/dist/session/navigator.js +158 -0
  37. package/dist/session/prompt-backfill.d.ts +23 -0
  38. package/dist/session/prompt-backfill.js +88 -0
  39. package/dist/session/state.d.ts +20 -0
  40. package/dist/session/state.js +1 -0
  41. package/dist/session/telemetry.d.ts +15 -6
  42. package/dist/session/telemetry.js +44 -7
  43. package/dist/session/transcript.d.ts +5 -1
  44. package/dist/slash/index.d.ts +1 -1
  45. package/dist/slash/parse.d.ts +2 -126
  46. package/dist/slash/registry.d.ts +1 -1
  47. package/dist/slash/types.d.ts +126 -0
  48. package/dist/slash/types.js +1 -0
  49. package/dist/state.d.ts +5 -17
  50. package/dist/state.js +1 -1
  51. package/dist/transport/client.d.ts +4 -3
  52. package/dist/transport/client.js +71 -25
  53. package/dist/ui/app.js +86 -301
  54. package/dist/ui/chat/shell-view.d.ts +2 -0
  55. package/dist/ui/chat/shell-view.js +8 -0
  56. package/dist/ui/chat/use-history-view.d.ts +69 -0
  57. package/dist/ui/chat/use-history-view.js +123 -0
  58. package/dist/ui/dialogs/use-panels.d.ts +53 -0
  59. package/dist/ui/dialogs/use-panels.js +51 -0
  60. package/dist/ui/input/use-composer.d.ts +35 -0
  61. package/dist/ui/input/use-composer.js +109 -0
  62. package/dist/ui/input/use-deferred-lines.d.ts +16 -0
  63. package/dist/ui/input/use-deferred-lines.js +54 -0
  64. package/dist/ui/input/use-history-recall.d.ts +20 -0
  65. package/dist/ui/input/use-history-recall.js +47 -0
  66. package/package.json +1 -1
@@ -0,0 +1,647 @@
1
+ /** Coordinates scored runs; each execution owns its own timers and asynchronous continuations. */
2
+ import { join } from 'node:path';
3
+ import { randomUUID } from 'node:crypto';
4
+ import { listEntries, readText } from "../storage/index.js";
5
+ import { errorText } from "../text.js";
6
+ import { parseLoopResult, ScoredLoop } from "./loop.js";
7
+ import { findingsLines } from "./loop-contract.js";
8
+ import { verificationId, verdictFile } from "./verifier.js";
9
+ const VERIFIER_RETRIES = 2;
10
+ /** Grace for the assistant reply that may commit just after the host's idle frame. */
11
+ const LOOP_SETTLE_GRACE_MS = 4000;
12
+ /** Owns the selected run and its lifetime, while ScoredLoop owns the pure scoring rules. */
13
+ export class LoopCoordinator {
14
+ host;
15
+ options;
16
+ current;
17
+ closed = false;
18
+ constructor(host, options) {
19
+ this.host = host;
20
+ this.options = options;
21
+ }
22
+ get progress() { return this.current?.progress; }
23
+ get sessionId() { return this.current?.sessionId; }
24
+ get verifierName() { return this.options.verifier?.name; }
25
+ get forkedVerification() { return this.options.verifier !== undefined; }
26
+ get selfScoring() { return !this.forkedVerification || this.options.allowSelfFallback === true; }
27
+ async start(protocol, limits) {
28
+ if (this.closed)
29
+ throw new Error('Client stopped');
30
+ const sessionId = this.host.facts().sessionId;
31
+ if (sessionId === undefined) {
32
+ this.host.trace('loop', { phase: 'refused', kind: protocol.kind, reason: 'no-session' });
33
+ throw new Error('Select a session first');
34
+ }
35
+ this.forget();
36
+ const run = new LoopExecution(this.host, this.options, () => this.current === run && !this.closed);
37
+ this.current = run;
38
+ await run.startLoop(sessionId, protocol, limits);
39
+ }
40
+ stop(reason = 'user-cancelled') { this.current?.stopLoop(reason); }
41
+ answer(text) {
42
+ if (this.closed || this.current === undefined)
43
+ return Promise.reject(new Error('No loop is waiting for an answer'));
44
+ return this.current.answerLoop(text);
45
+ }
46
+ clearResult() {
47
+ if (this.current === undefined || this.progress?.active)
48
+ return;
49
+ this.forget();
50
+ this.host.publish();
51
+ }
52
+ forget() {
53
+ const previous = this.current;
54
+ this.current = undefined;
55
+ previous?.forgetLoop();
56
+ }
57
+ /** Called after application facts or transcript content change. */
58
+ changed() { if (!this.closed)
59
+ this.current?.changed(); }
60
+ idle(sessionId) { if (!this.closed)
61
+ this.current?.settleLoop(sessionId); }
62
+ /** Stop timers and verifier work before the application tears down the connection. */
63
+ async close() {
64
+ this.closed = true;
65
+ this.current?.stopLoop();
66
+ await this.options.verifier?.settle?.();
67
+ }
68
+ }
69
+ /** Never reused for another run: a late callback can only touch the execution that created it. */
70
+ class LoopExecution {
71
+ host;
72
+ owns;
73
+ /** Running agent loop, if any; the loop lives here, not in the UI. */
74
+ loop;
75
+ /** Prompt the loop still has to send, when it could not be sent immediately. */
76
+ pendingPrompt;
77
+ /** When the in-flight attempt's turn ended, while its result block is still awaited. */
78
+ endedAt;
79
+ /** Timer that settles an attempt whose reply never commits its result block. */
80
+ settleTimer;
81
+ /** Whether an attempt is currently being judged by an independent verifier. */
82
+ verifying = false;
83
+ /** A verdict is being consumed right now.
84
+ *
85
+ * Consuming one reads the artifact, so it is asynchronous; without this gate a replayed idle edge
86
+ * could start a second verification of an attempt whose verdict is already being applied.
87
+ */
88
+ settling = false;
89
+ /** Cancels that verification when the loop is stopped or replaced. */
90
+ verifyAbort;
91
+ /** Verdict of the previous attempt on the current step, for the retry and the next verifier. */
92
+ previous;
93
+ /** Whether the run in flight already wrote its `loop end`; one end per begin (I9). */
94
+ endTraced = false;
95
+ /** Sequence of the verification task in flight, so a retry never reuses the old task's identity. */
96
+ verificationSeq = 0;
97
+ /** Identity of the verification task whose result may still decide the attempt in flight. */
98
+ verificationIdentity;
99
+ /** Timer that stops the whole run when its budget expires. */
100
+ deadlineTimer;
101
+ /** Whole-run budget, when the operator set one. */
102
+ deadlineMs;
103
+ /** Consecutive verifier outages in this attempt, so a broken verifier is retried then reported. */
104
+ verifierMisses = 0;
105
+ /** What the operator answered when a verifier abstained; consumed by the next judgment. */
106
+ answerText;
107
+ /** Whether a reply block may stand in for a missing verdict; off unless asked for. */
108
+ allowSelfFallback;
109
+ /** Client-side directory verdict files are written under. */
110
+ verdictRoot;
111
+ verifier;
112
+ localDirectory;
113
+ constructor(host, options, owns) {
114
+ this.host = host;
115
+ this.owns = owns;
116
+ this.verifier = options.verifier;
117
+ this.localDirectory = options.directory;
118
+ this.verdictRoot = options.verdictRoot ?? options.directory;
119
+ this.deadlineMs = options.deadlineMs;
120
+ this.allowSelfFallback = options.allowSelfFallback === true;
121
+ }
122
+ get progress() { return this.loop?.progress; }
123
+ get sessionId() { return this.loop?.sessionId; }
124
+ changed() {
125
+ if (this.pendingPrompt !== undefined)
126
+ void this.flushLoop();
127
+ if (this.endedAt !== undefined)
128
+ this.trySettleLoop();
129
+ }
130
+ update(patch) {
131
+ if (this.owns())
132
+ this.host.publish(patch.lastFailure);
133
+ }
134
+ isCurrent(loop) { return this.owns() && this.loop === loop && loop.active; }
135
+ traceEvent(event, detail) { this.host.trace(event, detail); }
136
+ /** Start a scored loop and send its opening step.
137
+ * @param protocol - Prompt text and step count the loop follows.
138
+ * @param limits - Resolved `--from/--to/--score/--tries`.
139
+ */
140
+ async startLoop(sessionId, protocol, limits) {
141
+ const runId = randomUUID();
142
+ this.endTraced = false;
143
+ this.traceEvent('loop', { phase: 'begin', runId, kind: protocol.kind, session: sessionId,
144
+ from: limits.from, to: limits.to, score: limits.score, tries: limits.tries, forked: this.verifier !== undefined });
145
+ const loop = new ScoredLoop(runId, sessionId, protocol, limits);
146
+ this.loop = loop;
147
+ // A protocol that reviews something already on disk verifies first: a round that passes costs no
148
+ // work turn at all, and only a failing verdict asks the agent to change anything.
149
+ if (this.startsByVerifying(loop)) {
150
+ this.armLoopDeadline();
151
+ this.update({});
152
+ this.traceEvent('loop', { phase: 'verify-first', runId, kind: protocol.kind, step: limits.from });
153
+ this.verifyStep(loop);
154
+ return;
155
+ }
156
+ const prompt = loop.start();
157
+ loop.sent();
158
+ this.armLoopDeadline();
159
+ this.update({});
160
+ try {
161
+ if (!this.isCurrent(loop))
162
+ return;
163
+ await this.host.send(prompt);
164
+ this.traceEvent('loop', { phase: 'sent', runId, kind: protocol.kind, step: limits.from, attempt: 1 });
165
+ }
166
+ catch (error) {
167
+ // The run never got its first turn. It ends needing a person rather than silently vanishing:
168
+ // the reason says the send was rejected, and the snapshot stays readable.
169
+ this.rejectLoopSend(loop, error);
170
+ throw error;
171
+ }
172
+ }
173
+ /** End a run whose next prompt could not be sent, keeping the reason visible.
174
+ *
175
+ * A rejected send is not a verdict and not an operator cancellation, so the phase is `needs-human`
176
+ * and `terminalReason` says why — this is what keeps `phase=cancelled` meaning "a person stopped it".
177
+ * @param loop - Run that could not send.
178
+ * @param error - What the host or the session layer rejected with.
179
+ */
180
+ rejectLoopSend(loop, error) {
181
+ if (!this.isCurrent(loop))
182
+ return;
183
+ const text = errorText(error).slice(0, 200);
184
+ this.forgetSettleTimer();
185
+ this.clearLoopDeadline();
186
+ this.abortVerification();
187
+ loop.human({ kind: 'send', text }, 'send-rejected');
188
+ this.traceLoopEnd(loop);
189
+ this.pendingPrompt = undefined;
190
+ this.update({ lastFailure: text });
191
+ }
192
+ /** Record the end of one run once, with the phase it stopped in and why.
193
+ *
194
+ * I9 needs every `begin` to be paired inside the trace window; this is the only writer of `loop end`.
195
+ * @param loop - Run whose terminal phase was just published.
196
+ */
197
+ traceLoopEnd(loop) {
198
+ if (this.endTraced)
199
+ return;
200
+ this.endTraced = true;
201
+ const progress = loop.progress;
202
+ this.traceEvent('loop', { phase: 'end', runId: progress.runId, kind: loop.protocol.kind,
203
+ result: progress.phase, step: progress.step, attempt: progress.attempt,
204
+ reason: progress.terminalReason ?? 'unknown' });
205
+ }
206
+ /** Whether a step begins with verification rather than with a work prompt.
207
+ *
208
+ * Needs all three: the record asks for it, the record has a verifier prompt, and this client was
209
+ * given a verifier. Otherwise there is nobody to verify first, and the step asks for work.
210
+ * @param loop - Loop about to start a step.
211
+ * @returns True when the step starts by verifying.
212
+ */
213
+ startsByVerifying(loop) {
214
+ return loop.protocol.starts === 'verify' && loop.protocol.verify !== undefined && this.verifier !== undefined;
215
+ }
216
+ /** Verify the step in flight without a work turn before it.
217
+ *
218
+ * The activity is set inside `verifyRound`, so the first verification, a work-turn verdict and a
219
+ * retry all publish the same state without this caller having to remember it.
220
+ * @param loop - Loop whose step and attempt are already set.
221
+ */
222
+ verifyStep(loop) {
223
+ void this.verifySection(loop);
224
+ }
225
+ /** Check the round's own section before spending a verifier on it, then verify or ask for work.
226
+ *
227
+ * Verify-first exists so a round that already passes costs no work turn. When the section is not in
228
+ * the artifact at all the round *cannot* pass, and this client can read that itself — the same check
229
+ * it applies to a verdict before accepting one. Sending a verifier to discover "the file is missing"
230
+ * costs a session and, because a failed attempt consumes one, also the round's first attempt: a run
231
+ * over a document with no review yet would start working at attempt 2/10. An artifact this client
232
+ * cannot read stays a boundary: the verification runs and its verdict decides, as before.
233
+ * @param loop - Loop whose step and attempt are already set.
234
+ */
235
+ async verifySection(loop) {
236
+ if (!this.isCurrent(loop))
237
+ return;
238
+ const artifact = loop.protocol.artifact;
239
+ const marker = loop.protocol.artifactMarker?.(loop.progress.step);
240
+ if (artifact === undefined || marker === undefined) {
241
+ void this.verifyRound(loop);
242
+ return;
243
+ }
244
+ const section = await this.artifactSection(artifact, marker);
245
+ if (!this.isCurrent(loop))
246
+ return;
247
+ if (section !== 'missing') {
248
+ void this.verifyRound(loop);
249
+ return;
250
+ }
251
+ this.traceEvent('loop', { phase: 'work-first', runId: loop.runId, kind: loop.protocol.kind,
252
+ step: loop.progress.step, artifact, reason: 'section-missing' });
253
+ loop.note(`⚠ artifact check · ${artifact} 还没有本轮小节,直接开始工作`);
254
+ // No verdict was consumed, so the attempt is untouched: the first work turn is attempt 1, exactly
255
+ // as it is for a record that asks for work first.
256
+ this.pendingPrompt = loop.workPrompt();
257
+ this.update({});
258
+ void this.flushLoop();
259
+ }
260
+ /** Both preflight and settlement distinguish a missing artifact from an invisible workspace. */
261
+ async artifactSection(artifact, marker) {
262
+ try {
263
+ const text = await readText(join(this.localDirectory, artifact));
264
+ if (text !== undefined)
265
+ return text.includes(marker) ? 'present' : 'missing';
266
+ // An absent file proves a missing section only when this machine can see the workspace.
267
+ await listEntries(this.localDirectory);
268
+ return 'missing';
269
+ }
270
+ catch {
271
+ return 'unavailable';
272
+ }
273
+ }
274
+ /** Arm the whole-run budget, when the operator set one. */
275
+ armLoopDeadline() {
276
+ if (this.deadlineMs === undefined)
277
+ return;
278
+ this.deadlineTimer = setTimeout(() => this.expireLoopDeadline(), this.deadlineMs);
279
+ this.deadlineTimer.unref();
280
+ }
281
+ /** Answer a paused run, so the current artifact is judged again with what the operator supplied.
282
+ *
283
+ * The answer is not a work order: it only adds a condition to the judgment, so nothing is sent to the
284
+ * agent and no attempt is consumed. The verification that follows is a new task with a new identity,
285
+ * which is what keeps a late verdict from the paused one from deciding the attempt.
286
+ * @param text - What the operator added; never written to the trace.
287
+ */
288
+ async answerLoop(text) {
289
+ const loop = this.loop;
290
+ if (loop === undefined || !loop.active)
291
+ throw new Error('No loop is waiting for an answer');
292
+ this.answerText = text;
293
+ const judged = this.verifier !== undefined && loop.protocol.verify !== undefined;
294
+ loop.resume(judged ? 'verify' : 'turn');
295
+ this.update({});
296
+ this.traceEvent('loop', { phase: 'answered', runId: loop.progress.runId, judged, chars: text.length });
297
+ if (judged) {
298
+ this.verifyStep(loop);
299
+ return;
300
+ }
301
+ // Without a forked verifier the answer is the next attempt's instruction: the agent is asked again
302
+ // with the addition, and the attempt budget still decides how many times that may happen.
303
+ this.pendingPrompt = loop.answerPrompt(text);
304
+ void this.flushLoop();
305
+ }
306
+ /** Stop a running review; the terminal progress stays visible for the reader.
307
+ * @param reason - Why it stopped; the default is an operator action.
308
+ */
309
+ stopLoop(reason = 'user-cancelled') {
310
+ if (this.loop === undefined || !this.loop.active)
311
+ return;
312
+ this.forgetSettleTimer();
313
+ this.clearLoopDeadline();
314
+ this.abortVerification();
315
+ this.endedAt = undefined;
316
+ this.loop.cancel(reason);
317
+ this.traceLoopEnd(this.loop);
318
+ this.pendingPrompt = undefined;
319
+ this.update({});
320
+ }
321
+ /** Drop the loop entirely, without publishing a cancelled phase.
322
+ * @param reason - Why it was dropped, recorded when it never reached a terminal phase itself.
323
+ */
324
+ forgetLoop(reason = 'replaced') {
325
+ this.stopLoop(reason);
326
+ this.loop = undefined;
327
+ }
328
+ /** Note that an attempt's turn ended; its block may still be arriving.
329
+ * @param sessionId - Session the host reported idle.
330
+ */
331
+ settleLoop(sessionId) {
332
+ const loop = this.loop;
333
+ if (loop === undefined || !loop.active || !loop.settled || loop.sessionId !== sessionId)
334
+ return;
335
+ this.endedAt ??= Date.now();
336
+ this.trySettleLoop();
337
+ }
338
+ /** Consume the attempt once its result block is committed, or the grace period expires.
339
+ *
340
+ * The final assistant message can land a moment after the idle event, so an empty parse is not yet
341
+ * a failed attempt: the next publish retries, and one timer covers a transcript that never grows.
342
+ */
343
+ trySettleLoop() {
344
+ const loop = this.loop;
345
+ if (loop === undefined || !loop.active || !loop.settled) {
346
+ this.forgetSettleTimer();
347
+ this.endedAt = undefined;
348
+ return;
349
+ }
350
+ // Only a turn that actually ended may be consumed: a timer that fired just before the attempt
351
+ // settled must not decide the attempt that replaced it.
352
+ const endedAt = this.endedAt;
353
+ if (endedAt === undefined) {
354
+ this.forgetSettleTimer();
355
+ return;
356
+ }
357
+ // An independent verifier decides the round; the reply block below stays as its fallback. Its
358
+ // branch keeps its own `verify` sub-state, so this must not announce settling over it.
359
+ if (loop.protocol.verify !== undefined && this.verifier !== undefined) {
360
+ if (!this.verifying && !this.settling)
361
+ void this.verifyRound(loop);
362
+ return;
363
+ }
364
+ // No forked verifier: reading the reply block, including the grace wait for it, is settling.
365
+ loop.settling();
366
+ const result = parseLoopResult(this.host.reply(), loop.protocol);
367
+ if (result === undefined && Date.now() - endedAt < LOOP_SETTLE_GRACE_MS) {
368
+ if (this.settleTimer === undefined) {
369
+ this.settleTimer = setTimeout(() => { this.settleTimer = undefined; this.trySettleLoop(); }, LOOP_SETTLE_GRACE_MS);
370
+ this.settleTimer.unref();
371
+ }
372
+ return;
373
+ }
374
+ this.forgetSettleTimer();
375
+ this.endedAt = undefined;
376
+ void this.settleChecked(loop, result);
377
+ }
378
+ /** Score one finished round out of band, in a process of its own.
379
+ *
380
+ * Verifier outages retry without spending an attempt. A reply block is a fallback only when the
381
+ * operator enabled it; otherwise the run reports verification unavailable.
382
+ * @param loop - The run whose attempt just finished.
383
+ */
384
+ async verifyRound(loop) {
385
+ if (!this.isCurrent(loop) || this.verifying)
386
+ return;
387
+ const verifier = this.verifier;
388
+ if (verifier === undefined)
389
+ return;
390
+ const { step, attempt } = loop.progress;
391
+ const { kind } = loop.protocol;
392
+ const runId = loop.runId;
393
+ // Every started verification is its own task: a failure retry or an operator answer must not
394
+ // reuse the identity or the file of the task it replaced, or a late verdict could decide it.
395
+ const seq = this.verificationSeq += 1;
396
+ const file = verdictFile(this.verdictRoot, runId, kind, step, attempt, seq);
397
+ const identity = verificationId(runId, kind, step, attempt, seq);
398
+ const prompt = loop.protocol.verify(loop.progress, step, attempt, { file, verificationId: identity }, this.previous);
399
+ // An answer the operator gave after an abstention only adds a condition: the same artifact is
400
+ // judged again, under a fresh identity, and no attempt is consumed for it.
401
+ const answered = this.answerText === undefined ? prompt : `${prompt}\n\n## 操作者的补充判断\n${this.answerText}`;
402
+ this.verifying = true;
403
+ this.verificationIdentity = identity;
404
+ // The sub-state is a fact on the loop, not a note: the progress line and the status bar both read
405
+ // it, and a retry's warning note stays beside it instead of being overwritten by a string.
406
+ loop.verifying();
407
+ const abort = new AbortController();
408
+ this.verifyAbort = abort;
409
+ this.traceEvent('verify', { runId, phase: 'begin', kind, step, attempt, seq, file });
410
+ this.update({});
411
+ let outcome;
412
+ try {
413
+ // The reviewed workspace is declared here, where it is known, so the verifier's artifact check
414
+ // never has to infer it from whatever directory the process happens to run in.
415
+ outcome = abort.signal.aborted ? { type: 'cancelled' } : await verifier.verify({ verificationId: identity, kind, step, attempt, prompt: answered, file,
416
+ workspace: this.localDirectory,
417
+ ...(loop.protocol.artifact === undefined ? {} : { artifact: loop.protocol.artifact }),
418
+ title: `[dsht-verify] ${loop.protocol.title} · ${step}/${attempt}` }, abort.signal);
419
+ }
420
+ catch (error) {
421
+ outcome = { type: 'unavailable', reason: `verifier failed: ${errorText(error)}` };
422
+ }
423
+ finally {
424
+ if (this.verifyAbort === abort) {
425
+ this.verifyAbort = undefined;
426
+ this.verifying = false;
427
+ }
428
+ }
429
+ // A newer verification task owns this attempt now: this task's verdict is stale by definition.
430
+ if (this.verificationIdentity !== identity) {
431
+ this.traceEvent('verify', { runId, phase: 'stale', kind, step, attempt, seq });
432
+ return;
433
+ }
434
+ // Verification outlives nothing: a cancelled or replaced loop must not be settled by its result.
435
+ if (!this.isCurrent(loop) || !loop.settled) {
436
+ this.traceEvent('verify', { runId, phase: 'abandoned', kind, step, attempt, seq });
437
+ return;
438
+ }
439
+ this.forgetSettleTimer();
440
+ this.endedAt = undefined;
441
+ // The review was cancelled: nothing to decide, and nothing to report as a verdict.
442
+ if (outcome.type === 'cancelled') {
443
+ this.traceEvent('verify', { runId, phase: 'cancelled', kind, step, attempt, seq });
444
+ return;
445
+ }
446
+ // The host is waiting for a human the verifier cannot answer: stop with the request attached.
447
+ // The verifier is blocked, not broken, so retrying would only hit the same wall three times.
448
+ if (outcome.type === 'needs-human') {
449
+ this.traceEvent('verify', { runId, phase: 'needs-human', kind, step, attempt, seq, request: outcome.request.kind });
450
+ this.clearLoopDeadline();
451
+ loop.human(outcome.request);
452
+ this.traceLoopEnd(loop);
453
+ this.update({});
454
+ return;
455
+ }
456
+ if (outcome.type === 'verified') {
457
+ this.traceEvent('verify', { runId, phase: 'verified', kind, step, attempt, seq,
458
+ score: outcome.result.score ?? -1, blocked: outcome.result.blocked === true, status: outcome.result.status ?? 'none' });
459
+ this.verifierMisses = 0;
460
+ await this.settleChecked(loop, outcome.result);
461
+ return;
462
+ }
463
+ // Unavailable is not a verdict, so it never consumes the attempt: retry the verifier, and only
464
+ // then stop the run. Self-scoring is opt-in and is always visible in the progress line.
465
+ // A failure that says it is not retryable (an unconfirmed remote cancel, a bad configuration)
466
+ // would only repeat itself, so it is reported instead of spending the retry budget.
467
+ if (outcome.retryable === false) {
468
+ this.traceEvent('verify', { runId, phase: 'unavailable', kind, step, attempt, seq, retryable: false, reason: outcome.reason.slice(0, 200) });
469
+ loop.note(`⚠ verification unavailable · ${outcome.reason}`);
470
+ this.clearLoopDeadline();
471
+ loop.unavailable();
472
+ this.traceLoopEnd(loop);
473
+ this.update({});
474
+ return;
475
+ }
476
+ this.verifierMisses += 1;
477
+ if (this.verifierMisses <= VERIFIER_RETRIES) {
478
+ this.traceEvent('verify', { runId, phase: 'retry', kind, step, attempt, seq, misses: this.verifierMisses, reason: outcome.reason.slice(0, 200) });
479
+ loop.note(`⚠ verification unavailable · retrying (${outcome.reason})`);
480
+ this.update({});
481
+ void this.verifyRound(loop);
482
+ return;
483
+ }
484
+ if (this.allowSelfFallback) {
485
+ const fallback = parseLoopResult(this.host.reply(), loop.protocol);
486
+ this.traceEvent('verify', { runId, phase: 'fallback', kind, step, attempt, seq, block: fallback !== undefined });
487
+ await this.settleChecked(loop, fallback, fallback === undefined
488
+ ? `⚠ verification fallback · self-reported (and no reply block: ${outcome.reason})`
489
+ : '⚠ verification fallback · self-reported');
490
+ return;
491
+ }
492
+ this.traceEvent('verify', { runId, phase: 'unavailable', kind, step, attempt, seq, retryable: true, misses: this.verifierMisses, reason: outcome.reason.slice(0, 200) });
493
+ loop.note(`⚠ verification unavailable · ${outcome.reason}`);
494
+ this.clearLoopDeadline();
495
+ loop.unavailable();
496
+ this.traceLoopEnd(loop);
497
+ this.update({});
498
+ }
499
+ /** Apply a protocol's own artifact requirement, then settle the attempt.
500
+ *
501
+ * The score is the verifier's judgement; whether the round's conclusion actually reached the
502
+ * artifact is a fact this client checks itself. A hard condition may not be overridden by a score:
503
+ * a round whose section is missing fails even at 10/10, and the missing line is fed back to the
504
+ * next attempt like any other finding. An artifact this client cannot read cannot be checked, so
505
+ * the verdict stands rather than being failed on a boundary.
506
+ * @param loop - The run whose attempt just finished.
507
+ * @param result - Verdict to consume.
508
+ * @param note - Note to show instead, when the check accepted the verdict unchanged.
509
+ */
510
+ async settleChecked(loop, result, note = '') {
511
+ if (!this.isCurrent(loop) || !loop.settled || this.settling)
512
+ return;
513
+ this.settling = true;
514
+ try {
515
+ const marker = result === undefined || result.blocked === true || result.abstained === true
516
+ ? undefined : loop.protocol.artifactMarker?.(loop.progress.step);
517
+ const artifact = loop.protocol.artifact;
518
+ if (marker === undefined || artifact === undefined || result === undefined) {
519
+ this.settleWith(loop, result, note);
520
+ return;
521
+ }
522
+ const section = await this.artifactSection(artifact, marker);
523
+ if (!this.isCurrent(loop) || !loop.settled)
524
+ return;
525
+ if (section === 'unavailable') {
526
+ this.settleWith(loop, result, note || '⚠ artifact check unavailable · using the verdict');
527
+ return;
528
+ }
529
+ if (section === 'present') {
530
+ this.settleWith(loop, result, note);
531
+ return;
532
+ }
533
+ this.traceEvent('artifact', { phase: 'missing', artifact, step: loop.progress.step, score: result.score ?? -1 });
534
+ const reported = result.score === undefined ? '没有分数' : `${result.score} 分`;
535
+ this.settleWith(loop, { ...result, score: undefined,
536
+ findings: [...(result.findings ?? []), `工作区文件 ${artifact} 缺少本轮小节「${marker}」`] }, `⚠ artifact check · ${artifact} 缺少本轮小节「${marker}」(验证者给了 ${reported},本轮不通过)`);
537
+ }
538
+ finally {
539
+ this.settling = false;
540
+ }
541
+ }
542
+ /** Apply one attempt's verdict, and continue the run when it has a next step.
543
+ * @param loop - The run being settled.
544
+ * @param result - Verdict to consume, or undefined when the attempt produced none.
545
+ */
546
+ settleWith(loop, result, note = '') {
547
+ if (!this.isCurrent(loop) || !loop.settled)
548
+ return;
549
+ const before = loop.progress;
550
+ const step = loop.settle(result);
551
+ // The answer was the condition for this judgment, so it is spent once the verdict is in.
552
+ this.answerText = undefined;
553
+ // A pause is not an end: the run keeps its span (and its deadline) until it is answered or ended.
554
+ if (step.kind !== 'continue' && !loop.active) {
555
+ this.clearLoopDeadline();
556
+ // A decision ended the run: close its trace span with the phase and reason it stopped in.
557
+ this.traceLoopEnd(loop);
558
+ }
559
+ // The note describes the attempt just decided, so it is applied after the state moved on.
560
+ loop.note(note);
561
+ if (step.kind === 'continue') {
562
+ // A retry on the same step carries the verdict that caused it; a new step starts clean.
563
+ this.previous = before.step === loop.progress.step && result !== undefined
564
+ ? { step: before.step, attempt: before.attempt, result } : undefined;
565
+ // A passing verdict advanced the step. When the record verifies first, the new round is
566
+ // verified against the artifact as it stands before anyone is asked to change it.
567
+ if (loop.progress.step !== before.step && this.startsByVerifying(loop)) {
568
+ this.update({});
569
+ this.verifyStep(loop);
570
+ return;
571
+ }
572
+ const findings = result === undefined ? [] : findingsLines(result);
573
+ // `step.prompt` is already brief-aware: a run that began by verifying has sent no prompt yet, so
574
+ // its first work message is the brief rather than a follow-up that describes nothing.
575
+ this.pendingPrompt = [step.prompt, ...findings].join('\n');
576
+ }
577
+ this.update({});
578
+ if (step.kind === 'continue')
579
+ void this.flushLoop();
580
+ }
581
+ /** Stop an in-flight verification, if any; its result can no longer decide anything. */
582
+ abortVerification() {
583
+ this.verifyAbort?.abort();
584
+ this.verifyAbort = undefined;
585
+ this.verifying = false;
586
+ }
587
+ /** Stop the run because the whole-run budget expired; a budget stop, not a verdict.
588
+ *
589
+ * Any verification in flight is cancelled (bounded, as everywhere else) and its late result can
590
+ * no longer decide anything, because the loop is no longer active.
591
+ */
592
+ expireLoopDeadline() {
593
+ this.deadlineTimer = undefined;
594
+ const loop = this.loop;
595
+ if (loop === undefined || !loop.active)
596
+ return;
597
+ this.forgetSettleTimer();
598
+ this.abortVerification();
599
+ this.endedAt = undefined;
600
+ loop.note('⚠ deadline reached');
601
+ loop.deadline();
602
+ this.traceLoopEnd(loop);
603
+ this.update({});
604
+ }
605
+ /** Drop the run deadline, if one is armed. */
606
+ clearLoopDeadline() {
607
+ if (this.deadlineTimer === undefined)
608
+ return;
609
+ clearTimeout(this.deadlineTimer);
610
+ this.deadlineTimer = undefined;
611
+ }
612
+ /** Drop a pending settle timer, if any. */
613
+ forgetSettleTimer() {
614
+ if (this.settleTimer === undefined)
615
+ return;
616
+ clearTimeout(this.settleTimer);
617
+ this.settleTimer = undefined;
618
+ }
619
+ /** Send the prompt the loop is holding, once the client can actually send it. */
620
+ async flushLoop() {
621
+ const loop = this.loop;
622
+ const prompt = this.pendingPrompt;
623
+ if (loop === undefined || prompt === undefined || !this.isCurrent(loop))
624
+ return;
625
+ // Nothing of this loop writes while an independent verifier is judging it: the round under
626
+ // verification must be the round that was scored. A prompt that appears meanwhile is sent when
627
+ // the verdict lands (or dropped with the run), never interleaved with the verification.
628
+ if (this.verifying)
629
+ return;
630
+ const facts = this.host.facts();
631
+ if (facts.sessionId !== loop.sessionId || !facts.online || !facts.ready || facts.busy || facts.pending)
632
+ return;
633
+ // Consume before awaiting, so a re-entrant update cannot send the same prompt twice.
634
+ this.pendingPrompt = undefined;
635
+ loop.sent();
636
+ this.update({});
637
+ try {
638
+ if (this.isCurrent(loop))
639
+ await this.host.send(prompt);
640
+ }
641
+ catch (error) {
642
+ // The next prompt was rejected (a waiting interaction, a lost snapshot, offline): the run ends
643
+ // with that reason recorded instead of vanishing without a trace.
644
+ this.rejectLoopSend(loop, error);
645
+ }
646
+ }
647
+ }
@@ -123,4 +123,8 @@ export interface VerifierPort {
123
123
  * @returns The verdict, or a note explaining why there is none.
124
124
  */
125
125
  verify(request: VerifierRequest, signal: AbortSignal): Promise<VerifierOutcome>;
126
+ /** Drain adapter-owned cleanup after all run signals have been aborted, before closing the host.
127
+ * Adapters with child processes or remote sessions implement this; pure in-memory judges need not.
128
+ */
129
+ settle?(): Promise<void>;
126
130
  }
@@ -35,7 +35,7 @@ export declare class CostController {
35
35
  * sessions.
36
36
  */
37
37
  start(): void;
38
- /** Stop the timer and wait for an in-flight scan; the ledger keeps its folded totals. */
38
+ /** Cancel the timer and active scan, then wait for cleanup; keep already folded totals. */
39
39
  stop(): Promise<void>;
40
40
  /** Refresh after the host reports a turn complete. */
41
41
  onTurnIdle(): void;