@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.
- package/README.i18n.yaml +2 -2
- package/README.md +4 -2
- package/README.zh.md +6 -4
- package/dist/catalog/controller.d.ts +26 -6
- package/dist/catalog/controller.js +73 -45
- package/dist/catalog/index.d.ts +1 -0
- package/dist/cli/dsht.js +4 -1
- package/dist/cli/startup.js +30 -11
- package/dist/cli/verifier.d.ts +4 -0
- package/dist/cli/verifier.js +28 -5
- package/dist/contracts.d.ts +20 -5
- package/dist/controller/connection-streams.d.ts +22 -0
- package/dist/controller/connection-streams.js +105 -0
- package/dist/controller/connection.d.ts +14 -3
- package/dist/controller/connection.js +40 -69
- package/dist/controller/controller.d.ts +17 -233
- package/dist/controller/controller.js +108 -811
- package/dist/controller/foreground.d.ts +44 -0
- package/dist/controller/foreground.js +79 -0
- package/dist/controller/loop-coordinator.d.ts +48 -0
- package/dist/controller/loop-coordinator.js +647 -0
- package/dist/controller/verifier.d.ts +4 -0
- package/dist/cost/controller.d.ts +1 -1
- package/dist/cost/controller.js +12 -5
- package/dist/cost/scanner.js +1 -0
- package/dist/session/controller.d.ts +23 -35
- package/dist/session/controller.js +113 -363
- package/dist/session/history-reader.d.ts +32 -0
- package/dist/session/history-reader.js +170 -0
- package/dist/session/index.d.ts +1 -1
- package/dist/session/info.d.ts +3 -38
- package/dist/session/info.js +14 -1
- package/dist/session/interactions.d.ts +26 -0
- package/dist/session/interactions.js +75 -0
- package/dist/session/navigator.d.ts +47 -0
- package/dist/session/navigator.js +158 -0
- package/dist/session/prompt-backfill.d.ts +23 -0
- package/dist/session/prompt-backfill.js +88 -0
- package/dist/session/state.d.ts +20 -0
- package/dist/session/state.js +1 -0
- package/dist/session/telemetry.d.ts +15 -6
- package/dist/session/telemetry.js +44 -7
- package/dist/session/transcript.d.ts +5 -1
- package/dist/slash/index.d.ts +1 -1
- package/dist/slash/parse.d.ts +2 -126
- package/dist/slash/registry.d.ts +1 -1
- package/dist/slash/types.d.ts +126 -0
- package/dist/slash/types.js +1 -0
- package/dist/state.d.ts +5 -17
- package/dist/state.js +1 -1
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/ui/app.js +86 -301
- package/dist/ui/chat/shell-view.d.ts +2 -0
- package/dist/ui/chat/shell-view.js +8 -0
- package/dist/ui/chat/use-history-view.d.ts +69 -0
- package/dist/ui/chat/use-history-view.js +123 -0
- package/dist/ui/dialogs/use-panels.d.ts +53 -0
- package/dist/ui/dialogs/use-panels.js +51 -0
- package/dist/ui/input/use-composer.d.ts +35 -0
- package/dist/ui/input/use-composer.js +109 -0
- package/dist/ui/input/use-deferred-lines.d.ts +16 -0
- package/dist/ui/input/use-deferred-lines.js +54 -0
- package/dist/ui/input/use-history-recall.d.ts +20 -0
- package/dist/ui/input/use-history-recall.js +47 -0
- package/package.json +1 -1
|
@@ -1,9 +1,7 @@
|
|
|
1
1
|
/** Application facade: composes the connection, session, catalog and cost domains. */
|
|
2
2
|
import { join } from 'node:path';
|
|
3
|
-
import { AsyncLocalStorage } from 'node:async_hooks';
|
|
4
|
-
import { randomUUID } from 'node:crypto';
|
|
5
3
|
import { Client } from "../transport/client.js";
|
|
6
|
-
import {
|
|
4
|
+
import { removeFile, writeHeapSnapshot } from "../storage/index.js";
|
|
7
5
|
import { errorText, string } from "../transport/wire.js";
|
|
8
6
|
import { DEFAULT_HISTORY_LIMITS } from "../session/memory.js";
|
|
9
7
|
import { layoutStats } from "../session/history.js";
|
|
@@ -13,12 +11,12 @@ import { CatalogController } from "../catalog/controller.js";
|
|
|
13
11
|
import { CostController } from "../cost/controller.js";
|
|
14
12
|
import { ConnectionController } from "./connection.js";
|
|
15
13
|
import { MemoryLog } from "./memory-log.js";
|
|
14
|
+
import { ForegroundSlot } from "./foreground.js";
|
|
16
15
|
import { TraceLog } from "./trace-log.js";
|
|
17
16
|
import { PromptStore } from "./prompts.js";
|
|
18
|
-
import { latestAssistantText
|
|
19
|
-
import {
|
|
17
|
+
import { latestAssistantText } from "./loop.js";
|
|
18
|
+
import { LoopCoordinator } from "./loop-coordinator.js";
|
|
20
19
|
import { loopRecords as listLoopRecords } from "./loop-protocols.js";
|
|
21
|
-
import { verificationId, verdictFile } from "./verifier.js";
|
|
22
20
|
import { SessionPeek } from "../session/peek.js";
|
|
23
21
|
import { costAddresses } from "../cost/scanner.js";
|
|
24
22
|
import { sessionLabel } from "../session-title.js";
|
|
@@ -38,8 +36,6 @@ function shellEnv() {
|
|
|
38
36
|
delete env.DSH_URL;
|
|
39
37
|
return env;
|
|
40
38
|
}
|
|
41
|
-
/** How many times a verifier outage is retried before the run reports verification unavailable. */
|
|
42
|
-
const VERIFIER_RETRIES = 2;
|
|
43
39
|
/** Sources this client registers before it forgets the oldest; a session list can still find them. */
|
|
44
40
|
const SOURCE_LIMIT = 50;
|
|
45
41
|
/** The tag every verifier session title starts with, so a reader can tell it apart in a list. */
|
|
@@ -49,13 +45,6 @@ function verifierLabel(title) {
|
|
|
49
45
|
const stripped = title.startsWith(VERIFIER_TAG) ? title.slice(VERIFIER_TAG.length) : title;
|
|
50
46
|
return stripped.trim() === '' ? title : stripped.trim();
|
|
51
47
|
}
|
|
52
|
-
/** How long a finished turn may take to commit its final assistant message.
|
|
53
|
-
*
|
|
54
|
-
* The host reports the turn idle just before that message reaches the follow stream, so a single
|
|
55
|
-
* parse can miss the result block that decides the attempt. Waiting too long only delays a reply
|
|
56
|
-
* that never complies, while deciding too early spends an attempt the reviewer never got.
|
|
57
|
-
*/
|
|
58
|
-
const LOOP_SETTLE_GRACE_MS = 4000;
|
|
59
48
|
/** File `/handoff` clears on this machine before it asks the agent to write a handoff. */
|
|
60
49
|
const HANDOFF_FILE = 'HANDOFF.md';
|
|
61
50
|
/** Instruction `/handoff` sends once this client's own copy of the file is gone.
|
|
@@ -95,50 +84,12 @@ export class Controller {
|
|
|
95
84
|
trace;
|
|
96
85
|
/** Shortcut prompts the operator saved; in memory for this run when no path was supplied. */
|
|
97
86
|
promptStore;
|
|
98
|
-
/** Running agent loop, if any; the loop lives here, not in the UI. */
|
|
99
|
-
loop;
|
|
100
|
-
/** Prompt the loop still has to send, when it could not be sent immediately. */
|
|
101
|
-
loopPrompt;
|
|
102
87
|
/** Finished turns of the selected session, so a waiter never has to sample a cached flag. */
|
|
103
88
|
completedTurns = 0;
|
|
104
89
|
/** Sessions the host reported busy, so a lone idle frame cannot claim a finished turn. */
|
|
105
90
|
busySessions = new Set();
|
|
106
|
-
/**
|
|
107
|
-
|
|
108
|
-
/** Timer that settles an attempt whose reply never commits its result block. */
|
|
109
|
-
loopSettleTimer;
|
|
110
|
-
/** Whether an attempt is currently being judged by an independent verifier. */
|
|
111
|
-
loopVerifying = false;
|
|
112
|
-
/** A verdict is being consumed right now.
|
|
113
|
-
*
|
|
114
|
-
* Consuming one reads the artifact, so it is asynchronous; without this gate a replayed idle edge
|
|
115
|
-
* could start a second verification of an attempt whose verdict is already being applied.
|
|
116
|
-
*/
|
|
117
|
-
loopSettling = false;
|
|
118
|
-
/** Cancels that verification when the loop is stopped or replaced. */
|
|
119
|
-
loopVerifyAbort;
|
|
120
|
-
/** Verdict of the previous attempt on the current step, for the retry and the next verifier. */
|
|
121
|
-
loopPrevious;
|
|
122
|
-
/** Identity of the run in flight, so its verdicts are isolated from every other run's. */
|
|
123
|
-
loopRunId;
|
|
124
|
-
/** Whether the run in flight already wrote its `loop end`; one end per begin (I9). */
|
|
125
|
-
loopEndTraced = false;
|
|
126
|
-
/** Sequence of the verification task in flight, so a retry never reuses the old task's identity. */
|
|
127
|
-
loopVerifySeq = 0;
|
|
128
|
-
/** Identity of the verification task whose result may still decide the attempt in flight. */
|
|
129
|
-
loopVerifyIdentity;
|
|
130
|
-
/** Timer that stops the whole run when its budget expires. */
|
|
131
|
-
loopDeadlineTimer;
|
|
132
|
-
/** Whole-run budget, when the operator set one. */
|
|
133
|
-
deadlineMs;
|
|
134
|
-
/** Consecutive verifier outages in this attempt, so a broken verifier is retried then reported. */
|
|
135
|
-
loopVerifierMisses = 0;
|
|
136
|
-
/** What the operator answered when a verifier abstained; consumed by the next judgment. */
|
|
137
|
-
loopAnswer;
|
|
138
|
-
/** Whether a reply block may stand in for a missing verdict; off unless asked for. */
|
|
139
|
-
allowSelfFallback;
|
|
140
|
-
/** Client-side directory verdict files are written under. */
|
|
141
|
-
verdictRoot;
|
|
91
|
+
/** Loop execution owns all verifier, settling and deadline state. */
|
|
92
|
+
loops;
|
|
142
93
|
/** Local `!` commands, run on this machine and shown inline in the transcript. */
|
|
143
94
|
shell;
|
|
144
95
|
/** Mutating surface the UI drives. */
|
|
@@ -155,26 +106,20 @@ export class Controller {
|
|
|
155
106
|
memoryLogPath;
|
|
156
107
|
/** Directory this client runs in. */
|
|
157
108
|
localDirectory;
|
|
158
|
-
/** Independent verifier for scored rounds, when one was supplied. */
|
|
159
|
-
verifier;
|
|
160
109
|
/** Session opened at startup, when one was named. */
|
|
161
110
|
initialSession;
|
|
162
111
|
observers = new Set();
|
|
163
112
|
selector = 0;
|
|
164
113
|
connectionSettled = false;
|
|
114
|
+
/** Startup defaults apply once; subsequent generations restore the operator's selection. */
|
|
115
|
+
initialized = false;
|
|
165
116
|
/** Counter behind `commandId`, so every executed line has one identifier in begin and end. */
|
|
166
117
|
commandSeq = 0;
|
|
167
|
-
/**
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
foregroundGranted = false;
|
|
173
|
-
/** Set when the client stops, so a waiter never starts work on a closed connection. */
|
|
174
|
-
foregroundClosed = false;
|
|
175
|
-
foregroundSeq = 0;
|
|
176
|
-
/** Which operation the current async continuation belongs to, so a nested call never re-claims. */
|
|
177
|
-
foregroundOwner = new AsyncLocalStorage();
|
|
118
|
+
/** Scheduling and cancellation are owned independently of domain operations. */
|
|
119
|
+
foregroundSlot = new ForegroundSlot({
|
|
120
|
+
changed: started => this.update(started ? { lastFailure: '' } : {}),
|
|
121
|
+
trace: event => this.traceEvent('foreground', event),
|
|
122
|
+
});
|
|
178
123
|
/** Read-only follower for the full-screen view; it borrows the connection and never selects. */
|
|
179
124
|
peek;
|
|
180
125
|
/** Sources this client created, keyed by session id; the host cannot record that lineage itself. */
|
|
@@ -193,10 +138,15 @@ export class Controller {
|
|
|
193
138
|
this.memoryLogPath = options.memoryLogPath;
|
|
194
139
|
this.localDirectory = options.localDirectory ?? process.cwd();
|
|
195
140
|
this.initialSession = initialSession;
|
|
196
|
-
this.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
141
|
+
this.loops = new LoopCoordinator({
|
|
142
|
+
facts: () => ({ sessionId: this.state.sessionId, online: this.state.online,
|
|
143
|
+
ready: this.state.session.record.ready, busy: this.foregroundSlot.snapshot !== undefined, pending: this.state.pending.length > 0 }),
|
|
144
|
+
reply: () => latestAssistantText(this.state.session.record.messages),
|
|
145
|
+
send: prompt => this.session.promptInternal(prompt),
|
|
146
|
+
publish: failure => this.update(failure === undefined ? {} : { lastFailure: failure }),
|
|
147
|
+
trace: (event, detail) => this.traceEvent(event, detail),
|
|
148
|
+
}, { directory: this.localDirectory, verifier: options.verifier, verdictRoot: options.verdictRoot,
|
|
149
|
+
deadlineMs: options.deadlineMs, allowSelfFallback: options.allowSelfFallback });
|
|
200
150
|
const connectionOptions = { base, token, initialSession, makeClient, authenticate };
|
|
201
151
|
this.connection = new ConnectionController(this, connectionOptions, this);
|
|
202
152
|
// The view follows other sessions over the same connection; it never becomes a second writer.
|
|
@@ -210,7 +160,12 @@ export class Controller {
|
|
|
210
160
|
env: () => shellEnv(),
|
|
211
161
|
anchor: () => this.state.session.record.readThrough,
|
|
212
162
|
}, shellEnabled);
|
|
213
|
-
this.catalog = new CatalogController(
|
|
163
|
+
this.catalog = new CatalogController({
|
|
164
|
+
client: () => this.connection.client(), require: () => this.connection.require(),
|
|
165
|
+
online: () => this.state.online, signal: () => this.connection.signal(),
|
|
166
|
+
selection: () => ({ revision: this.selection(), sessionId: this.state.sessionId }),
|
|
167
|
+
publish: patch => this.update(patch),
|
|
168
|
+
});
|
|
214
169
|
if (costs)
|
|
215
170
|
this.cost = new CostController(costs, {
|
|
216
171
|
client: () => this.connection.client(),
|
|
@@ -251,19 +206,19 @@ export class Controller {
|
|
|
251
206
|
/** Whether an operation owns the client's foreground slot right now.
|
|
252
207
|
* @returns True while an operation is running.
|
|
253
208
|
*/
|
|
254
|
-
busy() { return this.
|
|
209
|
+
busy() { return this.foregroundSlot.snapshot !== undefined; }
|
|
255
210
|
update(patch) {
|
|
256
211
|
const previous = this.state;
|
|
257
212
|
const next = { ...this.state, ...patch, version: this.state.version + 1 };
|
|
258
213
|
next.pending = next.online && next.screen === 'chat' && this.session
|
|
259
|
-
? this.session.pendingFor(next) : [];
|
|
214
|
+
? this.session.pendingFor(next.sessionId) : [];
|
|
260
215
|
// The shell service owns its blocks; state carries only the plain snapshot the UI renders.
|
|
261
216
|
if (this.shell)
|
|
262
217
|
next.shell = this.shell.snapshot();
|
|
263
218
|
// A loop belongs to one session: selecting a different session ends it, but a reconnect — which
|
|
264
219
|
// transiently clears the selection — must not, or a long review could never finish.
|
|
265
|
-
if (next.sessionId !== undefined && next.sessionId !== this.state.sessionId && this.
|
|
266
|
-
this.
|
|
220
|
+
if (next.sessionId !== undefined && next.sessionId !== this.state.sessionId && this.loops.sessionId !== next.sessionId) {
|
|
221
|
+
this.loops.forget();
|
|
267
222
|
// The read-only view belongs to the conversation that opened it; leaving that conversation
|
|
268
223
|
// closes it, and the session switch that follows is never left rendering someone else's rows.
|
|
269
224
|
this.dropPeek();
|
|
@@ -272,12 +227,7 @@ export class Controller {
|
|
|
272
227
|
this.traceTransition(previous, next);
|
|
273
228
|
for (const observer of this.observers)
|
|
274
229
|
observer();
|
|
275
|
-
|
|
276
|
-
if (this.loopPrompt !== undefined)
|
|
277
|
-
void this.flushLoop();
|
|
278
|
-
// A finished attempt whose reply is still committing gets another look on every publish.
|
|
279
|
-
if (this.loopEndedAt !== undefined)
|
|
280
|
-
this.trySettleLoop();
|
|
230
|
+
this.loops.changed();
|
|
281
231
|
}
|
|
282
232
|
/** Record one diagnostic event; a no-op when no trace path was configured. */
|
|
283
233
|
traceEvent(event, detail = {}) {
|
|
@@ -334,33 +284,34 @@ export class Controller {
|
|
|
334
284
|
*/
|
|
335
285
|
buildActions() {
|
|
336
286
|
return {
|
|
337
|
-
foreground: (kind, label, work, wait) => this.
|
|
338
|
-
cancelForeground: () => this.
|
|
287
|
+
foreground: (kind, label, work, wait) => this.foregroundSlot.run(kind, label, work, wait),
|
|
288
|
+
cancelForeground: () => this.foregroundSlot.cancel(),
|
|
339
289
|
openPeek: id => this.openPeek(id),
|
|
340
290
|
closePeek: () => this.closePeek(),
|
|
341
|
-
switchWorkspace: id => this.runAction('navigation', 'Switching workspace…',
|
|
342
|
-
switchSession: query => this.runAction('navigation', 'Switching session…',
|
|
291
|
+
switchWorkspace: id => this.runAction('navigation', 'Switching workspace…', signal => this.switchWorkspace(id, signal)),
|
|
292
|
+
switchSession: query => this.runAction('navigation', 'Switching session…', signal => this.switchSession(query, signal)),
|
|
343
293
|
selectSession: id => this.runAction('navigation', 'Loading session…', () => this.selectSession(id)),
|
|
344
|
-
createWorkspace: path => this.runAction('navigation', 'Registering workspace…',
|
|
345
|
-
createSession: () => this.runAction('navigation', 'Creating session…',
|
|
294
|
+
createWorkspace: path => this.runAction('navigation', 'Registering workspace…', signal => this.createWorkspace(path, signal)),
|
|
295
|
+
createSession: () => this.runAction('navigation', 'Creating session…', signal => this.createSession(signal)),
|
|
346
296
|
createVerifierSession: title => this.runActionValue('verifier', 'Creating verifier session…', () => this.createVerifierSession(title)),
|
|
347
|
-
cancelVerifierSession: sessionId => this.
|
|
297
|
+
cancelVerifierSession: sessionId => this.runImmediateAction(async () => {
|
|
348
298
|
await this.session.cancelNamedSession(sessionId);
|
|
349
299
|
// The run is over even though its transcript stays readable; the list should say so.
|
|
350
300
|
this.endSource(sessionId, Date.now());
|
|
351
301
|
}),
|
|
352
|
-
showPicker: screen => this.runAction('picker', 'Listing…',
|
|
302
|
+
showPicker: screen => this.runAction('picker', 'Listing…', signal => this.showPicker(screen, signal)),
|
|
353
303
|
removeTarget: target => this.runAction('removal', 'Removing…', () => this.removeTarget(target)),
|
|
354
304
|
removalTarget: (kind, query) => this.runActionValue('removal', 'Reading target…', () => this.removalTarget(kind, query)),
|
|
355
305
|
waitForHistory: signal => this.runAction('history', 'Loading history…', () => this.waitForHistory(signal)),
|
|
356
306
|
searchSessions: (query, workspaceOnly, signal) => this.runActionValue('search', 'Searching sessions…', () => this.searchSessions(query, workspaceOnly, signal)),
|
|
357
|
-
|
|
307
|
+
openHistory: (target, signal) => this.runAction('history', 'Opening history…', operation => this.session.openHistory(target, AbortSignal.any([signal, operation]))),
|
|
308
|
+
searchHistory: (query, signal) => this.runActionValue('search', 'Searching history…', operation => this.searchHistory(query, AbortSignal.any([signal, operation]))),
|
|
358
309
|
prompt: text => this.runAction('prompt', 'Sending…', () => this.prompt(text)),
|
|
359
310
|
handoff: () => this.runAction('handoff', 'Requesting handoff…', () => this.handoff()),
|
|
360
|
-
startLoop: (protocol, limits) => this.runAction('loop', 'Starting loop…', () => this.
|
|
361
|
-
stopLoop: () => this.
|
|
362
|
-
clearLoopResult: () => this.
|
|
363
|
-
answerLoop: text => this.runAction('loop', 'Answering the verifier…', () => this.
|
|
311
|
+
startLoop: (protocol, limits) => this.runAction('loop', 'Starting loop…', () => this.loops.start(protocol, limits)),
|
|
312
|
+
stopLoop: () => this.loops.stop(),
|
|
313
|
+
clearLoopResult: () => this.loops.clearResult(),
|
|
314
|
+
answerLoop: text => this.runAction('loop', 'Answering the verifier…', () => this.loops.answer(text)),
|
|
364
315
|
cancelTurn: () => this.runAction('interaction', 'Cancelling…', () => this.cancelTurn()),
|
|
365
316
|
answer: value => this.runAction('interaction', 'Answering…', () => this.answer(value)),
|
|
366
317
|
answerQuestion: input => this.runAction('interaction', 'Answering…', async () => {
|
|
@@ -370,19 +321,19 @@ export class Controller {
|
|
|
370
321
|
approve: allowed => this.runAction('interaction', 'Answering…', () => this.approve(allowed)),
|
|
371
322
|
dismissQuestion: () => this.runAction('interaction', 'Dismissing…', () => this.dismissQuestion()),
|
|
372
323
|
interrupt: force => this.interrupt(force),
|
|
373
|
-
older: (signal, transcript) => this.runAction('history', 'Loading history…',
|
|
374
|
-
historyThrough: (target, signal) => this.runAction('history', 'Loading history…',
|
|
324
|
+
older: (signal, transcript) => this.runAction('history', 'Loading history…', operation => this.older(signal === undefined ? operation : AbortSignal.any([signal, operation]), transcript)),
|
|
325
|
+
historyThrough: (target, signal) => this.runAction('history', 'Loading history…', operation => this.historyThrough(target, AbortSignal.any([signal, operation]))),
|
|
375
326
|
removeQueued: itemId => this.runAction('command', 'Removing queued input…', () => this.removeQueued(itemId)),
|
|
376
|
-
savePrompt: text => this.
|
|
327
|
+
savePrompt: text => this.runImmediateAction(async () => {
|
|
377
328
|
await this.promptStore.save(text);
|
|
378
329
|
this.update({ lastFailure: '' });
|
|
379
330
|
}),
|
|
380
|
-
updatePrompt: (id, text) => this.
|
|
331
|
+
updatePrompt: (id, text) => this.runImmediateAction(async () => {
|
|
381
332
|
if (!await this.promptStore.update(id, text))
|
|
382
333
|
throw new Error('That saved prompt no longer exists');
|
|
383
334
|
this.update({ lastFailure: '' });
|
|
384
335
|
}),
|
|
385
|
-
deletePrompt: id => this.
|
|
336
|
+
deletePrompt: id => this.runImmediateAction(async () => {
|
|
386
337
|
if (!await this.promptStore.remove(id))
|
|
387
338
|
throw new Error('That saved prompt no longer exists');
|
|
388
339
|
this.update({ lastFailure: '' });
|
|
@@ -390,15 +341,15 @@ export class Controller {
|
|
|
390
341
|
command: (line, signal) => this.runActionValue('command', 'Running command…', () => this.command(line, signal)),
|
|
391
342
|
exportLog: (path, signal) => this.runActionValue('export', 'Exporting session log…', () => this.exportLog(path, signal)),
|
|
392
343
|
exportHtml: (path, signal) => this.runActionValue('export', 'Exporting conversation…', () => this.exportHtml(path, signal)),
|
|
393
|
-
selectModel: (provider, model, effort) => this.runAction('model', 'Selecting model…',
|
|
394
|
-
modelCatalog: () => this.runActionValue('model', 'Loading models…',
|
|
395
|
-
refreshCosts: signal => this.runAction('cost', 'Refreshing costs…',
|
|
344
|
+
selectModel: (provider, model, effort) => this.runAction('model', 'Selecting model…', signal => this.catalog.selectModel(provider, model, effort, signal)),
|
|
345
|
+
modelCatalog: () => this.runActionValue('model', 'Loading models…', signal => this.catalog.modelCatalog(signal)),
|
|
346
|
+
refreshCosts: signal => this.runAction('cost', 'Refreshing costs…', operation => this.refreshCosts(signal === undefined ? operation : AbortSignal.any([signal, operation]))),
|
|
396
347
|
loadPresetNames: () => this.loadPresetNames(),
|
|
397
348
|
clearFailure: () => this.clearFailure(),
|
|
398
349
|
enterPath: () => this.enterPath(),
|
|
399
350
|
pickWorkspace: id => this.pickWorkspace(id),
|
|
400
351
|
showChat: () => this.showChat(),
|
|
401
|
-
|
|
352
|
+
showLatest: () => this.session.setViewWindow(undefined),
|
|
402
353
|
pinHistory: pinned => this.pinHistory(pinned),
|
|
403
354
|
setAnswers: answers => this.setAnswers(answers),
|
|
404
355
|
setOption: option => this.setOption(option),
|
|
@@ -415,8 +366,8 @@ export class Controller {
|
|
|
415
366
|
return {
|
|
416
367
|
get running() { return controller.running; },
|
|
417
368
|
get turnsCompleted() { return controller.completedTurns; },
|
|
418
|
-
get forkedVerification() { return controller.
|
|
419
|
-
get selfScoring() { return controller.
|
|
369
|
+
get forkedVerification() { return controller.loops.forkedVerification; },
|
|
370
|
+
get selfScoring() { return controller.loops.selfScoring; },
|
|
420
371
|
get connectionSettled() { return controller.connectionSettled; },
|
|
421
372
|
get sessionName() { return controller.sessionName; },
|
|
422
373
|
get sessionMode() { return controller.sessionMode; },
|
|
@@ -425,14 +376,14 @@ export class Controller {
|
|
|
425
376
|
get record() { return controller.record; },
|
|
426
377
|
get window() { return controller.window; },
|
|
427
378
|
get interaction() { return controller.interaction; },
|
|
428
|
-
get telemetry() { return controller.telemetry; },
|
|
379
|
+
get telemetry() { return controller.telemetry.reader; },
|
|
429
380
|
get recallAtOldest() { return controller.recallAtOldest; },
|
|
430
381
|
get recallLength() { return controller.recallLength; },
|
|
431
382
|
get recallHasOlder() { return controller.recallHasOlder; },
|
|
432
383
|
get prompts() { return controller.promptStore.list; },
|
|
433
384
|
get promptsError() { return controller.promptStore.error; },
|
|
434
|
-
get loop() { return controller.
|
|
435
|
-
get foreground() { return controller.
|
|
385
|
+
get loop() { return controller.loops.progress; },
|
|
386
|
+
get foreground() { return controller.foregroundSlot.snapshot; },
|
|
436
387
|
get sources() { return controller.outputSources(); },
|
|
437
388
|
get peek() { return controller.peekSnapshot(); },
|
|
438
389
|
get activity() { return controller.activity; },
|
|
@@ -440,7 +391,6 @@ export class Controller {
|
|
|
440
391
|
pendingCounts: () => controller.pendingCounts(),
|
|
441
392
|
recall: (direction, current) => controller.recall(direction, current),
|
|
442
393
|
references: (query, signal) => controller.references(query, signal),
|
|
443
|
-
historyAt: (target, signal) => controller.historyAt(target, signal),
|
|
444
394
|
render: input => controller.render(input),
|
|
445
395
|
};
|
|
446
396
|
}
|
|
@@ -457,9 +407,9 @@ export class Controller {
|
|
|
457
407
|
/** Cancel retries and HTTP, close the socket, and release session and catalog work. */
|
|
458
408
|
async stop() {
|
|
459
409
|
// Nobody waits forever for a slot that will never be handed out again.
|
|
460
|
-
this.
|
|
461
|
-
|
|
462
|
-
|
|
410
|
+
this.foregroundSlot.close();
|
|
411
|
+
this.catalog.close();
|
|
412
|
+
await this.loops.close();
|
|
463
413
|
this.dropPeek();
|
|
464
414
|
await this.shell.stop();
|
|
465
415
|
await this.connection.stop();
|
|
@@ -489,13 +439,13 @@ export class Controller {
|
|
|
489
439
|
* @returns Whether the operation ran to completion.
|
|
490
440
|
*/
|
|
491
441
|
async runAction(kind, label, operation) {
|
|
492
|
-
if (!this.state.online)
|
|
493
|
-
|
|
494
|
-
if (!this.ownsForeground() && this.foreground !== undefined)
|
|
442
|
+
if (!this.state.online) {
|
|
443
|
+
this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
|
|
495
444
|
return false;
|
|
496
|
-
|
|
445
|
+
}
|
|
446
|
+
return await this.foregroundSlot.run(kind, label, async (signal) => {
|
|
497
447
|
try {
|
|
498
|
-
await operation();
|
|
448
|
+
await operation(signal);
|
|
499
449
|
return true;
|
|
500
450
|
}
|
|
501
451
|
catch (error) {
|
|
@@ -506,13 +456,13 @@ export class Controller {
|
|
|
506
456
|
}
|
|
507
457
|
/** Same slot, for an operation that produces a value the caller needs. */
|
|
508
458
|
async runActionValue(kind, label, operation) {
|
|
509
|
-
if (!this.state.online)
|
|
510
|
-
|
|
511
|
-
if (!this.ownsForeground() && this.foreground !== undefined)
|
|
459
|
+
if (!this.state.online) {
|
|
460
|
+
this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
|
|
512
461
|
return undefined;
|
|
513
|
-
|
|
462
|
+
}
|
|
463
|
+
return await this.foregroundSlot.run(kind, label, async (signal) => {
|
|
514
464
|
try {
|
|
515
|
-
return await operation();
|
|
465
|
+
return await operation(signal);
|
|
516
466
|
}
|
|
517
467
|
catch (error) {
|
|
518
468
|
this.update({ lastFailure: errorText(error) });
|
|
@@ -520,83 +470,11 @@ export class Controller {
|
|
|
520
470
|
}
|
|
521
471
|
});
|
|
522
472
|
}
|
|
523
|
-
/**
|
|
524
|
-
*
|
|
525
|
-
*
|
|
526
|
-
* calling an action) belongs to that operation: refusing it for being busy would deadlock the very
|
|
527
|
-
* work that owns the slot. An async-local owner answers that exactly, where a plain flag could not
|
|
528
|
-
* tell a nested call from a second, unrelated one.
|
|
529
|
-
* @returns True when this call runs inside the operation that owns the slot.
|
|
473
|
+
/** Run outside the foreground slot, with the same application failure reporting.
|
|
474
|
+
* Local prompt edits need no connection; verifier cancellation uses the session control lane and
|
|
475
|
+
* must reach the host even while the foreground is busy or closed for shutdown.
|
|
530
476
|
*/
|
|
531
|
-
|
|
532
|
-
/** Run one operation in the foreground slot, claiming it when the caller does not already own it.
|
|
533
|
-
*
|
|
534
|
-
* The slot serializes what the operator is doing — one thing at a time — which is a different
|
|
535
|
-
* question from the session write order (§6.3): a read takes this slot too. The controller owns the
|
|
536
|
-
* slot, the abort controller and the identity, so the front end only renders `queries.foreground`
|
|
537
|
-
* and cancels it in one call.
|
|
538
|
-
* @param kind - What the operation is.
|
|
539
|
-
* @param label - Human label shown while it runs.
|
|
540
|
-
* @param work - The work, handed the signal that `cancelForeground` aborts.
|
|
541
|
-
* @returns What the work returned, or undefined when the slot was taken or the work was cancelled.
|
|
542
|
-
*/
|
|
543
|
-
async claimForeground(kind, label, work, wait = false) {
|
|
544
|
-
const owner = this.foreground;
|
|
545
|
-
// Nested: the operation that owns the slot supplies the signal and keeps the identity.
|
|
546
|
-
if (owner !== undefined && this.ownsForeground())
|
|
547
|
-
return await work(owner.abort.signal);
|
|
548
|
-
if (owner !== undefined || this.foregroundGranted) {
|
|
549
|
-
// A caller that does not want to wait is told no; one that does waits its turn in arrival order.
|
|
550
|
-
if (!wait)
|
|
551
|
-
return undefined;
|
|
552
|
-
this.traceEvent('foreground', { phase: 'queued', kind, label });
|
|
553
|
-
await new Promise(resolve => this.foregroundWaiters.push(resolve));
|
|
554
|
-
// The release handed this caller the slot; clearing the flag and taking it is one synchronous
|
|
555
|
-
// step, so a later arrival cannot slip in between.
|
|
556
|
-
this.foregroundGranted = false;
|
|
557
|
-
if (this.foregroundClosed)
|
|
558
|
-
return undefined;
|
|
559
|
-
}
|
|
560
|
-
const operation = { id: ++this.foregroundSeq, kind, label, startedAt: Date.now(), abort: new AbortController() };
|
|
561
|
-
this.foreground = operation;
|
|
562
|
-
this.update({ lastFailure: '' });
|
|
563
|
-
this.traceEvent('foreground', { phase: 'begin', id: operation.id, kind, label });
|
|
564
|
-
try {
|
|
565
|
-
return await this.foregroundOwner.run(operation.id, () => work(operation.abort.signal));
|
|
566
|
-
}
|
|
567
|
-
finally {
|
|
568
|
-
// Only the owner releases the slot; a nested claim never reaches this branch.
|
|
569
|
-
if (this.foreground === operation) {
|
|
570
|
-
this.foreground = undefined;
|
|
571
|
-
this.update({});
|
|
572
|
-
this.traceEvent('foreground', { phase: 'end', id: operation.id, kind, cancelled: operation.abort.signal.aborted });
|
|
573
|
-
// Hand the slot to the oldest waiter, if any: the occupant is gone before the next one takes
|
|
574
|
-
// it, and `foregroundGranted` keeps a fresh arrival from overtaking the promise already made.
|
|
575
|
-
const next = this.foregroundWaiters.shift();
|
|
576
|
-
if (next !== undefined) {
|
|
577
|
-
this.foregroundGranted = true;
|
|
578
|
-
next();
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
}
|
|
582
|
-
}
|
|
583
|
-
/** Cancel the operation that owns the foreground slot; false when none is running.
|
|
584
|
-
* @returns Whether an operation was there to cancel.
|
|
585
|
-
*/
|
|
586
|
-
cancelForeground() {
|
|
587
|
-
if (this.foreground === undefined)
|
|
588
|
-
return false;
|
|
589
|
-
this.foreground.abort.abort();
|
|
590
|
-
return true;
|
|
591
|
-
}
|
|
592
|
-
/** Run one local operation that needs no connection, reporting failure the way `runAction` does.
|
|
593
|
-
*
|
|
594
|
-
* Shortcut prompts are the operator's own file, so they must keep working while the host is
|
|
595
|
-
* disconnected; only the failure line is shared with the remote operations.
|
|
596
|
-
* @param operation - Operation to run against local storage.
|
|
597
|
-
* @returns Whether it ran to completion.
|
|
598
|
-
*/
|
|
599
|
-
async runLocalAction(operation) {
|
|
477
|
+
async runImmediateAction(operation) {
|
|
600
478
|
try {
|
|
601
479
|
await operation();
|
|
602
480
|
return true;
|
|
@@ -672,35 +550,28 @@ export class Controller {
|
|
|
672
550
|
}
|
|
673
551
|
/** The event stream is ready and the control baseline is applied. */
|
|
674
552
|
async ready() {
|
|
675
|
-
this.catalog.refresh();
|
|
676
553
|
this.update({ online: true, status: 'Connected', pending: [], lastFailure: '' });
|
|
554
|
+
this.catalog.refresh();
|
|
677
555
|
const screen = this.state.screen;
|
|
678
556
|
this.traceEvent('generation', { phase: 'ready', screen });
|
|
679
|
-
|
|
680
|
-
this.
|
|
681
|
-
|
|
682
|
-
//
|
|
683
|
-
|
|
684
|
-
// The guard only excludes the `sessions` screen, so it also runs when the captured screen is
|
|
685
|
-
// `chat`: a reconnect mid-conversation then adopts the local workspace, `pickWorkspace` clears
|
|
686
|
-
// the selection, and the reader is returned to `/resume`. The trace records the `adopt` event
|
|
687
|
-
// and the `state` transition that follow, which is how that jump is told from a user action.
|
|
688
|
-
const mayAdopt = screen !== 'sessions' && !this.initialSession;
|
|
689
|
-
if (mayAdopt) {
|
|
557
|
+
this.traceEvent('navigation-refresh', { screen });
|
|
558
|
+
await this.session.refreshLists();
|
|
559
|
+
// Startup may choose the local workspace. Reconnect only refreshes data: it must not reinterpret
|
|
560
|
+
// the reader's current screen as a request to navigate or discard a selected conversation.
|
|
561
|
+
if (!this.initialized && this.state.screen === 'workspaces' && !this.state.sessionId && !this.initialSession) {
|
|
690
562
|
const adopted = this.session.adoptLocalWorkspace(this.localDirectory);
|
|
691
563
|
this.traceEvent('adopt', { directory: this.localDirectory, workspace: adopted ?? 'none' });
|
|
692
564
|
if (adopted !== undefined)
|
|
693
565
|
this.update({ status: 'Workspace from this directory · ← to switch' });
|
|
694
566
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (sessionId && reselect) {
|
|
567
|
+
const selected = this.state.sessionId;
|
|
568
|
+
const sessionId = selected ?? this.loops.sessionId ?? (!this.initialized ? this.initialSession : undefined);
|
|
569
|
+
this.traceEvent('resolve', { session: sessionId ?? 'none', looping: this.loops.sessionId ?? 'none', reselect: sessionId !== undefined });
|
|
570
|
+
if (selected !== undefined)
|
|
571
|
+
this.session.restoreSelectedSession();
|
|
572
|
+
else if (sessionId !== undefined)
|
|
702
573
|
await this.session.selectSession(sessionId);
|
|
703
|
-
|
|
574
|
+
this.initialized = true;
|
|
704
575
|
// `online` means the socket works; `connectionSettled` means the picker and the selection are
|
|
705
576
|
// done, which is what an automation caller must wait for or it races `showPicker`.
|
|
706
577
|
this.connectionSettled = true;
|
|
@@ -744,7 +615,7 @@ export class Controller {
|
|
|
744
615
|
if (started && event.sessionId === this.state.sessionId)
|
|
745
616
|
this.completedTurns += 1;
|
|
746
617
|
this.cost?.onTurnIdle();
|
|
747
|
-
this.
|
|
618
|
+
this.loops.idle(event.sessionId);
|
|
748
619
|
}
|
|
749
620
|
return true;
|
|
750
621
|
case 'catalog-invalidated':
|
|
@@ -763,7 +634,7 @@ export class Controller {
|
|
|
763
634
|
/** @returns Current session title, falling back to the list title and then the ID. */
|
|
764
635
|
get sessionName() { return this.session.sessionName; }
|
|
765
636
|
/** @returns Current agent-preset label. */
|
|
766
|
-
get sessionMode() { return this.session.sessionMode; }
|
|
637
|
+
get sessionMode() { return this.session.sessionMode(this.state.presets); }
|
|
767
638
|
/** @returns Epoch start of the active turn, when known. */
|
|
768
639
|
get workingSince() { return this.session.workingSince; }
|
|
769
640
|
/** What the client is doing right now, merged across the host turn and any running loop.
|
|
@@ -776,7 +647,7 @@ export class Controller {
|
|
|
776
647
|
if (this.running) {
|
|
777
648
|
return { kind: 'turn', ...(this.workingSince === undefined ? {} : { since: this.workingSince }) };
|
|
778
649
|
}
|
|
779
|
-
const progress = this.
|
|
650
|
+
const progress = this.loops.progress;
|
|
780
651
|
if (progress === undefined || !progress.active)
|
|
781
652
|
return undefined;
|
|
782
653
|
// A paused run is still the client's work, but nothing is running: the bar must ask for the reader
|
|
@@ -849,7 +720,7 @@ export class Controller {
|
|
|
849
720
|
id: sessionId, kind: 'session', label: verifierLabel(title), state: 'running', startedAt: Date.now(),
|
|
850
721
|
createdBy: 'verifier',
|
|
851
722
|
...(parent === undefined ? {} : { parentSessionId: parent }),
|
|
852
|
-
...(this.
|
|
723
|
+
...(this.loops.verifierName === undefined ? {} : { detail: `verifier ${this.loops.verifierName}` }),
|
|
853
724
|
});
|
|
854
725
|
// The bar the run echoed into the transcript now opens this session, which is the newest check.
|
|
855
726
|
this.shell.link(sessionId);
|
|
@@ -923,23 +794,13 @@ export class Controller {
|
|
|
923
794
|
pendingCounts() { return this.session.pendingCounts(); }
|
|
924
795
|
/** Load the optional preset roster once per connection. */
|
|
925
796
|
loadPresetNames() { this.catalog.loadPresetNames(); }
|
|
926
|
-
/** @returns Host model routes and adapter-owned reasoning choices. */
|
|
927
|
-
async modelCatalog() { return this.catalog.modelCatalog(); }
|
|
928
|
-
/** Select the next request's model.
|
|
929
|
-
* @param provider - Host provider route ID.
|
|
930
|
-
* @param model - Exact model ID.
|
|
931
|
-
* @param reasoningEffort - Optional adapter-owned effort ID.
|
|
932
|
-
*/
|
|
933
|
-
async selectModel(provider, model, reasoningEffort) {
|
|
934
|
-
await this.catalog.selectModel(provider, model, reasoningEffort);
|
|
935
|
-
}
|
|
936
797
|
/** Stop the selected turn, or allow exit only while idle.
|
|
937
798
|
* @param force - Send an explicit cancellation even when the cached running flag is idle.
|
|
938
799
|
* @returns True when the caller may exit.
|
|
939
800
|
*/
|
|
940
801
|
interrupt(force = false) {
|
|
941
802
|
// Esc and Ctrl+C stop the automated loop as well as the turn; otherwise it would keep sending.
|
|
942
|
-
this.
|
|
803
|
+
this.loops.stop();
|
|
943
804
|
return this.session.interrupt(force);
|
|
944
805
|
}
|
|
945
806
|
/** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
|
|
@@ -952,7 +813,6 @@ export class Controller {
|
|
|
952
813
|
/** Show a detached history window, releasing the one it replaces.
|
|
953
814
|
* @param window - Record to display, or undefined to return to the live transcript.
|
|
954
815
|
*/
|
|
955
|
-
setViewWindow(window) { this.session.setViewWindow(window); }
|
|
956
816
|
/** Recall one step through the selected session's prompt index; never touches the network.
|
|
957
817
|
* @param direction - Negative for older input, positive for newer input.
|
|
958
818
|
* @param current - Composer content before recall began, restored at the newest position.
|
|
@@ -1001,9 +861,9 @@ export class Controller {
|
|
|
1001
861
|
/** Refresh both lists from the host, then show the requested picker.
|
|
1002
862
|
* @param screen - Picker to display after the refresh.
|
|
1003
863
|
*/
|
|
1004
|
-
async showPicker(screen) {
|
|
864
|
+
async showPicker(screen, signal) {
|
|
1005
865
|
this.traceEvent('action', { action: 'showPicker', screen });
|
|
1006
|
-
await this.session.showPicker(screen);
|
|
866
|
+
await this.session.showPicker(screen, signal);
|
|
1007
867
|
}
|
|
1008
868
|
/** Resolve a removal command to one reviewable object.
|
|
1009
869
|
* @param kind - Workspace registration removal or session archival.
|
|
@@ -1036,16 +896,16 @@ export class Controller {
|
|
|
1036
896
|
/** Open a workspace picker, or resolve a workspace target.
|
|
1037
897
|
* @param query - Workspace target, if any.
|
|
1038
898
|
*/
|
|
1039
|
-
async switchWorkspace(query) {
|
|
899
|
+
async switchWorkspace(query, signal) {
|
|
1040
900
|
this.traceEvent('action', { action: 'switchWorkspace', query: query ?? 'picker' });
|
|
1041
|
-
await this.session.switchWorkspace(query);
|
|
901
|
+
await this.session.switchWorkspace(query, signal);
|
|
1042
902
|
}
|
|
1043
903
|
/** Guide session selection, list all sessions with `all`, or resolve a target.
|
|
1044
904
|
* @param query - Session target, `all`, or nothing for the guided picker.
|
|
1045
905
|
*/
|
|
1046
|
-
async switchSession(query) {
|
|
906
|
+
async switchSession(query, signal) {
|
|
1047
907
|
this.traceEvent('action', { action: 'switchSession', query: query ?? 'picker' });
|
|
1048
|
-
await this.session.switchSession(query);
|
|
908
|
+
await this.session.switchSession(query, signal);
|
|
1049
909
|
}
|
|
1050
910
|
/** Prompt for a host path without starting a local agent. */
|
|
1051
911
|
enterPath() {
|
|
@@ -1055,14 +915,14 @@ export class Controller {
|
|
|
1055
915
|
/** Register a host directory and move to its session picker.
|
|
1056
916
|
* @param path - Absolute directory path on the host.
|
|
1057
917
|
*/
|
|
1058
|
-
async createWorkspace(path) {
|
|
918
|
+
async createWorkspace(path, signal) {
|
|
1059
919
|
this.traceEvent('action', { action: 'createWorkspace', path });
|
|
1060
|
-
await this.session.createWorkspace(path);
|
|
920
|
+
await this.session.createWorkspace(path, signal);
|
|
1061
921
|
}
|
|
1062
922
|
/** Create a session in the selected workspace. */
|
|
1063
|
-
async createSession() {
|
|
923
|
+
async createSession(signal) {
|
|
1064
924
|
this.traceEvent('action', { action: 'createSession', workspace: this.state.workspaceId ?? 'none' });
|
|
1065
|
-
await this.session.createSession();
|
|
925
|
+
await this.session.createSession(signal);
|
|
1066
926
|
}
|
|
1067
927
|
/** Replace the selected transcript and follow the session.
|
|
1068
928
|
* @param sessionId - Session to follow.
|
|
@@ -1124,7 +984,7 @@ export class Controller {
|
|
|
1124
984
|
* @param text - Composed prompt text.
|
|
1125
985
|
*/
|
|
1126
986
|
async prompt(text) {
|
|
1127
|
-
this.
|
|
987
|
+
this.loops.stop();
|
|
1128
988
|
await this.session.prompt(text);
|
|
1129
989
|
}
|
|
1130
990
|
/** Clear this client's stale handoff file, then ask the agent to write a new one.
|
|
@@ -1137,566 +997,9 @@ export class Controller {
|
|
|
1137
997
|
await removeFile(join(this.localDirectory, HANDOFF_FILE));
|
|
1138
998
|
await this.session.promptInternal(HANDOFF_PROMPT);
|
|
1139
999
|
}
|
|
1140
|
-
/** Start a scored loop and send its opening step.
|
|
1141
|
-
* @param protocol - Prompt text and step count the loop follows.
|
|
1142
|
-
* @param limits - Resolved `--from/--to/--score/--tries`.
|
|
1143
|
-
*/
|
|
1144
|
-
async startLoop(protocol, limits) {
|
|
1145
|
-
const sessionId = this.state.sessionId;
|
|
1146
|
-
// Every branch that can leave a start invisible is traced with its reason, so "it did nothing"
|
|
1147
|
-
// is answerable from the trace instead of guessed at.
|
|
1148
|
-
if (sessionId === undefined) {
|
|
1149
|
-
this.traceEvent('loop', { phase: 'refused', kind: protocol.kind, reason: 'no-session' });
|
|
1150
|
-
throw new Error('Select a session first');
|
|
1151
|
-
}
|
|
1152
|
-
// A new run replaces whatever was there; the old one is closed before the new identity exists.
|
|
1153
|
-
this.forgetLoop('replaced');
|
|
1154
|
-
const runId = randomUUID();
|
|
1155
|
-
this.loopRunId = runId;
|
|
1156
|
-
this.loopEndTraced = false;
|
|
1157
|
-
this.traceEvent('loop', { phase: 'begin', runId, kind: protocol.kind, session: sessionId,
|
|
1158
|
-
from: limits.from, to: limits.to, score: limits.score, tries: limits.tries, forked: this.verifier !== undefined });
|
|
1159
|
-
const loop = new ScoredLoop(runId, sessionId, protocol, limits);
|
|
1160
|
-
this.loop = loop;
|
|
1161
|
-
// A protocol that reviews something already on disk verifies first: a round that passes costs no
|
|
1162
|
-
// work turn at all, and only a failing verdict asks the agent to change anything.
|
|
1163
|
-
if (this.startsByVerifying(loop)) {
|
|
1164
|
-
this.update({});
|
|
1165
|
-
this.armLoopDeadline();
|
|
1166
|
-
this.traceEvent('loop', { phase: 'verify-first', runId, kind: protocol.kind, step: limits.from });
|
|
1167
|
-
this.verifyStep(loop);
|
|
1168
|
-
return;
|
|
1169
|
-
}
|
|
1170
|
-
const prompt = loop.start();
|
|
1171
|
-
loop.sent();
|
|
1172
|
-
this.update({});
|
|
1173
|
-
this.armLoopDeadline();
|
|
1174
|
-
try {
|
|
1175
|
-
await this.session.promptInternal(prompt);
|
|
1176
|
-
this.traceEvent('loop', { phase: 'sent', runId, kind: protocol.kind, step: limits.from, attempt: 1 });
|
|
1177
|
-
}
|
|
1178
|
-
catch (error) {
|
|
1179
|
-
// The run never got its first turn. It ends needing a person rather than silently vanishing:
|
|
1180
|
-
// the reason says the send was rejected, and the snapshot stays readable.
|
|
1181
|
-
this.rejectLoopSend(loop, error);
|
|
1182
|
-
throw error;
|
|
1183
|
-
}
|
|
1184
|
-
}
|
|
1185
|
-
/** End a run whose next prompt could not be sent, keeping the reason visible.
|
|
1186
|
-
*
|
|
1187
|
-
* A rejected send is not a verdict and not an operator cancellation, so the phase is `needs-human`
|
|
1188
|
-
* and `terminalReason` says why — this is what keeps `phase=cancelled` meaning "a person stopped it".
|
|
1189
|
-
* @param loop - Run that could not send.
|
|
1190
|
-
* @param error - What the host or the session layer rejected with.
|
|
1191
|
-
*/
|
|
1192
|
-
rejectLoopSend(loop, error) {
|
|
1193
|
-
const text = errorText(error).slice(0, 200);
|
|
1194
|
-
this.forgetSettleTimer();
|
|
1195
|
-
this.clearLoopDeadline();
|
|
1196
|
-
this.abortVerification();
|
|
1197
|
-
loop.human({ kind: 'send', text }, 'send-rejected');
|
|
1198
|
-
this.traceLoopEnd(loop);
|
|
1199
|
-
this.loopPrompt = undefined;
|
|
1200
|
-
this.update({ lastFailure: text });
|
|
1201
|
-
}
|
|
1202
|
-
/** Record the end of one run once, with the phase it stopped in and why.
|
|
1203
|
-
*
|
|
1204
|
-
* I9 needs every `begin` to be paired inside the trace window; this is the only writer of `loop end`.
|
|
1205
|
-
* @param loop - Run whose terminal phase was just published.
|
|
1206
|
-
*/
|
|
1207
|
-
traceLoopEnd(loop) {
|
|
1208
|
-
if (this.loopEndTraced)
|
|
1209
|
-
return;
|
|
1210
|
-
this.loopEndTraced = true;
|
|
1211
|
-
const progress = loop.progress;
|
|
1212
|
-
this.traceEvent('loop', { phase: 'end', runId: progress.runId, kind: loop.protocol.kind,
|
|
1213
|
-
result: progress.phase, step: progress.step, attempt: progress.attempt,
|
|
1214
|
-
reason: progress.terminalReason ?? 'unknown' });
|
|
1215
|
-
}
|
|
1216
|
-
/** Whether a step begins with verification rather than with a work prompt.
|
|
1217
|
-
*
|
|
1218
|
-
* Needs all three: the record asks for it, the record has a verifier prompt, and this client was
|
|
1219
|
-
* given a verifier. Otherwise there is nobody to verify first, and the step asks for work.
|
|
1220
|
-
* @param loop - Loop about to start a step.
|
|
1221
|
-
* @returns True when the step starts by verifying.
|
|
1222
|
-
*/
|
|
1223
|
-
startsByVerifying(loop) {
|
|
1224
|
-
return loop.protocol.starts === 'verify' && loop.protocol.verify !== undefined && this.verifier !== undefined;
|
|
1225
|
-
}
|
|
1226
|
-
/** Verify the step in flight without a work turn before it.
|
|
1227
|
-
*
|
|
1228
|
-
* The activity is set inside `verifyRound`, so the first verification, a work-turn verdict and a
|
|
1229
|
-
* retry all publish the same state without this caller having to remember it.
|
|
1230
|
-
* @param loop - Loop whose step and attempt are already set.
|
|
1231
|
-
*/
|
|
1232
|
-
verifyStep(loop) {
|
|
1233
|
-
void this.verifySection(loop);
|
|
1234
|
-
}
|
|
1235
|
-
/** Check the round's own section before spending a verifier on it, then verify or ask for work.
|
|
1236
|
-
*
|
|
1237
|
-
* Verify-first exists so a round that already passes costs no work turn. When the section is not in
|
|
1238
|
-
* the artifact at all the round *cannot* pass, and this client can read that itself — the same check
|
|
1239
|
-
* it applies to a verdict before accepting one. Sending a verifier to discover "the file is missing"
|
|
1240
|
-
* costs a session and, because a failed attempt consumes one, also the round's first attempt: a run
|
|
1241
|
-
* over a document with no review yet would start working at attempt 2/10. An artifact this client
|
|
1242
|
-
* cannot read stays a boundary: the verification runs and its verdict decides, as before.
|
|
1243
|
-
* @param loop - Loop whose step and attempt are already set.
|
|
1244
|
-
*/
|
|
1245
|
-
async verifySection(loop) {
|
|
1246
|
-
const artifact = loop.protocol.artifact;
|
|
1247
|
-
const marker = loop.protocol.artifactMarker?.(loop.progress.step);
|
|
1248
|
-
if (artifact === undefined || marker === undefined) {
|
|
1249
|
-
void this.verifyRound(loop);
|
|
1250
|
-
return;
|
|
1251
|
-
}
|
|
1252
|
-
// `readText` answers undefined only for a file that is not there and throws for one that cannot be
|
|
1253
|
-
// read, so the two cases are told apart here: a missing file has no section either, while an
|
|
1254
|
-
// unreadable one is a boundary this client cannot decide.
|
|
1255
|
-
let text;
|
|
1256
|
-
try {
|
|
1257
|
-
text = await readText(join(this.localDirectory, artifact));
|
|
1258
|
-
}
|
|
1259
|
-
catch {
|
|
1260
|
-
void this.verifyRound(loop);
|
|
1261
|
-
return;
|
|
1262
|
-
}
|
|
1263
|
-
// Reading the artifact is I/O, so the run may have moved on before it came back.
|
|
1264
|
-
if (this.loop !== loop || !loop.active)
|
|
1265
|
-
return;
|
|
1266
|
-
if (text !== undefined && text.includes(marker)) {
|
|
1267
|
-
void this.verifyRound(loop);
|
|
1268
|
-
return;
|
|
1269
|
-
}
|
|
1270
|
-
// A missing file only proves the producer never wrote it when this client can see the workspace at
|
|
1271
|
-
// all. A workspace this machine does not have is a boundary — the same boundary the artifact check
|
|
1272
|
-
// after a verdict already respects — so the verifier's reading decides instead.
|
|
1273
|
-
if (text === undefined && !await this.workspaceVisible()) {
|
|
1274
|
-
void this.verifyRound(loop);
|
|
1275
|
-
return;
|
|
1276
|
-
}
|
|
1277
|
-
this.traceEvent('loop', { phase: 'work-first', runId: this.loopRunId ?? '', kind: loop.protocol.kind,
|
|
1278
|
-
step: loop.progress.step, artifact, reason: 'section-missing' });
|
|
1279
|
-
loop.note(`⚠ artifact check · ${artifact} 还没有本轮小节,直接开始工作`);
|
|
1280
|
-
// No verdict was consumed, so the attempt is untouched: the first work turn is attempt 1, exactly
|
|
1281
|
-
// as it is for a record that asks for work first.
|
|
1282
|
-
this.loopPrompt = loop.workPrompt();
|
|
1283
|
-
this.update({});
|
|
1284
|
-
void this.flushLoop();
|
|
1285
|
-
}
|
|
1286
|
-
/** Whether the directory this client runs in is readable here at all.
|
|
1287
|
-
*
|
|
1288
|
-
* `readText` answers undefined for a missing file and for a file inside a directory this machine does
|
|
1289
|
-
* not have, and the two mean opposite things: the first is a producer that wrote nothing, the second
|
|
1290
|
-
* is a workspace whose verdict cannot be checked from here.
|
|
1291
|
-
* @returns True when the directory can be listed.
|
|
1292
|
-
*/
|
|
1293
|
-
async workspaceVisible() {
|
|
1294
|
-
try {
|
|
1295
|
-
await listEntries(this.localDirectory);
|
|
1296
|
-
return true;
|
|
1297
|
-
}
|
|
1298
|
-
catch {
|
|
1299
|
-
return false;
|
|
1300
|
-
}
|
|
1301
|
-
}
|
|
1302
|
-
/** Arm the whole-run budget, when the operator set one. */
|
|
1303
|
-
armLoopDeadline() {
|
|
1304
|
-
if (this.deadlineMs === undefined)
|
|
1305
|
-
return;
|
|
1306
|
-
this.loopDeadlineTimer = setTimeout(() => this.expireLoopDeadline(), this.deadlineMs);
|
|
1307
|
-
this.loopDeadlineTimer.unref();
|
|
1308
|
-
}
|
|
1309
|
-
/** Answer a paused run, so the current artifact is judged again with what the operator supplied.
|
|
1310
|
-
*
|
|
1311
|
-
* The answer is not a work order: it only adds a condition to the judgment, so nothing is sent to the
|
|
1312
|
-
* agent and no attempt is consumed. The verification that follows is a new task with a new identity,
|
|
1313
|
-
* which is what keeps a late verdict from the paused one from deciding the attempt.
|
|
1314
|
-
* @param text - What the operator added; never written to the trace.
|
|
1315
|
-
*/
|
|
1316
|
-
async answerLoop(text) {
|
|
1317
|
-
const loop = this.loop;
|
|
1318
|
-
if (loop === undefined || !loop.active)
|
|
1319
|
-
throw new Error('No loop is waiting for an answer');
|
|
1320
|
-
this.loopAnswer = text;
|
|
1321
|
-
const judged = this.verifier !== undefined && loop.protocol.verify !== undefined;
|
|
1322
|
-
loop.resume(judged ? 'verify' : 'turn');
|
|
1323
|
-
this.update({});
|
|
1324
|
-
this.traceEvent('loop', { phase: 'answered', runId: loop.progress.runId, judged, chars: text.length });
|
|
1325
|
-
if (judged) {
|
|
1326
|
-
this.verifyStep(loop);
|
|
1327
|
-
return;
|
|
1328
|
-
}
|
|
1329
|
-
// Without a forked verifier the answer is the next attempt's instruction: the agent is asked again
|
|
1330
|
-
// with the addition, and the attempt budget still decides how many times that may happen.
|
|
1331
|
-
this.loopPrompt = loop.answerPrompt(text);
|
|
1332
|
-
void this.flushLoop();
|
|
1333
|
-
}
|
|
1334
|
-
/** Stop a running review; the terminal progress stays visible for the reader.
|
|
1335
|
-
* @param reason - Why it stopped; the default is an operator action.
|
|
1336
|
-
*/
|
|
1337
|
-
stopLoop(reason = 'user-cancelled') {
|
|
1338
|
-
if (this.loop === undefined)
|
|
1339
|
-
return;
|
|
1340
|
-
this.forgetSettleTimer();
|
|
1341
|
-
this.clearLoopDeadline();
|
|
1342
|
-
this.abortVerification();
|
|
1343
|
-
this.loopEndedAt = undefined;
|
|
1344
|
-
this.loop.cancel(reason);
|
|
1345
|
-
this.traceLoopEnd(this.loop);
|
|
1346
|
-
this.loopPrompt = undefined;
|
|
1347
|
-
this.update({});
|
|
1348
|
-
}
|
|
1349
|
-
/** Drop a finished run's progress line, keeping an active one.
|
|
1350
|
-
*
|
|
1351
|
-
* D3 keeps a terminal result on screen because it is what the reader most needs to see — but it is a
|
|
1352
|
-
* result, not a status: the next line the operator runs means they have read it. An active run is
|
|
1353
|
-
* left alone, so `/loop answer` and `/loop stop` keep the target they were typed for.
|
|
1354
|
-
*/
|
|
1355
|
-
clearLoopResult() {
|
|
1356
|
-
const loop = this.loop;
|
|
1357
|
-
if (loop === undefined || loop.active)
|
|
1358
|
-
return;
|
|
1359
|
-
this.forgetLoop();
|
|
1360
|
-
this.update({});
|
|
1361
|
-
}
|
|
1362
|
-
/** Drop the loop entirely, without publishing a cancelled phase.
|
|
1363
|
-
* @param reason - Why it was dropped, recorded when it never reached a terminal phase itself.
|
|
1364
|
-
*/
|
|
1365
|
-
forgetLoop(reason = 'replaced') {
|
|
1366
|
-
// A run that is dropped while still active never published a terminal phase: close its trace span
|
|
1367
|
-
// here so one begin still has one end, then let the snapshot go.
|
|
1368
|
-
if (this.loop !== undefined && this.loop.active) {
|
|
1369
|
-
this.loop.cancel(reason);
|
|
1370
|
-
this.traceLoopEnd(this.loop);
|
|
1371
|
-
}
|
|
1372
|
-
this.forgetSettleTimer();
|
|
1373
|
-
this.clearLoopDeadline();
|
|
1374
|
-
this.abortVerification();
|
|
1375
|
-
this.loopPrevious = undefined;
|
|
1376
|
-
this.loopRunId = undefined;
|
|
1377
|
-
this.loopEndTraced = false;
|
|
1378
|
-
this.loopVerifySeq = 0;
|
|
1379
|
-
this.loopVerifyIdentity = undefined;
|
|
1380
|
-
this.loopVerifierMisses = 0;
|
|
1381
|
-
this.loopAnswer = undefined;
|
|
1382
|
-
this.loop = undefined;
|
|
1383
|
-
this.loopPrompt = undefined;
|
|
1384
|
-
this.loopEndedAt = undefined;
|
|
1385
|
-
}
|
|
1386
|
-
/** Note that an attempt's turn ended; its block may still be arriving.
|
|
1387
|
-
* @param sessionId - Session the host reported idle.
|
|
1388
|
-
*/
|
|
1389
|
-
settleLoop(sessionId) {
|
|
1390
|
-
const loop = this.loop;
|
|
1391
|
-
if (loop === undefined || !loop.active || !loop.settled || loop.sessionId !== sessionId)
|
|
1392
|
-
return;
|
|
1393
|
-
this.loopEndedAt ??= Date.now();
|
|
1394
|
-
this.trySettleLoop();
|
|
1395
|
-
}
|
|
1396
|
-
/** Consume the attempt once its result block is committed, or the grace period expires.
|
|
1397
|
-
*
|
|
1398
|
-
* The final assistant message can land a moment after the idle event, so an empty parse is not yet
|
|
1399
|
-
* a failed attempt: the next publish retries, and one timer covers a transcript that never grows.
|
|
1400
|
-
*/
|
|
1401
|
-
trySettleLoop() {
|
|
1402
|
-
const loop = this.loop;
|
|
1403
|
-
if (loop === undefined || !loop.active || !loop.settled) {
|
|
1404
|
-
this.forgetSettleTimer();
|
|
1405
|
-
this.loopEndedAt = undefined;
|
|
1406
|
-
return;
|
|
1407
|
-
}
|
|
1408
|
-
// Only a turn that actually ended may be consumed: a timer that fired just before the attempt
|
|
1409
|
-
// settled must not decide the attempt that replaced it.
|
|
1410
|
-
const endedAt = this.loopEndedAt;
|
|
1411
|
-
if (endedAt === undefined) {
|
|
1412
|
-
this.forgetSettleTimer();
|
|
1413
|
-
return;
|
|
1414
|
-
}
|
|
1415
|
-
// An independent verifier decides the round; the reply block below stays as its fallback. Its
|
|
1416
|
-
// branch keeps its own `verify` sub-state, so this must not announce settling over it.
|
|
1417
|
-
if (loop.protocol.verify !== undefined && this.verifier !== undefined) {
|
|
1418
|
-
if (!this.loopVerifying && !this.loopSettling)
|
|
1419
|
-
void this.verifyRound(loop);
|
|
1420
|
-
return;
|
|
1421
|
-
}
|
|
1422
|
-
// No forked verifier: reading the reply block, including the grace wait for it, is settling.
|
|
1423
|
-
loop.settling();
|
|
1424
|
-
const result = parseLoopResult(latestAssistantText(this.state.session.record.messages), loop.protocol);
|
|
1425
|
-
if (result === undefined && Date.now() - endedAt < LOOP_SETTLE_GRACE_MS) {
|
|
1426
|
-
if (this.loopSettleTimer === undefined) {
|
|
1427
|
-
this.loopSettleTimer = setTimeout(() => { this.loopSettleTimer = undefined; this.trySettleLoop(); }, LOOP_SETTLE_GRACE_MS);
|
|
1428
|
-
this.loopSettleTimer.unref();
|
|
1429
|
-
}
|
|
1430
|
-
return;
|
|
1431
|
-
}
|
|
1432
|
-
this.forgetSettleTimer();
|
|
1433
|
-
this.loopEndedAt = undefined;
|
|
1434
|
-
void this.settleChecked(loop, result);
|
|
1435
|
-
}
|
|
1436
|
-
/** Score one finished round out of band, in a process of its own.
|
|
1437
|
-
*
|
|
1438
|
-
* The child writes its verdict to a file, so a slow or failed verifier delays the attempt instead
|
|
1439
|
-
* of corrupting it: with no verdict the reply block decides, and with neither the attempt fails.
|
|
1440
|
-
* @param loop - The run whose attempt just finished.
|
|
1441
|
-
*/
|
|
1442
|
-
async verifyRound(loop) {
|
|
1443
|
-
const verifier = this.verifier;
|
|
1444
|
-
if (verifier === undefined)
|
|
1445
|
-
return;
|
|
1446
|
-
const { step, attempt } = loop.progress;
|
|
1447
|
-
const { kind } = loop.protocol;
|
|
1448
|
-
const runId = this.loopRunId ?? (this.loopRunId = randomUUID());
|
|
1449
|
-
// Every started verification is its own task: a failure retry or an operator answer must not
|
|
1450
|
-
// reuse the identity or the file of the task it replaced, or a late verdict could decide it.
|
|
1451
|
-
const seq = this.loopVerifySeq += 1;
|
|
1452
|
-
const file = verdictFile(this.verdictRoot, runId, kind, step, attempt, seq);
|
|
1453
|
-
const identity = verificationId(runId, kind, step, attempt, seq);
|
|
1454
|
-
const prompt = loop.protocol.verify(loop.progress, step, attempt, { file, verificationId: identity }, this.loopPrevious);
|
|
1455
|
-
// An answer the operator gave after an abstention only adds a condition: the same artifact is
|
|
1456
|
-
// judged again, under a fresh identity, and no attempt is consumed for it.
|
|
1457
|
-
const answered = this.loopAnswer === undefined ? prompt : `${prompt}\n\n## 操作者的补充判断\n${this.loopAnswer}`;
|
|
1458
|
-
this.loopVerifying = true;
|
|
1459
|
-
this.loopVerifyIdentity = identity;
|
|
1460
|
-
// The sub-state is a fact on the loop, not a note: the progress line and the status bar both read
|
|
1461
|
-
// it, and a retry's warning note stays beside it instead of being overwritten by a string.
|
|
1462
|
-
loop.verifying();
|
|
1463
|
-
this.update({});
|
|
1464
|
-
this.traceEvent('verify', { runId, phase: 'begin', kind, step, attempt, seq, file });
|
|
1465
|
-
const abort = new AbortController();
|
|
1466
|
-
this.loopVerifyAbort = abort;
|
|
1467
|
-
let outcome;
|
|
1468
|
-
try {
|
|
1469
|
-
// The reviewed workspace is declared here, where it is known, so the verifier's artifact check
|
|
1470
|
-
// never has to infer it from whatever directory the process happens to run in.
|
|
1471
|
-
outcome = await verifier.verify({ verificationId: identity, kind, step, attempt, prompt: answered, file,
|
|
1472
|
-
workspace: this.localDirectory,
|
|
1473
|
-
...(loop.protocol.artifact === undefined ? {} : { artifact: loop.protocol.artifact }),
|
|
1474
|
-
title: `[dsht-verify] ${loop.protocol.title} · ${step}/${attempt}` }, abort.signal);
|
|
1475
|
-
}
|
|
1476
|
-
catch (error) {
|
|
1477
|
-
outcome = { type: 'unavailable', reason: `verifier failed: ${errorText(error)}` };
|
|
1478
|
-
}
|
|
1479
|
-
finally {
|
|
1480
|
-
if (this.loopVerifyAbort === abort)
|
|
1481
|
-
this.loopVerifyAbort = undefined;
|
|
1482
|
-
this.loopVerifying = false;
|
|
1483
|
-
}
|
|
1484
|
-
// A newer verification task owns this attempt now: this task's verdict is stale by definition.
|
|
1485
|
-
if (this.loopVerifyIdentity !== identity) {
|
|
1486
|
-
this.traceEvent('verify', { runId, phase: 'stale', kind, step, attempt, seq });
|
|
1487
|
-
return;
|
|
1488
|
-
}
|
|
1489
|
-
// Verification outlives nothing: a cancelled or replaced loop must not be settled by its result.
|
|
1490
|
-
if (this.loop !== loop || !loop.active || !loop.settled) {
|
|
1491
|
-
this.traceEvent('verify', { runId, phase: 'abandoned', kind, step, attempt, seq });
|
|
1492
|
-
return;
|
|
1493
|
-
}
|
|
1494
|
-
this.forgetSettleTimer();
|
|
1495
|
-
this.loopEndedAt = undefined;
|
|
1496
|
-
// The review was cancelled: nothing to decide, and nothing to report as a verdict.
|
|
1497
|
-
if (outcome.type === 'cancelled') {
|
|
1498
|
-
this.traceEvent('verify', { runId, phase: 'cancelled', kind, step, attempt, seq });
|
|
1499
|
-
return;
|
|
1500
|
-
}
|
|
1501
|
-
// The host is waiting for a human the verifier cannot answer: stop with the request attached.
|
|
1502
|
-
// The verifier is blocked, not broken, so retrying would only hit the same wall three times.
|
|
1503
|
-
if (outcome.type === 'needs-human') {
|
|
1504
|
-
this.traceEvent('verify', { runId, phase: 'needs-human', kind, step, attempt, seq, request: outcome.request.kind });
|
|
1505
|
-
this.clearLoopDeadline();
|
|
1506
|
-
loop.human(outcome.request);
|
|
1507
|
-
this.traceLoopEnd(loop);
|
|
1508
|
-
this.update({});
|
|
1509
|
-
return;
|
|
1510
|
-
}
|
|
1511
|
-
if (outcome.type === 'verified') {
|
|
1512
|
-
this.traceEvent('verify', { runId, phase: 'verified', kind, step, attempt, seq,
|
|
1513
|
-
score: outcome.result.score ?? -1, blocked: outcome.result.blocked === true, status: outcome.result.status ?? 'none' });
|
|
1514
|
-
this.loopVerifierMisses = 0;
|
|
1515
|
-
await this.settleChecked(loop, outcome.result);
|
|
1516
|
-
return;
|
|
1517
|
-
}
|
|
1518
|
-
// Unavailable is not a verdict, so it never consumes the attempt: retry the verifier, and only
|
|
1519
|
-
// then stop the run. Self-scoring is opt-in and is always visible in the progress line.
|
|
1520
|
-
// A failure that says it is not retryable (an unconfirmed remote cancel, a bad configuration)
|
|
1521
|
-
// would only repeat itself, so it is reported instead of spending the retry budget.
|
|
1522
|
-
if (outcome.retryable === false) {
|
|
1523
|
-
this.traceEvent('verify', { runId, phase: 'unavailable', kind, step, attempt, seq, retryable: false, reason: outcome.reason.slice(0, 200) });
|
|
1524
|
-
loop.note(`⚠ verification unavailable · ${outcome.reason}`);
|
|
1525
|
-
this.clearLoopDeadline();
|
|
1526
|
-
loop.unavailable();
|
|
1527
|
-
this.traceLoopEnd(loop);
|
|
1528
|
-
this.update({});
|
|
1529
|
-
return;
|
|
1530
|
-
}
|
|
1531
|
-
this.loopVerifierMisses += 1;
|
|
1532
|
-
if (this.loopVerifierMisses <= VERIFIER_RETRIES) {
|
|
1533
|
-
this.traceEvent('verify', { runId, phase: 'retry', kind, step, attempt, seq, misses: this.loopVerifierMisses, reason: outcome.reason.slice(0, 200) });
|
|
1534
|
-
loop.note(`⚠ verification unavailable · retrying (${outcome.reason})`);
|
|
1535
|
-
this.update({});
|
|
1536
|
-
void this.verifyRound(loop);
|
|
1537
|
-
return;
|
|
1538
|
-
}
|
|
1539
|
-
if (this.allowSelfFallback) {
|
|
1540
|
-
const fallback = parseLoopResult(latestAssistantText(this.state.session.record.messages), loop.protocol);
|
|
1541
|
-
this.traceEvent('verify', { runId, phase: 'fallback', kind, step, attempt, seq, block: fallback !== undefined });
|
|
1542
|
-
await this.settleChecked(loop, fallback, fallback === undefined
|
|
1543
|
-
? `⚠ verification fallback · self-reported (and no reply block: ${outcome.reason})`
|
|
1544
|
-
: '⚠ verification fallback · self-reported');
|
|
1545
|
-
return;
|
|
1546
|
-
}
|
|
1547
|
-
this.traceEvent('verify', { runId, phase: 'unavailable', kind, step, attempt, seq, retryable: true, misses: this.loopVerifierMisses, reason: outcome.reason.slice(0, 200) });
|
|
1548
|
-
loop.note(`⚠ verification unavailable · ${outcome.reason}`);
|
|
1549
|
-
this.clearLoopDeadline();
|
|
1550
|
-
loop.unavailable();
|
|
1551
|
-
this.traceLoopEnd(loop);
|
|
1552
|
-
this.update({});
|
|
1553
|
-
}
|
|
1554
|
-
/** Apply a protocol's own artifact requirement, then settle the attempt.
|
|
1555
|
-
*
|
|
1556
|
-
* The score is the verifier's judgement; whether the round's conclusion actually reached the
|
|
1557
|
-
* artifact is a fact this client checks itself. A hard condition may not be overridden by a score:
|
|
1558
|
-
* a round whose section is missing fails even at 10/10, and the missing line is fed back to the
|
|
1559
|
-
* next attempt like any other finding. An artifact this client cannot read cannot be checked, so
|
|
1560
|
-
* the verdict stands rather than being failed on a boundary.
|
|
1561
|
-
* @param loop - The run whose attempt just finished.
|
|
1562
|
-
* @param result - Verdict to consume.
|
|
1563
|
-
* @param note - Note to show instead, when the check accepted the verdict unchanged.
|
|
1564
|
-
*/
|
|
1565
|
-
async settleChecked(loop, result, note = '') {
|
|
1566
|
-
if (this.loopSettling)
|
|
1567
|
-
return;
|
|
1568
|
-
this.loopSettling = true;
|
|
1569
|
-
try {
|
|
1570
|
-
const marker = result === undefined || result.blocked === true || result.abstained === true
|
|
1571
|
-
? undefined : loop.protocol.artifactMarker?.(loop.progress.step);
|
|
1572
|
-
const artifact = loop.protocol.artifact;
|
|
1573
|
-
if (marker === undefined || artifact === undefined || result === undefined) {
|
|
1574
|
-
this.settleWith(loop, result, note);
|
|
1575
|
-
return;
|
|
1576
|
-
}
|
|
1577
|
-
const text = await readText(join(this.localDirectory, artifact));
|
|
1578
|
-
// Reading the artifact is I/O, so the run may have moved on before it came back.
|
|
1579
|
-
if (this.loop !== loop || !loop.active || !loop.settled)
|
|
1580
|
-
return;
|
|
1581
|
-
if (text === undefined || text.includes(marker)) {
|
|
1582
|
-
this.settleWith(loop, result, note);
|
|
1583
|
-
return;
|
|
1584
|
-
}
|
|
1585
|
-
this.traceEvent('artifact', { phase: 'missing', artifact, step: loop.progress.step, score: result.score ?? -1 });
|
|
1586
|
-
const reported = result.score === undefined ? '没有分数' : `${result.score} 分`;
|
|
1587
|
-
this.settleWith(loop, { ...result, score: undefined,
|
|
1588
|
-
findings: [...(result.findings ?? []), `工作区文件 ${artifact} 缺少本轮小节「${marker}」`] }, `⚠ artifact check · ${artifact} 缺少本轮小节「${marker}」(验证者给了 ${reported},本轮不通过)`);
|
|
1589
|
-
}
|
|
1590
|
-
finally {
|
|
1591
|
-
this.loopSettling = false;
|
|
1592
|
-
}
|
|
1593
|
-
}
|
|
1594
|
-
/** Apply one attempt's verdict, and continue the run when it has a next step.
|
|
1595
|
-
* @param loop - The run being settled.
|
|
1596
|
-
* @param result - Verdict to consume, or undefined when the attempt produced none.
|
|
1597
|
-
*/
|
|
1598
|
-
settleWith(loop, result, note = '') {
|
|
1599
|
-
const before = loop.progress;
|
|
1600
|
-
const step = loop.settle(result);
|
|
1601
|
-
// The answer was the condition for this judgment, so it is spent once the verdict is in.
|
|
1602
|
-
this.loopAnswer = undefined;
|
|
1603
|
-
// A pause is not an end: the run keeps its span (and its deadline) until it is answered or ended.
|
|
1604
|
-
if (step.kind !== 'continue' && !loop.active) {
|
|
1605
|
-
this.clearLoopDeadline();
|
|
1606
|
-
// A decision ended the run: close its trace span with the phase and reason it stopped in.
|
|
1607
|
-
this.traceLoopEnd(loop);
|
|
1608
|
-
}
|
|
1609
|
-
// The note describes the attempt just decided, so it is applied after the state moved on.
|
|
1610
|
-
loop.note(note);
|
|
1611
|
-
if (step.kind === 'continue') {
|
|
1612
|
-
// A retry on the same step carries the verdict that caused it; a new step starts clean.
|
|
1613
|
-
this.loopPrevious = before.step === loop.progress.step && result !== undefined
|
|
1614
|
-
? { step: before.step, attempt: before.attempt, result } : undefined;
|
|
1615
|
-
// A passing verdict advanced the step. When the record verifies first, the new round is
|
|
1616
|
-
// verified against the artifact as it stands before anyone is asked to change it.
|
|
1617
|
-
if (loop.progress.step !== before.step && this.startsByVerifying(loop)) {
|
|
1618
|
-
this.update({});
|
|
1619
|
-
this.verifyStep(loop);
|
|
1620
|
-
return;
|
|
1621
|
-
}
|
|
1622
|
-
const findings = result === undefined ? [] : findingsLines(result);
|
|
1623
|
-
// `step.prompt` is already brief-aware: a run that began by verifying has sent no prompt yet, so
|
|
1624
|
-
// its first work message is the brief rather than a follow-up that describes nothing.
|
|
1625
|
-
this.loopPrompt = [step.prompt, ...findings].join('\n');
|
|
1626
|
-
}
|
|
1627
|
-
this.update({});
|
|
1628
|
-
if (step.kind === 'continue')
|
|
1629
|
-
void this.flushLoop();
|
|
1630
|
-
}
|
|
1631
|
-
/** Stop an in-flight verification, if any; its result can no longer decide anything. */
|
|
1632
|
-
abortVerification() {
|
|
1633
|
-
this.loopVerifyAbort?.abort();
|
|
1634
|
-
this.loopVerifyAbort = undefined;
|
|
1635
|
-
this.loopVerifying = false;
|
|
1636
|
-
}
|
|
1637
|
-
/** Stop the run because the whole-run budget expired; a budget stop, not a verdict.
|
|
1638
|
-
*
|
|
1639
|
-
* Any verification in flight is cancelled (bounded, as everywhere else) and its late result can
|
|
1640
|
-
* no longer decide anything, because the loop is no longer active.
|
|
1641
|
-
*/
|
|
1642
|
-
expireLoopDeadline() {
|
|
1643
|
-
this.loopDeadlineTimer = undefined;
|
|
1644
|
-
const loop = this.loop;
|
|
1645
|
-
if (loop === undefined || !loop.active)
|
|
1646
|
-
return;
|
|
1647
|
-
this.forgetSettleTimer();
|
|
1648
|
-
this.abortVerification();
|
|
1649
|
-
this.loopEndedAt = undefined;
|
|
1650
|
-
loop.note('⚠ deadline reached');
|
|
1651
|
-
loop.deadline();
|
|
1652
|
-
this.traceLoopEnd(loop);
|
|
1653
|
-
this.update({});
|
|
1654
|
-
}
|
|
1655
|
-
/** Drop the run deadline, if one is armed. */
|
|
1656
|
-
clearLoopDeadline() {
|
|
1657
|
-
if (this.loopDeadlineTimer === undefined)
|
|
1658
|
-
return;
|
|
1659
|
-
clearTimeout(this.loopDeadlineTimer);
|
|
1660
|
-
this.loopDeadlineTimer = undefined;
|
|
1661
|
-
}
|
|
1662
|
-
/** Drop a pending settle timer, if any. */
|
|
1663
|
-
forgetSettleTimer() {
|
|
1664
|
-
if (this.loopSettleTimer === undefined)
|
|
1665
|
-
return;
|
|
1666
|
-
clearTimeout(this.loopSettleTimer);
|
|
1667
|
-
this.loopSettleTimer = undefined;
|
|
1668
|
-
}
|
|
1669
|
-
/** Send the prompt the loop is holding, once the client can actually send it. */
|
|
1670
|
-
async flushLoop() {
|
|
1671
|
-
const loop = this.loop;
|
|
1672
|
-
const prompt = this.loopPrompt;
|
|
1673
|
-
if (loop === undefined || prompt === undefined || !loop.active)
|
|
1674
|
-
return;
|
|
1675
|
-
// Nothing of this loop writes while an independent verifier is judging it: the round under
|
|
1676
|
-
// verification must be the round that was scored. A prompt that appears meanwhile is sent when
|
|
1677
|
-
// the verdict lands (or dropped with the run), never interleaved with the verification.
|
|
1678
|
-
if (this.loopVerifying)
|
|
1679
|
-
return;
|
|
1680
|
-
if (this.state.sessionId !== loop.sessionId)
|
|
1681
|
-
return;
|
|
1682
|
-
if (!this.state.online || this.foreground !== undefined || this.state.pending.length)
|
|
1683
|
-
return;
|
|
1684
|
-
// Consume before awaiting, so a re-entrant update cannot send the same prompt twice.
|
|
1685
|
-
this.loopPrompt = undefined;
|
|
1686
|
-
loop.sent();
|
|
1687
|
-
this.update({});
|
|
1688
|
-
try {
|
|
1689
|
-
await this.session.promptInternal(prompt);
|
|
1690
|
-
}
|
|
1691
|
-
catch (error) {
|
|
1692
|
-
// The next prompt was rejected (a waiting interaction, a lost snapshot, offline): the run ends
|
|
1693
|
-
// with that reason recorded instead of vanishing without a trace.
|
|
1694
|
-
this.rejectLoopSend(loop, error);
|
|
1695
|
-
}
|
|
1696
|
-
}
|
|
1697
1000
|
/** Cancel the active turn; pending queue items remain host-owned. */
|
|
1698
1001
|
async cancelTurn() {
|
|
1699
|
-
this.
|
|
1002
|
+
this.loops.stop();
|
|
1700
1003
|
await this.session.cancelTurn();
|
|
1701
1004
|
}
|
|
1702
1005
|
/** Add a page before the retained window.
|
|
@@ -1710,12 +1013,6 @@ export class Controller {
|
|
|
1710
1013
|
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
1711
1014
|
*/
|
|
1712
1015
|
async searchHistory(query, signal) { return this.session.searchHistory(query, signal); }
|
|
1713
|
-
/** Load a separate small window ending at a search target.
|
|
1714
|
-
* @param target - Durable message sequence to display.
|
|
1715
|
-
* @param signal - Cancels the target-page request.
|
|
1716
|
-
* @returns A caller-owned historical window that must be disposed when closed.
|
|
1717
|
-
*/
|
|
1718
|
-
async historyAt(target, signal) { return this.session.historyAt(target, signal); }
|
|
1719
1016
|
/** Plain rows and offsets for one laid-out record; the projection stays in the session domain. */
|
|
1720
1017
|
render(input) {
|
|
1721
1018
|
return this.session.render(input);
|