@itookit/dsht 0.5.1 → 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.i18n.yaml +2 -2
- package/README.md +10 -4
- package/README.zh.md +12 -6
- 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 +22 -2
- 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 +42 -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 +20 -234
- package/dist/controller/controller.js +113 -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/loop-prompts-schema.d.ts +16 -2
- package/dist/controller/loop-prompts-schema.js +106 -27
- package/dist/controller/loop-prompts.d.ts +17 -2
- package/dist/controller/loop-prompts.generated.js +2 -1
- package/dist/controller/loop-prompts.js +35 -9
- package/dist/controller/loop-protocols.d.ts +3 -1
- package/dist/controller/loop-protocols.js +8 -3
- package/dist/controller/loop-source.d.ts +74 -0
- package/dist/controller/loop-source.js +224 -0
- package/dist/controller/verifier.d.ts +4 -0
- package/dist/cost/controller.d.ts +3 -1
- package/dist/cost/controller.js +26 -7
- package/dist/cost/index.d.ts +1 -1
- package/dist/cost/index.js +1 -1
- package/dist/cost/ledger-files.d.ts +20 -0
- package/dist/cost/ledger-files.js +115 -15
- package/dist/cost/ledger.d.ts +31 -6
- package/dist/cost/ledger.js +74 -22
- package/dist/cost/pricing.d.ts +39 -0
- package/dist/cost/pricing.js +46 -0
- package/dist/cost/scanner.js +1 -0
- package/dist/cost/types.d.ts +9 -3
- 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/storage/files.d.ts +8 -0
- package/dist/storage/files.js +18 -1
- package/dist/storage/index.d.ts +1 -1
- package/dist/storage/index.js +1 -1
- package/dist/transport/client.d.ts +4 -3
- package/dist/transport/client.js +71 -25
- package/dist/ui/app.js +88 -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/cost.d.ts +6 -0
- package/dist/ui/dialogs/cost.js +5 -1
- package/dist/ui/dialogs/loop.d.ts +5 -4
- package/dist/ui/dialogs/loop.js +14 -6
- 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/loop.yaml +230 -0
- package/package.json +5 -4
|
@@ -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,13 @@ 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 {
|
|
20
|
+
import { loopSourceInfo } from "./loop-prompts.js";
|
|
22
21
|
import { SessionPeek } from "../session/peek.js";
|
|
23
22
|
import { costAddresses } from "../cost/scanner.js";
|
|
24
23
|
import { sessionLabel } from "../session-title.js";
|
|
@@ -38,8 +37,6 @@ function shellEnv() {
|
|
|
38
37
|
delete env.DSH_URL;
|
|
39
38
|
return env;
|
|
40
39
|
}
|
|
41
|
-
/** How many times a verifier outage is retried before the run reports verification unavailable. */
|
|
42
|
-
const VERIFIER_RETRIES = 2;
|
|
43
40
|
/** Sources this client registers before it forgets the oldest; a session list can still find them. */
|
|
44
41
|
const SOURCE_LIMIT = 50;
|
|
45
42
|
/** The tag every verifier session title starts with, so a reader can tell it apart in a list. */
|
|
@@ -49,13 +46,6 @@ function verifierLabel(title) {
|
|
|
49
46
|
const stripped = title.startsWith(VERIFIER_TAG) ? title.slice(VERIFIER_TAG.length) : title;
|
|
50
47
|
return stripped.trim() === '' ? title : stripped.trim();
|
|
51
48
|
}
|
|
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
49
|
/** File `/handoff` clears on this machine before it asks the agent to write a handoff. */
|
|
60
50
|
const HANDOFF_FILE = 'HANDOFF.md';
|
|
61
51
|
/** Instruction `/handoff` sends once this client's own copy of the file is gone.
|
|
@@ -95,50 +85,12 @@ export class Controller {
|
|
|
95
85
|
trace;
|
|
96
86
|
/** Shortcut prompts the operator saved; in memory for this run when no path was supplied. */
|
|
97
87
|
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
88
|
/** Finished turns of the selected session, so a waiter never has to sample a cached flag. */
|
|
103
89
|
completedTurns = 0;
|
|
104
90
|
/** Sessions the host reported busy, so a lone idle frame cannot claim a finished turn. */
|
|
105
91
|
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;
|
|
92
|
+
/** Loop execution owns all verifier, settling and deadline state. */
|
|
93
|
+
loops;
|
|
142
94
|
/** Local `!` commands, run on this machine and shown inline in the transcript. */
|
|
143
95
|
shell;
|
|
144
96
|
/** Mutating surface the UI drives. */
|
|
@@ -155,26 +107,20 @@ export class Controller {
|
|
|
155
107
|
memoryLogPath;
|
|
156
108
|
/** Directory this client runs in. */
|
|
157
109
|
localDirectory;
|
|
158
|
-
/** Independent verifier for scored rounds, when one was supplied. */
|
|
159
|
-
verifier;
|
|
160
110
|
/** Session opened at startup, when one was named. */
|
|
161
111
|
initialSession;
|
|
162
112
|
observers = new Set();
|
|
163
113
|
selector = 0;
|
|
164
114
|
connectionSettled = false;
|
|
115
|
+
/** Startup defaults apply once; subsequent generations restore the operator's selection. */
|
|
116
|
+
initialized = false;
|
|
165
117
|
/** Counter behind `commandId`, so every executed line has one identifier in begin and end. */
|
|
166
118
|
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();
|
|
119
|
+
/** Scheduling and cancellation are owned independently of domain operations. */
|
|
120
|
+
foregroundSlot = new ForegroundSlot({
|
|
121
|
+
changed: started => this.update(started ? { lastFailure: '' } : {}),
|
|
122
|
+
trace: event => this.traceEvent('foreground', event),
|
|
123
|
+
});
|
|
178
124
|
/** Read-only follower for the full-screen view; it borrows the connection and never selects. */
|
|
179
125
|
peek;
|
|
180
126
|
/** Sources this client created, keyed by session id; the host cannot record that lineage itself. */
|
|
@@ -193,10 +139,15 @@ export class Controller {
|
|
|
193
139
|
this.memoryLogPath = options.memoryLogPath;
|
|
194
140
|
this.localDirectory = options.localDirectory ?? process.cwd();
|
|
195
141
|
this.initialSession = initialSession;
|
|
196
|
-
this.
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
142
|
+
this.loops = new LoopCoordinator({
|
|
143
|
+
facts: () => ({ sessionId: this.state.sessionId, online: this.state.online,
|
|
144
|
+
ready: this.state.session.record.ready, busy: this.foregroundSlot.snapshot !== undefined, pending: this.state.pending.length > 0 }),
|
|
145
|
+
reply: () => latestAssistantText(this.state.session.record.messages),
|
|
146
|
+
send: prompt => this.session.promptInternal(prompt),
|
|
147
|
+
publish: failure => this.update(failure === undefined ? {} : { lastFailure: failure }),
|
|
148
|
+
trace: (event, detail) => this.traceEvent(event, detail),
|
|
149
|
+
}, { directory: this.localDirectory, verifier: options.verifier, verdictRoot: options.verdictRoot,
|
|
150
|
+
deadlineMs: options.deadlineMs, allowSelfFallback: options.allowSelfFallback });
|
|
200
151
|
const connectionOptions = { base, token, initialSession, makeClient, authenticate };
|
|
201
152
|
this.connection = new ConnectionController(this, connectionOptions, this);
|
|
202
153
|
// The view follows other sessions over the same connection; it never becomes a second writer.
|
|
@@ -210,13 +161,21 @@ export class Controller {
|
|
|
210
161
|
env: () => shellEnv(),
|
|
211
162
|
anchor: () => this.state.session.record.readThrough,
|
|
212
163
|
}, shellEnabled);
|
|
213
|
-
this.catalog = new CatalogController(
|
|
164
|
+
this.catalog = new CatalogController({
|
|
165
|
+
client: () => this.connection.client(), require: () => this.connection.require(),
|
|
166
|
+
online: () => this.state.online, signal: () => this.connection.signal(),
|
|
167
|
+
selection: () => ({ revision: this.selection(), sessionId: this.state.sessionId }),
|
|
168
|
+
publish: patch => this.update(patch),
|
|
169
|
+
});
|
|
214
170
|
if (costs)
|
|
215
171
|
this.cost = new CostController(costs, {
|
|
216
172
|
client: () => this.connection.client(),
|
|
217
173
|
online: () => this.state.online,
|
|
218
174
|
signal: () => this.connection.signal(),
|
|
219
175
|
publish: () => this.update({}),
|
|
176
|
+
// The session on screen is scanned whatever its age: `/cost` reports its own total, and the
|
|
177
|
+
// window filter exists to skip sessions nobody is looking at.
|
|
178
|
+
selectedSessionId: () => this.state.sessionId,
|
|
220
179
|
// The scan already reads every session's whole history; handing its pages to the session
|
|
221
180
|
// domain lets the prompt cache pick them up, so one open does not pay for a second walk.
|
|
222
181
|
scanPage: (sessionId, records) => this.session.rememberScanPage(sessionId, records),
|
|
@@ -251,19 +210,19 @@ export class Controller {
|
|
|
251
210
|
/** Whether an operation owns the client's foreground slot right now.
|
|
252
211
|
* @returns True while an operation is running.
|
|
253
212
|
*/
|
|
254
|
-
busy() { return this.
|
|
213
|
+
busy() { return this.foregroundSlot.snapshot !== undefined; }
|
|
255
214
|
update(patch) {
|
|
256
215
|
const previous = this.state;
|
|
257
216
|
const next = { ...this.state, ...patch, version: this.state.version + 1 };
|
|
258
217
|
next.pending = next.online && next.screen === 'chat' && this.session
|
|
259
|
-
? this.session.pendingFor(next) : [];
|
|
218
|
+
? this.session.pendingFor(next.sessionId) : [];
|
|
260
219
|
// The shell service owns its blocks; state carries only the plain snapshot the UI renders.
|
|
261
220
|
if (this.shell)
|
|
262
221
|
next.shell = this.shell.snapshot();
|
|
263
222
|
// A loop belongs to one session: selecting a different session ends it, but a reconnect — which
|
|
264
223
|
// 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.
|
|
224
|
+
if (next.sessionId !== undefined && next.sessionId !== this.state.sessionId && this.loops.sessionId !== next.sessionId) {
|
|
225
|
+
this.loops.forget();
|
|
267
226
|
// The read-only view belongs to the conversation that opened it; leaving that conversation
|
|
268
227
|
// closes it, and the session switch that follows is never left rendering someone else's rows.
|
|
269
228
|
this.dropPeek();
|
|
@@ -272,12 +231,7 @@ export class Controller {
|
|
|
272
231
|
this.traceTransition(previous, next);
|
|
273
232
|
for (const observer of this.observers)
|
|
274
233
|
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();
|
|
234
|
+
this.loops.changed();
|
|
281
235
|
}
|
|
282
236
|
/** Record one diagnostic event; a no-op when no trace path was configured. */
|
|
283
237
|
traceEvent(event, detail = {}) {
|
|
@@ -334,33 +288,34 @@ export class Controller {
|
|
|
334
288
|
*/
|
|
335
289
|
buildActions() {
|
|
336
290
|
return {
|
|
337
|
-
foreground: (kind, label, work, wait) => this.
|
|
338
|
-
cancelForeground: () => this.
|
|
291
|
+
foreground: (kind, label, work, wait) => this.foregroundSlot.run(kind, label, work, wait),
|
|
292
|
+
cancelForeground: () => this.foregroundSlot.cancel(),
|
|
339
293
|
openPeek: id => this.openPeek(id),
|
|
340
294
|
closePeek: () => this.closePeek(),
|
|
341
|
-
switchWorkspace: id => this.runAction('navigation', 'Switching workspace…',
|
|
342
|
-
switchSession: query => this.runAction('navigation', 'Switching session…',
|
|
295
|
+
switchWorkspace: id => this.runAction('navigation', 'Switching workspace…', signal => this.switchWorkspace(id, signal)),
|
|
296
|
+
switchSession: query => this.runAction('navigation', 'Switching session…', signal => this.switchSession(query, signal)),
|
|
343
297
|
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…',
|
|
298
|
+
createWorkspace: path => this.runAction('navigation', 'Registering workspace…', signal => this.createWorkspace(path, signal)),
|
|
299
|
+
createSession: () => this.runAction('navigation', 'Creating session…', signal => this.createSession(signal)),
|
|
346
300
|
createVerifierSession: title => this.runActionValue('verifier', 'Creating verifier session…', () => this.createVerifierSession(title)),
|
|
347
|
-
cancelVerifierSession: sessionId => this.
|
|
301
|
+
cancelVerifierSession: sessionId => this.runImmediateAction(async () => {
|
|
348
302
|
await this.session.cancelNamedSession(sessionId);
|
|
349
303
|
// The run is over even though its transcript stays readable; the list should say so.
|
|
350
304
|
this.endSource(sessionId, Date.now());
|
|
351
305
|
}),
|
|
352
|
-
showPicker: screen => this.runAction('picker', 'Listing…',
|
|
306
|
+
showPicker: screen => this.runAction('picker', 'Listing…', signal => this.showPicker(screen, signal)),
|
|
353
307
|
removeTarget: target => this.runAction('removal', 'Removing…', () => this.removeTarget(target)),
|
|
354
308
|
removalTarget: (kind, query) => this.runActionValue('removal', 'Reading target…', () => this.removalTarget(kind, query)),
|
|
355
309
|
waitForHistory: signal => this.runAction('history', 'Loading history…', () => this.waitForHistory(signal)),
|
|
356
310
|
searchSessions: (query, workspaceOnly, signal) => this.runActionValue('search', 'Searching sessions…', () => this.searchSessions(query, workspaceOnly, signal)),
|
|
357
|
-
|
|
311
|
+
openHistory: (target, signal) => this.runAction('history', 'Opening history…', operation => this.session.openHistory(target, AbortSignal.any([signal, operation]))),
|
|
312
|
+
searchHistory: (query, signal) => this.runActionValue('search', 'Searching history…', operation => this.searchHistory(query, AbortSignal.any([signal, operation]))),
|
|
358
313
|
prompt: text => this.runAction('prompt', 'Sending…', () => this.prompt(text)),
|
|
359
314
|
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.
|
|
315
|
+
startLoop: (protocol, limits) => this.runAction('loop', 'Starting loop…', () => this.loops.start(protocol, limits)),
|
|
316
|
+
stopLoop: () => this.loops.stop(),
|
|
317
|
+
clearLoopResult: () => this.loops.clearResult(),
|
|
318
|
+
answerLoop: text => this.runAction('loop', 'Answering the verifier…', () => this.loops.answer(text)),
|
|
364
319
|
cancelTurn: () => this.runAction('interaction', 'Cancelling…', () => this.cancelTurn()),
|
|
365
320
|
answer: value => this.runAction('interaction', 'Answering…', () => this.answer(value)),
|
|
366
321
|
answerQuestion: input => this.runAction('interaction', 'Answering…', async () => {
|
|
@@ -370,19 +325,19 @@ export class Controller {
|
|
|
370
325
|
approve: allowed => this.runAction('interaction', 'Answering…', () => this.approve(allowed)),
|
|
371
326
|
dismissQuestion: () => this.runAction('interaction', 'Dismissing…', () => this.dismissQuestion()),
|
|
372
327
|
interrupt: force => this.interrupt(force),
|
|
373
|
-
older: (signal, transcript) => this.runAction('history', 'Loading history…',
|
|
374
|
-
historyThrough: (target, signal) => this.runAction('history', 'Loading history…',
|
|
328
|
+
older: (signal, transcript) => this.runAction('history', 'Loading history…', operation => this.older(signal === undefined ? operation : AbortSignal.any([signal, operation]), transcript)),
|
|
329
|
+
historyThrough: (target, signal) => this.runAction('history', 'Loading history…', operation => this.historyThrough(target, AbortSignal.any([signal, operation]))),
|
|
375
330
|
removeQueued: itemId => this.runAction('command', 'Removing queued input…', () => this.removeQueued(itemId)),
|
|
376
|
-
savePrompt: text => this.
|
|
331
|
+
savePrompt: text => this.runImmediateAction(async () => {
|
|
377
332
|
await this.promptStore.save(text);
|
|
378
333
|
this.update({ lastFailure: '' });
|
|
379
334
|
}),
|
|
380
|
-
updatePrompt: (id, text) => this.
|
|
335
|
+
updatePrompt: (id, text) => this.runImmediateAction(async () => {
|
|
381
336
|
if (!await this.promptStore.update(id, text))
|
|
382
337
|
throw new Error('That saved prompt no longer exists');
|
|
383
338
|
this.update({ lastFailure: '' });
|
|
384
339
|
}),
|
|
385
|
-
deletePrompt: id => this.
|
|
340
|
+
deletePrompt: id => this.runImmediateAction(async () => {
|
|
386
341
|
if (!await this.promptStore.remove(id))
|
|
387
342
|
throw new Error('That saved prompt no longer exists');
|
|
388
343
|
this.update({ lastFailure: '' });
|
|
@@ -390,15 +345,15 @@ export class Controller {
|
|
|
390
345
|
command: (line, signal) => this.runActionValue('command', 'Running command…', () => this.command(line, signal)),
|
|
391
346
|
exportLog: (path, signal) => this.runActionValue('export', 'Exporting session log…', () => this.exportLog(path, signal)),
|
|
392
347
|
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…',
|
|
348
|
+
selectModel: (provider, model, effort) => this.runAction('model', 'Selecting model…', signal => this.catalog.selectModel(provider, model, effort, signal)),
|
|
349
|
+
modelCatalog: () => this.runActionValue('model', 'Loading models…', signal => this.catalog.modelCatalog(signal)),
|
|
350
|
+
refreshCosts: signal => this.runAction('cost', 'Refreshing costs…', operation => this.refreshCosts(signal === undefined ? operation : AbortSignal.any([signal, operation]))),
|
|
396
351
|
loadPresetNames: () => this.loadPresetNames(),
|
|
397
352
|
clearFailure: () => this.clearFailure(),
|
|
398
353
|
enterPath: () => this.enterPath(),
|
|
399
354
|
pickWorkspace: id => this.pickWorkspace(id),
|
|
400
355
|
showChat: () => this.showChat(),
|
|
401
|
-
|
|
356
|
+
showLatest: () => this.session.setViewWindow(undefined),
|
|
402
357
|
pinHistory: pinned => this.pinHistory(pinned),
|
|
403
358
|
setAnswers: answers => this.setAnswers(answers),
|
|
404
359
|
setOption: option => this.setOption(option),
|
|
@@ -415,8 +370,8 @@ export class Controller {
|
|
|
415
370
|
return {
|
|
416
371
|
get running() { return controller.running; },
|
|
417
372
|
get turnsCompleted() { return controller.completedTurns; },
|
|
418
|
-
get forkedVerification() { return controller.
|
|
419
|
-
get selfScoring() { return controller.
|
|
373
|
+
get forkedVerification() { return controller.loops.forkedVerification; },
|
|
374
|
+
get selfScoring() { return controller.loops.selfScoring; },
|
|
420
375
|
get connectionSettled() { return controller.connectionSettled; },
|
|
421
376
|
get sessionName() { return controller.sessionName; },
|
|
422
377
|
get sessionMode() { return controller.sessionMode; },
|
|
@@ -425,22 +380,22 @@ export class Controller {
|
|
|
425
380
|
get record() { return controller.record; },
|
|
426
381
|
get window() { return controller.window; },
|
|
427
382
|
get interaction() { return controller.interaction; },
|
|
428
|
-
get telemetry() { return controller.telemetry; },
|
|
383
|
+
get telemetry() { return controller.telemetry.reader; },
|
|
429
384
|
get recallAtOldest() { return controller.recallAtOldest; },
|
|
430
385
|
get recallLength() { return controller.recallLength; },
|
|
431
386
|
get recallHasOlder() { return controller.recallHasOlder; },
|
|
432
387
|
get prompts() { return controller.promptStore.list; },
|
|
433
388
|
get promptsError() { return controller.promptStore.error; },
|
|
434
|
-
get loop() { return controller.
|
|
435
|
-
get foreground() { return controller.
|
|
389
|
+
get loop() { return controller.loops.progress; },
|
|
390
|
+
get foreground() { return controller.foregroundSlot.snapshot; },
|
|
436
391
|
get sources() { return controller.outputSources(); },
|
|
437
392
|
get peek() { return controller.peekSnapshot(); },
|
|
438
393
|
get activity() { return controller.activity; },
|
|
439
394
|
get loopRecords() { return listLoopRecords(); },
|
|
395
|
+
get loopSource() { return loopSourceInfo(); },
|
|
440
396
|
pendingCounts: () => controller.pendingCounts(),
|
|
441
397
|
recall: (direction, current) => controller.recall(direction, current),
|
|
442
398
|
references: (query, signal) => controller.references(query, signal),
|
|
443
|
-
historyAt: (target, signal) => controller.historyAt(target, signal),
|
|
444
399
|
render: input => controller.render(input),
|
|
445
400
|
};
|
|
446
401
|
}
|
|
@@ -457,9 +412,9 @@ export class Controller {
|
|
|
457
412
|
/** Cancel retries and HTTP, close the socket, and release session and catalog work. */
|
|
458
413
|
async stop() {
|
|
459
414
|
// Nobody waits forever for a slot that will never be handed out again.
|
|
460
|
-
this.
|
|
461
|
-
|
|
462
|
-
|
|
415
|
+
this.foregroundSlot.close();
|
|
416
|
+
this.catalog.close();
|
|
417
|
+
await this.loops.close();
|
|
463
418
|
this.dropPeek();
|
|
464
419
|
await this.shell.stop();
|
|
465
420
|
await this.connection.stop();
|
|
@@ -489,13 +444,13 @@ export class Controller {
|
|
|
489
444
|
* @returns Whether the operation ran to completion.
|
|
490
445
|
*/
|
|
491
446
|
async runAction(kind, label, operation) {
|
|
492
|
-
if (!this.state.online)
|
|
493
|
-
|
|
494
|
-
if (!this.ownsForeground() && this.foreground !== undefined)
|
|
447
|
+
if (!this.state.online) {
|
|
448
|
+
this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
|
|
495
449
|
return false;
|
|
496
|
-
|
|
450
|
+
}
|
|
451
|
+
return await this.foregroundSlot.run(kind, label, async (signal) => {
|
|
497
452
|
try {
|
|
498
|
-
await operation();
|
|
453
|
+
await operation(signal);
|
|
499
454
|
return true;
|
|
500
455
|
}
|
|
501
456
|
catch (error) {
|
|
@@ -506,13 +461,13 @@ export class Controller {
|
|
|
506
461
|
}
|
|
507
462
|
/** Same slot, for an operation that produces a value the caller needs. */
|
|
508
463
|
async runActionValue(kind, label, operation) {
|
|
509
|
-
if (!this.state.online)
|
|
510
|
-
|
|
511
|
-
if (!this.ownsForeground() && this.foreground !== undefined)
|
|
464
|
+
if (!this.state.online) {
|
|
465
|
+
this.update({ lastFailure: 'Not connected. Try again after reconnecting.' });
|
|
512
466
|
return undefined;
|
|
513
|
-
|
|
467
|
+
}
|
|
468
|
+
return await this.foregroundSlot.run(kind, label, async (signal) => {
|
|
514
469
|
try {
|
|
515
|
-
return await operation();
|
|
470
|
+
return await operation(signal);
|
|
516
471
|
}
|
|
517
472
|
catch (error) {
|
|
518
473
|
this.update({ lastFailure: errorText(error) });
|
|
@@ -520,83 +475,11 @@ export class Controller {
|
|
|
520
475
|
}
|
|
521
476
|
});
|
|
522
477
|
}
|
|
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.
|
|
478
|
+
/** Run outside the foreground slot, with the same application failure reporting.
|
|
479
|
+
* Local prompt edits need no connection; verifier cancellation uses the session control lane and
|
|
480
|
+
* must reach the host even while the foreground is busy or closed for shutdown.
|
|
530
481
|
*/
|
|
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) {
|
|
482
|
+
async runImmediateAction(operation) {
|
|
600
483
|
try {
|
|
601
484
|
await operation();
|
|
602
485
|
return true;
|
|
@@ -672,35 +555,28 @@ export class Controller {
|
|
|
672
555
|
}
|
|
673
556
|
/** The event stream is ready and the control baseline is applied. */
|
|
674
557
|
async ready() {
|
|
675
|
-
this.catalog.refresh();
|
|
676
558
|
this.update({ online: true, status: 'Connected', pending: [], lastFailure: '' });
|
|
559
|
+
this.catalog.refresh();
|
|
677
560
|
const screen = this.state.screen;
|
|
678
561
|
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) {
|
|
562
|
+
this.traceEvent('navigation-refresh', { screen });
|
|
563
|
+
await this.session.refreshLists();
|
|
564
|
+
// Startup may choose the local workspace. Reconnect only refreshes data: it must not reinterpret
|
|
565
|
+
// the reader's current screen as a request to navigate or discard a selected conversation.
|
|
566
|
+
if (!this.initialized && this.state.screen === 'workspaces' && !this.state.sessionId && !this.initialSession) {
|
|
690
567
|
const adopted = this.session.adoptLocalWorkspace(this.localDirectory);
|
|
691
568
|
this.traceEvent('adopt', { directory: this.localDirectory, workspace: adopted ?? 'none' });
|
|
692
569
|
if (adopted !== undefined)
|
|
693
570
|
this.update({ status: 'Workspace from this directory · ← to switch' });
|
|
694
571
|
}
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
if (sessionId && reselect) {
|
|
572
|
+
const selected = this.state.sessionId;
|
|
573
|
+
const sessionId = selected ?? this.loops.sessionId ?? (!this.initialized ? this.initialSession : undefined);
|
|
574
|
+
this.traceEvent('resolve', { session: sessionId ?? 'none', looping: this.loops.sessionId ?? 'none', reselect: sessionId !== undefined });
|
|
575
|
+
if (selected !== undefined)
|
|
576
|
+
this.session.restoreSelectedSession();
|
|
577
|
+
else if (sessionId !== undefined)
|
|
702
578
|
await this.session.selectSession(sessionId);
|
|
703
|
-
|
|
579
|
+
this.initialized = true;
|
|
704
580
|
// `online` means the socket works; `connectionSettled` means the picker and the selection are
|
|
705
581
|
// done, which is what an automation caller must wait for or it races `showPicker`.
|
|
706
582
|
this.connectionSettled = true;
|
|
@@ -744,7 +620,7 @@ export class Controller {
|
|
|
744
620
|
if (started && event.sessionId === this.state.sessionId)
|
|
745
621
|
this.completedTurns += 1;
|
|
746
622
|
this.cost?.onTurnIdle();
|
|
747
|
-
this.
|
|
623
|
+
this.loops.idle(event.sessionId);
|
|
748
624
|
}
|
|
749
625
|
return true;
|
|
750
626
|
case 'catalog-invalidated':
|
|
@@ -763,7 +639,7 @@ export class Controller {
|
|
|
763
639
|
/** @returns Current session title, falling back to the list title and then the ID. */
|
|
764
640
|
get sessionName() { return this.session.sessionName; }
|
|
765
641
|
/** @returns Current agent-preset label. */
|
|
766
|
-
get sessionMode() { return this.session.sessionMode; }
|
|
642
|
+
get sessionMode() { return this.session.sessionMode(this.state.presets); }
|
|
767
643
|
/** @returns Epoch start of the active turn, when known. */
|
|
768
644
|
get workingSince() { return this.session.workingSince; }
|
|
769
645
|
/** What the client is doing right now, merged across the host turn and any running loop.
|
|
@@ -776,7 +652,7 @@ export class Controller {
|
|
|
776
652
|
if (this.running) {
|
|
777
653
|
return { kind: 'turn', ...(this.workingSince === undefined ? {} : { since: this.workingSince }) };
|
|
778
654
|
}
|
|
779
|
-
const progress = this.
|
|
655
|
+
const progress = this.loops.progress;
|
|
780
656
|
if (progress === undefined || !progress.active)
|
|
781
657
|
return undefined;
|
|
782
658
|
// A paused run is still the client's work, but nothing is running: the bar must ask for the reader
|
|
@@ -849,7 +725,7 @@ export class Controller {
|
|
|
849
725
|
id: sessionId, kind: 'session', label: verifierLabel(title), state: 'running', startedAt: Date.now(),
|
|
850
726
|
createdBy: 'verifier',
|
|
851
727
|
...(parent === undefined ? {} : { parentSessionId: parent }),
|
|
852
|
-
...(this.
|
|
728
|
+
...(this.loops.verifierName === undefined ? {} : { detail: `verifier ${this.loops.verifierName}` }),
|
|
853
729
|
});
|
|
854
730
|
// The bar the run echoed into the transcript now opens this session, which is the newest check.
|
|
855
731
|
this.shell.link(sessionId);
|
|
@@ -923,23 +799,13 @@ export class Controller {
|
|
|
923
799
|
pendingCounts() { return this.session.pendingCounts(); }
|
|
924
800
|
/** Load the optional preset roster once per connection. */
|
|
925
801
|
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
802
|
/** Stop the selected turn, or allow exit only while idle.
|
|
937
803
|
* @param force - Send an explicit cancellation even when the cached running flag is idle.
|
|
938
804
|
* @returns True when the caller may exit.
|
|
939
805
|
*/
|
|
940
806
|
interrupt(force = false) {
|
|
941
807
|
// Esc and Ctrl+C stop the automated loop as well as the turn; otherwise it would keep sending.
|
|
942
|
-
this.
|
|
808
|
+
this.loops.stop();
|
|
943
809
|
return this.session.interrupt(force);
|
|
944
810
|
}
|
|
945
811
|
/** One-line estimate of the selected session's cost, or `?` while the ledger has no entry for it. */
|
|
@@ -952,7 +818,6 @@ export class Controller {
|
|
|
952
818
|
/** Show a detached history window, releasing the one it replaces.
|
|
953
819
|
* @param window - Record to display, or undefined to return to the live transcript.
|
|
954
820
|
*/
|
|
955
|
-
setViewWindow(window) { this.session.setViewWindow(window); }
|
|
956
821
|
/** Recall one step through the selected session's prompt index; never touches the network.
|
|
957
822
|
* @param direction - Negative for older input, positive for newer input.
|
|
958
823
|
* @param current - Composer content before recall began, restored at the newest position.
|
|
@@ -1001,9 +866,9 @@ export class Controller {
|
|
|
1001
866
|
/** Refresh both lists from the host, then show the requested picker.
|
|
1002
867
|
* @param screen - Picker to display after the refresh.
|
|
1003
868
|
*/
|
|
1004
|
-
async showPicker(screen) {
|
|
869
|
+
async showPicker(screen, signal) {
|
|
1005
870
|
this.traceEvent('action', { action: 'showPicker', screen });
|
|
1006
|
-
await this.session.showPicker(screen);
|
|
871
|
+
await this.session.showPicker(screen, signal);
|
|
1007
872
|
}
|
|
1008
873
|
/** Resolve a removal command to one reviewable object.
|
|
1009
874
|
* @param kind - Workspace registration removal or session archival.
|
|
@@ -1036,16 +901,16 @@ export class Controller {
|
|
|
1036
901
|
/** Open a workspace picker, or resolve a workspace target.
|
|
1037
902
|
* @param query - Workspace target, if any.
|
|
1038
903
|
*/
|
|
1039
|
-
async switchWorkspace(query) {
|
|
904
|
+
async switchWorkspace(query, signal) {
|
|
1040
905
|
this.traceEvent('action', { action: 'switchWorkspace', query: query ?? 'picker' });
|
|
1041
|
-
await this.session.switchWorkspace(query);
|
|
906
|
+
await this.session.switchWorkspace(query, signal);
|
|
1042
907
|
}
|
|
1043
908
|
/** Guide session selection, list all sessions with `all`, or resolve a target.
|
|
1044
909
|
* @param query - Session target, `all`, or nothing for the guided picker.
|
|
1045
910
|
*/
|
|
1046
|
-
async switchSession(query) {
|
|
911
|
+
async switchSession(query, signal) {
|
|
1047
912
|
this.traceEvent('action', { action: 'switchSession', query: query ?? 'picker' });
|
|
1048
|
-
await this.session.switchSession(query);
|
|
913
|
+
await this.session.switchSession(query, signal);
|
|
1049
914
|
}
|
|
1050
915
|
/** Prompt for a host path without starting a local agent. */
|
|
1051
916
|
enterPath() {
|
|
@@ -1055,14 +920,14 @@ export class Controller {
|
|
|
1055
920
|
/** Register a host directory and move to its session picker.
|
|
1056
921
|
* @param path - Absolute directory path on the host.
|
|
1057
922
|
*/
|
|
1058
|
-
async createWorkspace(path) {
|
|
923
|
+
async createWorkspace(path, signal) {
|
|
1059
924
|
this.traceEvent('action', { action: 'createWorkspace', path });
|
|
1060
|
-
await this.session.createWorkspace(path);
|
|
925
|
+
await this.session.createWorkspace(path, signal);
|
|
1061
926
|
}
|
|
1062
927
|
/** Create a session in the selected workspace. */
|
|
1063
|
-
async createSession() {
|
|
928
|
+
async createSession(signal) {
|
|
1064
929
|
this.traceEvent('action', { action: 'createSession', workspace: this.state.workspaceId ?? 'none' });
|
|
1065
|
-
await this.session.createSession();
|
|
930
|
+
await this.session.createSession(signal);
|
|
1066
931
|
}
|
|
1067
932
|
/** Replace the selected transcript and follow the session.
|
|
1068
933
|
* @param sessionId - Session to follow.
|
|
@@ -1124,7 +989,7 @@ export class Controller {
|
|
|
1124
989
|
* @param text - Composed prompt text.
|
|
1125
990
|
*/
|
|
1126
991
|
async prompt(text) {
|
|
1127
|
-
this.
|
|
992
|
+
this.loops.stop();
|
|
1128
993
|
await this.session.prompt(text);
|
|
1129
994
|
}
|
|
1130
995
|
/** Clear this client's stale handoff file, then ask the agent to write a new one.
|
|
@@ -1137,566 +1002,9 @@ export class Controller {
|
|
|
1137
1002
|
await removeFile(join(this.localDirectory, HANDOFF_FILE));
|
|
1138
1003
|
await this.session.promptInternal(HANDOFF_PROMPT);
|
|
1139
1004
|
}
|
|
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
1005
|
/** Cancel the active turn; pending queue items remain host-owned. */
|
|
1698
1006
|
async cancelTurn() {
|
|
1699
|
-
this.
|
|
1007
|
+
this.loops.stop();
|
|
1700
1008
|
await this.session.cancelTurn();
|
|
1701
1009
|
}
|
|
1702
1010
|
/** Add a page before the retained window.
|
|
@@ -1710,12 +1018,6 @@ export class Controller {
|
|
|
1710
1018
|
* @returns Newest-first bounded summaries and an explicit truncation flag.
|
|
1711
1019
|
*/
|
|
1712
1020
|
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
1021
|
/** Plain rows and offsets for one laid-out record; the projection stays in the session domain. */
|
|
1720
1022
|
render(input) {
|
|
1721
1023
|
return this.session.render(input);
|