zcode-acp-server 0.31.1 → 0.32.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/dist/code-fingerprint.json +2 -2
- package/dist/goal-loop/driver.d.ts +112 -0
- package/dist/goal-loop/driver.d.ts.map +1 -0
- package/dist/goal-loop/driver.js +556 -0
- package/dist/goal-loop/driver.js.map +1 -0
- package/dist/goal-loop/state.d.ts +53 -0
- package/dist/goal-loop/state.d.ts.map +1 -0
- package/dist/goal-loop/state.js +80 -0
- package/dist/goal-loop/state.js.map +1 -0
- package/dist/goal-loop/templates.d.ts +79 -0
- package/dist/goal-loop/templates.d.ts.map +1 -0
- package/dist/goal-loop/templates.js +187 -0
- package/dist/goal-loop/templates.js.map +1 -0
- package/dist/handlers/sandbox-allow.d.ts +3 -0
- package/dist/handlers/sandbox-allow.d.ts.map +1 -1
- package/dist/handlers/sandbox-allow.js +21 -6
- package/dist/handlers/sandbox-allow.js.map +1 -1
- package/dist/handlers/session.d.ts +49 -0
- package/dist/handlers/session.d.ts.map +1 -1
- package/dist/handlers/session.js +225 -135
- package/dist/handlers/session.js.map +1 -1
- package/dist/handlers/slash.d.ts.map +1 -1
- package/dist/handlers/slash.js +58 -0
- package/dist/handlers/slash.js.map +1 -1
- package/dist/i18n.d.ts +18 -0
- package/dist/i18n.d.ts.map +1 -1
- package/dist/i18n.js +34 -0
- package/dist/i18n.js.map +1 -1
- package/dist/server.d.ts +25 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +12 -0
- package/dist/server.js.map +1 -1
- package/dist/utils.d.ts +6 -0
- package/dist/utils.d.ts.map +1 -1
- package/dist/utils.js +5 -0
- package/dist/utils.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-loop driver (ADR-0022): the bridge-driven autonomous turn chain.
|
|
3
|
+
*
|
|
4
|
+
* Round shape: one ticket per `session/send` turn via the shared
|
|
5
|
+
* {@link runOneTurn} machinery, a worker self-report VERDICT, and — only on a
|
|
6
|
+
* claimed `met` — one verification turn that re-runs the acceptance criteria
|
|
7
|
+
* (never trust the worker's report). Compaction runs at round boundaries
|
|
8
|
+
* (handoff turn → session/compact → the next dispatch reads the handoff doc).
|
|
9
|
+
*
|
|
10
|
+
* User interaction: rounds register in `pendingTurns` with `goalLoop: true`
|
|
11
|
+
* INSIDE withPreemptLock — `preemptInFlightTurn` skips them and the prompt
|
|
12
|
+
* path parks the incoming prompt on the driver instead (merge at the next
|
|
13
|
+
* boundary). ESC/cancel still cancels the in-flight round (pause follows).
|
|
14
|
+
* While prompts are parked, a 60s keepalive keeps clients from hitting their
|
|
15
|
+
* idle deadlines during the quiet judge/compaction windows.
|
|
16
|
+
*/
|
|
17
|
+
import type * as acp from "@agentclientprotocol/sdk";
|
|
18
|
+
import type { ZcodeAcpServer } from "../server.js";
|
|
19
|
+
/** ENV: ZCODE_ACP_GOAL_MAX_TURNS — hard round budget before a pause. */
|
|
20
|
+
export declare function goalMaxTurns(): number;
|
|
21
|
+
/** Goal-loop compaction threshold: the shared env var, else 80% of window. */
|
|
22
|
+
export declare function goalCompactThreshold(contextWindow: number): number;
|
|
23
|
+
export declare class GoalLoopDriver {
|
|
24
|
+
readonly zcodeSid: string;
|
|
25
|
+
private readonly server;
|
|
26
|
+
private readonly acpSid;
|
|
27
|
+
private state;
|
|
28
|
+
private pauseFlag;
|
|
29
|
+
private stopFlag;
|
|
30
|
+
private parked;
|
|
31
|
+
private parkSeq;
|
|
32
|
+
private keepalive;
|
|
33
|
+
private runId;
|
|
34
|
+
/** Set when the last goal turn was cancelled by the sandbox allow-restart. */
|
|
35
|
+
private lastSandboxRestart;
|
|
36
|
+
private constructor();
|
|
37
|
+
/** Live driver for a session, when a loop is running. */
|
|
38
|
+
static live(server: ZcodeAcpServer, zcodeSid: string): GoalLoopDriver | undefined;
|
|
39
|
+
/**
|
|
40
|
+
* Start (or resume) the loop for a session. Resuming adopts the persisted
|
|
41
|
+
* state (round count, tickets, parked text); a fresh start decomposes the
|
|
42
|
+
* objective into tickets first.
|
|
43
|
+
*/
|
|
44
|
+
static start(server: ZcodeAcpServer, acpSid: string, zcodeSid: string, objective: string, opts?: {
|
|
45
|
+
resume?: boolean;
|
|
46
|
+
}): GoalLoopDriver;
|
|
47
|
+
/** Pause at the next round boundary (the in-flight round keeps running). */
|
|
48
|
+
pause(): void;
|
|
49
|
+
/**
|
|
50
|
+
* External pause request — session/cancel during a quiet window with no
|
|
51
|
+
* registered turn (e.g. compact()'s internal wait): same flag as pause(),
|
|
52
|
+
* consumed at the next boundary.
|
|
53
|
+
*/
|
|
54
|
+
requestPause(): void;
|
|
55
|
+
/** Clear a pending pause/stop (/auto resume before the boundary takes it). */
|
|
56
|
+
resume(): void;
|
|
57
|
+
/** Stop at the next boundary and clear persisted state. */
|
|
58
|
+
stop(): void;
|
|
59
|
+
/** One-line status for /auto status. */
|
|
60
|
+
statusText(): string;
|
|
61
|
+
/**
|
|
62
|
+
* Park an incoming user prompt (called by the prompt path when a goalLoop
|
|
63
|
+
* turn is in flight). Resolves when the merged round completes — or
|
|
64
|
+
* `cancelled` if the loop pauses/stops first (text preserved in state).
|
|
65
|
+
*/
|
|
66
|
+
parkPrompt(text: string): Promise<acp.PromptResponse>;
|
|
67
|
+
private armKeepalive;
|
|
68
|
+
private disarmKeepalive;
|
|
69
|
+
/**
|
|
70
|
+
* Resolve parked prompts. Without a `snapshot`, everything currently parked
|
|
71
|
+
* settles (pause/stop/end-of-loop paths). With one, ONLY the snapshot's
|
|
72
|
+
* entries resolve — prompts parked DURING a round (the common steering
|
|
73
|
+
* window) stay parked and their text merges into the NEXT round instead of
|
|
74
|
+
* being silently resolved-and-dropped (ADR-0022 §3).
|
|
75
|
+
*/
|
|
76
|
+
private settleParked;
|
|
77
|
+
/** One goal-loop turn through the shared runOneTurn (goalLoop-marked). */
|
|
78
|
+
private runGoalTurn;
|
|
79
|
+
/** Consume the sandbox-restart mark set by the last runGoalTurn. */
|
|
80
|
+
private consumeSandboxRestart;
|
|
81
|
+
/**
|
|
82
|
+
* Wait out in-flight NON-goal turns for this session before the first
|
|
83
|
+
* round: a send landing mid-generation is accepted as steer input and its
|
|
84
|
+
* text silently dropped when the old turn finishes (see AGENTS.md) — the
|
|
85
|
+
* decompose prompt would be injected into the foreign conversation and the
|
|
86
|
+
* tickets parsed from its reply. Real agent turns run minutes, so on timeout
|
|
87
|
+
* the wait PRE-EMPTS the editor turn with the same semantics a normal
|
|
88
|
+
* prompt uses (cancel flag + stop pair via preemptInFlightTurn) — proceeding
|
|
89
|
+
* would land the decompose send as steer input into the live conversation.
|
|
90
|
+
* runOneTurn's drain gate, armed by the pre-empt's lastCancelledAt mark,
|
|
91
|
+
* settles the backend before the decompose send.
|
|
92
|
+
*/
|
|
93
|
+
private waitForEditorTurnsIdle;
|
|
94
|
+
/** Text of the last assistant reply at or after `since` (verdict parsing input). */
|
|
95
|
+
private lastAssistantText;
|
|
96
|
+
/** Tool-part count in the messages appended since `before` (stall signal). */
|
|
97
|
+
private toolActivitySince;
|
|
98
|
+
private messageCount;
|
|
99
|
+
private contextUsed;
|
|
100
|
+
private announce;
|
|
101
|
+
private persist;
|
|
102
|
+
private endLoop;
|
|
103
|
+
private run;
|
|
104
|
+
private runRounds;
|
|
105
|
+
private contextWindowFromRead;
|
|
106
|
+
/**
|
|
107
|
+
* Handoff turn → session/compact → mark handoffFresh for the next round.
|
|
108
|
+
* Returns true when the handoff turn was cancelled (ESC / restart).
|
|
109
|
+
*/
|
|
110
|
+
private compactBoundary;
|
|
111
|
+
}
|
|
112
|
+
//# sourceMappingURL=driver.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"driver.d.ts","sourceRoot":"","sources":["../../src/goal-loop/driver.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;GAeG;AAEH,OAAO,KAAK,KAAK,GAAG,MAAM,0BAA0B,CAAC;AAOrD,OAAO,KAAK,EAAe,cAAc,EAAE,MAAM,cAAc,CAAC;AAsBhE,wEAAwE;AACxE,wBAAgB,YAAY,IAAI,MAAM,CAGrC;AAED,8EAA8E;AAC9E,wBAAgB,oBAAoB,CAAC,aAAa,EAAE,MAAM,GAAG,MAAM,CAIlE;AAiBD,qBAAa,cAAc;IACzB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAiB;IACxC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,KAAK,CAAgB;IAC7B,OAAO,CAAC,SAAS,CAAS;IAC1B,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,MAAM,CAAsB;IACpC,OAAO,CAAC,OAAO,CAAK;IACpB,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,KAAK,CAAK;IAClB,8EAA8E;IAC9E,OAAO,CAAC,kBAAkB,CAAS;IAEnC,OAAO;IAYP,yDAAyD;IACzD,MAAM,CAAC,IAAI,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,MAAM,GAAG,cAAc,GAAG,SAAS;IAKjF;;;;OAIG;IACH,MAAM,CAAC,KAAK,CACV,MAAM,EAAE,cAAc,EACtB,MAAM,EAAE,MAAM,EACd,QAAQ,EAAE,MAAM,EAChB,SAAS,EAAE,MAAM,EACjB,IAAI,GAAE;QAAE,MAAM,CAAC,EAAE,OAAO,CAAA;KAAO,GAC9B,cAAc;IA+BjB,4EAA4E;IAC5E,KAAK,IAAI,IAAI;IAIb;;;;OAIG;IACH,YAAY,IAAI,IAAI;IAIpB,8EAA8E;IAC9E,MAAM,IAAI,IAAI;IAKd,2DAA2D;IAC3D,IAAI,IAAI,IAAI;IAIZ,wCAAwC;IACxC,UAAU,IAAI,MAAM;IAcpB;;;;OAIG;IACH,UAAU,CAAC,IAAI,EAAE,MAAM,GAAG,OAAO,CAAC,GAAG,CAAC,cAAc,CAAC;IAOrD,OAAO,CAAC,YAAY;IAgBpB,OAAO,CAAC,eAAe;IAKvB;;;;;;OAMG;IACH,OAAO,CAAC,YAAY;IAqBpB,0EAA0E;YAC5D,WAAW;IAmCzB,oEAAoE;IACpE,OAAO,CAAC,qBAAqB;IAM7B;;;;;;;;;;;OAWG;YACW,sBAAsB;IAcpC,oFAAoF;YACtE,iBAAiB;IAe/B,8EAA8E;YAChE,iBAAiB;YAUjB,YAAY;YAKZ,WAAW;YAWX,QAAQ;IAStB,OAAO,CAAC,OAAO;YAID,OAAO;YAgBP,GAAG;YAoBH,SAAS;YAoNT,qBAAqB;IAWnC;;;OAGG;YACW,eAAe;CA0B9B"}
|
|
@@ -0,0 +1,556 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Goal-loop driver (ADR-0022): the bridge-driven autonomous turn chain.
|
|
3
|
+
*
|
|
4
|
+
* Round shape: one ticket per `session/send` turn via the shared
|
|
5
|
+
* {@link runOneTurn} machinery, a worker self-report VERDICT, and — only on a
|
|
6
|
+
* claimed `met` — one verification turn that re-runs the acceptance criteria
|
|
7
|
+
* (never trust the worker's report). Compaction runs at round boundaries
|
|
8
|
+
* (handoff turn → session/compact → the next dispatch reads the handoff doc).
|
|
9
|
+
*
|
|
10
|
+
* User interaction: rounds register in `pendingTurns` with `goalLoop: true`
|
|
11
|
+
* INSIDE withPreemptLock — `preemptInFlightTurn` skips them and the prompt
|
|
12
|
+
* path parks the incoming prompt on the driver instead (merge at the next
|
|
13
|
+
* boundary). ESC/cancel still cancels the in-flight round (pause follows).
|
|
14
|
+
* While prompts are parked, a 60s keepalive keeps clients from hitting their
|
|
15
|
+
* idle deadlines during the quiet judge/compaction windows.
|
|
16
|
+
*/
|
|
17
|
+
import { readFileSync, unlinkSync } from "node:fs";
|
|
18
|
+
import { compact } from "../handlers/extensions.js";
|
|
19
|
+
import { preemptInFlightTurn, runOneTurn, withPreemptLock } from "../handlers/session.js";
|
|
20
|
+
import { sendTextChunk } from "../handlers/io.js";
|
|
21
|
+
import { messages } from "../i18n.js";
|
|
22
|
+
import { log, warn } from "../utils.js";
|
|
23
|
+
import { clearGoalState, handoffPath, readGoalState, verifyPath, writeGoalState, } from "./state.js";
|
|
24
|
+
import { decomposePrompt, dispatchPrompt, handoffPrompt, parseTickets, parseVerifyFile, parseVerifyReply, parseVerdict, strictVerifyPrompt, verifyPrompt, } from "./templates.js";
|
|
25
|
+
/** ENV: ZCODE_ACP_GOAL_MAX_TURNS — hard round budget before a pause. */
|
|
26
|
+
export function goalMaxTurns() {
|
|
27
|
+
const raw = Number(process.env.ZCODE_ACP_GOAL_MAX_TURNS ?? "0");
|
|
28
|
+
return Number.isFinite(raw) && raw > 0 ? Math.floor(raw) : 100;
|
|
29
|
+
}
|
|
30
|
+
/** Goal-loop compaction threshold: the shared env var, else 80% of window. */
|
|
31
|
+
export function goalCompactThreshold(contextWindow) {
|
|
32
|
+
const raw = Number(process.env.ZCODE_ACP_AUTO_COMPACT_THRESHOLD ?? "0");
|
|
33
|
+
if (Number.isFinite(raw) && raw > 0)
|
|
34
|
+
return raw;
|
|
35
|
+
return Math.floor(contextWindow * 0.8);
|
|
36
|
+
}
|
|
37
|
+
/** Consecutive dispatch rounds with no tool activity before a stall pause. */
|
|
38
|
+
const STALL_ROUND_LIMIT = 3;
|
|
39
|
+
/** Keepalive cadence while a user prompt is parked (client deadlines ~300s). */
|
|
40
|
+
const KEEPALIVE_INTERVAL_MS = 60_000;
|
|
41
|
+
/** How long a start waits for in-flight editor turns before pre-empting them. */
|
|
42
|
+
const EDITOR_TURN_WAIT_MS = 10_000;
|
|
43
|
+
const sleep = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
44
|
+
export class GoalLoopDriver {
|
|
45
|
+
zcodeSid;
|
|
46
|
+
server;
|
|
47
|
+
acpSid;
|
|
48
|
+
state;
|
|
49
|
+
pauseFlag = false;
|
|
50
|
+
stopFlag = false;
|
|
51
|
+
parked = [];
|
|
52
|
+
parkSeq = 0;
|
|
53
|
+
keepalive = null;
|
|
54
|
+
runId = 0;
|
|
55
|
+
/** Set when the last goal turn was cancelled by the sandbox allow-restart. */
|
|
56
|
+
lastSandboxRestart = false;
|
|
57
|
+
constructor(server, acpSid, zcodeSid, state) {
|
|
58
|
+
this.server = server;
|
|
59
|
+
this.acpSid = acpSid;
|
|
60
|
+
this.zcodeSid = zcodeSid;
|
|
61
|
+
this.state = state;
|
|
62
|
+
}
|
|
63
|
+
/** Live driver for a session, when a loop is running. */
|
|
64
|
+
static live(server, zcodeSid) {
|
|
65
|
+
// Optional chaining: unit-test servers predate the registry field.
|
|
66
|
+
return server.goalLoops?.get(zcodeSid);
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Start (or resume) the loop for a session. Resuming adopts the persisted
|
|
70
|
+
* state (round count, tickets, parked text); a fresh start decomposes the
|
|
71
|
+
* objective into tickets first.
|
|
72
|
+
*/
|
|
73
|
+
static start(server, acpSid, zcodeSid, objective, opts = {}) {
|
|
74
|
+
const existing = server.goalLoops.get(zcodeSid);
|
|
75
|
+
if (existing && existing.state.status === "running")
|
|
76
|
+
return existing;
|
|
77
|
+
const prior = opts.resume ? readGoalState(server.projectCwd(), zcodeSid) : null;
|
|
78
|
+
const state = prior ?? {
|
|
79
|
+
objective,
|
|
80
|
+
status: "running",
|
|
81
|
+
rounds: 0,
|
|
82
|
+
tickets: [],
|
|
83
|
+
handoffFresh: false,
|
|
84
|
+
createdAt: Date.now(),
|
|
85
|
+
updatedAt: Date.now(),
|
|
86
|
+
};
|
|
87
|
+
state.status = "running";
|
|
88
|
+
if (!opts.resume)
|
|
89
|
+
state.objective = objective;
|
|
90
|
+
const driver = new GoalLoopDriver(server, acpSid, zcodeSid, state);
|
|
91
|
+
server.goalLoops.set(zcodeSid, driver);
|
|
92
|
+
const myRun = ++driver.runId;
|
|
93
|
+
void driver
|
|
94
|
+
.run(myRun)
|
|
95
|
+
.catch((e) => warn(`goal-loop: driver crashed (${e instanceof Error ? e.message : String(e)})`))
|
|
96
|
+
.finally(() => {
|
|
97
|
+
// Identity check: a NEWER driver for this sid (pause → fresh start in
|
|
98
|
+
// the endLoop announce window) must not be unregistered by this one.
|
|
99
|
+
if (server.goalLoops.get(zcodeSid) === driver)
|
|
100
|
+
server.goalLoops.delete(zcodeSid);
|
|
101
|
+
});
|
|
102
|
+
return driver;
|
|
103
|
+
}
|
|
104
|
+
/** Pause at the next round boundary (the in-flight round keeps running). */
|
|
105
|
+
pause() {
|
|
106
|
+
this.pauseFlag = true;
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* External pause request — session/cancel during a quiet window with no
|
|
110
|
+
* registered turn (e.g. compact()'s internal wait): same flag as pause(),
|
|
111
|
+
* consumed at the next boundary.
|
|
112
|
+
*/
|
|
113
|
+
requestPause() {
|
|
114
|
+
this.pauseFlag = true;
|
|
115
|
+
}
|
|
116
|
+
/** Clear a pending pause/stop (/auto resume before the boundary takes it). */
|
|
117
|
+
resume() {
|
|
118
|
+
this.pauseFlag = false;
|
|
119
|
+
this.stopFlag = false;
|
|
120
|
+
}
|
|
121
|
+
/** Stop at the next boundary and clear persisted state. */
|
|
122
|
+
stop() {
|
|
123
|
+
this.stopFlag = true;
|
|
124
|
+
}
|
|
125
|
+
/** One-line status for /auto status. */
|
|
126
|
+
statusText() {
|
|
127
|
+
const s = this.state;
|
|
128
|
+
const current = s.tickets.find((t) => t.status === "in_progress" || t.status === "pending");
|
|
129
|
+
const done = s.tickets.filter((t) => t.status === "done").length;
|
|
130
|
+
return messages().goalStatus(s.status, s.rounds, goalMaxTurns(), done, s.tickets.length, current?.title);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Park an incoming user prompt (called by the prompt path when a goalLoop
|
|
134
|
+
* turn is in flight). Resolves when the merged round completes — or
|
|
135
|
+
* `cancelled` if the loop pauses/stops first (text preserved in state).
|
|
136
|
+
*/
|
|
137
|
+
parkPrompt(text) {
|
|
138
|
+
return new Promise((resolve) => {
|
|
139
|
+
this.parked.push({ id: ++this.parkSeq, text, resolve });
|
|
140
|
+
this.armKeepalive();
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
armKeepalive() {
|
|
144
|
+
if (this.keepalive)
|
|
145
|
+
return;
|
|
146
|
+
this.keepalive = setInterval(() => {
|
|
147
|
+
if (this.parked.length === 0) {
|
|
148
|
+
this.disarmKeepalive();
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
for (const sid of this.server.sessionAliases(this.acpSid)) {
|
|
152
|
+
void this.server.clients
|
|
153
|
+
.broadcast()
|
|
154
|
+
.notify("$/zcode/turnState", { sessionId: sid, running: true })
|
|
155
|
+
.catch(() => undefined);
|
|
156
|
+
}
|
|
157
|
+
}, KEEPALIVE_INTERVAL_MS);
|
|
158
|
+
}
|
|
159
|
+
disarmKeepalive() {
|
|
160
|
+
if (this.keepalive)
|
|
161
|
+
clearInterval(this.keepalive);
|
|
162
|
+
this.keepalive = null;
|
|
163
|
+
}
|
|
164
|
+
/**
|
|
165
|
+
* Resolve parked prompts. Without a `snapshot`, everything currently parked
|
|
166
|
+
* settles (pause/stop/end-of-loop paths). With one, ONLY the snapshot's
|
|
167
|
+
* entries resolve — prompts parked DURING a round (the common steering
|
|
168
|
+
* window) stay parked and their text merges into the NEXT round instead of
|
|
169
|
+
* being silently resolved-and-dropped (ADR-0022 §3).
|
|
170
|
+
*/
|
|
171
|
+
settleParked(r, keepText, snapshot) {
|
|
172
|
+
const resolving = snapshot ?? [...this.parked];
|
|
173
|
+
if (resolving.length === 0) {
|
|
174
|
+
if (this.parked.length === 0)
|
|
175
|
+
this.disarmKeepalive();
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
const ids = new Set(resolving.map((p) => p.id));
|
|
179
|
+
this.parked = this.parked.filter((p) => !ids.has(p.id));
|
|
180
|
+
for (const p of resolving)
|
|
181
|
+
p.resolve(r);
|
|
182
|
+
if (keepText) {
|
|
183
|
+
// Merge (not overwrite): text preserved across a resume may already be
|
|
184
|
+
// on record when the loop pauses before this round's snapshot.
|
|
185
|
+
this.state.parkedText =
|
|
186
|
+
[this.state.parkedText, ...resolving.map((p) => p.text)].filter(Boolean).join("\n\n") ||
|
|
187
|
+
undefined;
|
|
188
|
+
}
|
|
189
|
+
if (this.parked.length === 0)
|
|
190
|
+
this.disarmKeepalive();
|
|
191
|
+
}
|
|
192
|
+
// ---------- round machinery ----------
|
|
193
|
+
/** One goal-loop turn through the shared runOneTurn (goalLoop-marked). */
|
|
194
|
+
async runGoalTurn(prompt) {
|
|
195
|
+
const server = this.server;
|
|
196
|
+
const backend = server.ensureBackend();
|
|
197
|
+
const turn = { zcodeSid: this.zcodeSid, cancelled: false, goalLoop: true };
|
|
198
|
+
const requestId = `goal-${this.zcodeSid}-${this.state.rounds}-${Date.now()}`;
|
|
199
|
+
await withPreemptLock(server, this.zcodeSid, async () => {
|
|
200
|
+
server.pendingTurns.set(requestId, turn);
|
|
201
|
+
});
|
|
202
|
+
// Mirror runPrompt's round-start indicator: remote clients would flip
|
|
203
|
+
// idle between rounds (runOneTurn's finally emits running:false per round).
|
|
204
|
+
for (const sid of server.sessionAliases(this.acpSid)) {
|
|
205
|
+
void server.clients
|
|
206
|
+
.broadcast()
|
|
207
|
+
.notify("$/zcode/turnState", { sessionId: sid, running: true })
|
|
208
|
+
.catch(() => undefined);
|
|
209
|
+
}
|
|
210
|
+
try {
|
|
211
|
+
return await runOneTurn(server, {
|
|
212
|
+
backend,
|
|
213
|
+
cx: server.clients.broadcast(),
|
|
214
|
+
acpSid: this.acpSid,
|
|
215
|
+
zcodeSid: this.zcodeSid,
|
|
216
|
+
requestId,
|
|
217
|
+
turn,
|
|
218
|
+
preempted: false,
|
|
219
|
+
sendText: prompt,
|
|
220
|
+
autoCompact: false,
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
finally {
|
|
224
|
+
// flushSandboxGrants marks goalLoop turns it cancels so the round
|
|
225
|
+
// boundary can tell a backend restart apart from a user ESC.
|
|
226
|
+
this.lastSandboxRestart = turn.sandboxRestart === true;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
/** Consume the sandbox-restart mark set by the last runGoalTurn. */
|
|
230
|
+
consumeSandboxRestart() {
|
|
231
|
+
const r = this.lastSandboxRestart;
|
|
232
|
+
this.lastSandboxRestart = false;
|
|
233
|
+
return r;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Wait out in-flight NON-goal turns for this session before the first
|
|
237
|
+
* round: a send landing mid-generation is accepted as steer input and its
|
|
238
|
+
* text silently dropped when the old turn finishes (see AGENTS.md) — the
|
|
239
|
+
* decompose prompt would be injected into the foreign conversation and the
|
|
240
|
+
* tickets parsed from its reply. Real agent turns run minutes, so on timeout
|
|
241
|
+
* the wait PRE-EMPTS the editor turn with the same semantics a normal
|
|
242
|
+
* prompt uses (cancel flag + stop pair via preemptInFlightTurn) — proceeding
|
|
243
|
+
* would land the decompose send as steer input into the live conversation.
|
|
244
|
+
* runOneTurn's drain gate, armed by the pre-empt's lastCancelledAt mark,
|
|
245
|
+
* settles the backend before the decompose send.
|
|
246
|
+
*/
|
|
247
|
+
async waitForEditorTurnsIdle(myRun) {
|
|
248
|
+
const deadline = Date.now() + EDITOR_TURN_WAIT_MS;
|
|
249
|
+
while (Date.now() < deadline) {
|
|
250
|
+
if (this.runId !== myRun)
|
|
251
|
+
return;
|
|
252
|
+
const busy = [...this.server.pendingTurns.values()].some((t) => t.zcodeSid === this.zcodeSid && !t.goalLoop);
|
|
253
|
+
if (!busy)
|
|
254
|
+
return;
|
|
255
|
+
await sleep(250);
|
|
256
|
+
}
|
|
257
|
+
warn("goal-loop: a normal turn is still in flight after 10s — pre-empting it");
|
|
258
|
+
preemptInFlightTurn(this.server, this.zcodeSid, "");
|
|
259
|
+
}
|
|
260
|
+
/** Text of the last assistant reply at or after `since` (verdict parsing input). */
|
|
261
|
+
async lastAssistantText(since = 0) {
|
|
262
|
+
const { fetchMessages } = await import("../handlers/replay.js");
|
|
263
|
+
const msgs = await fetchMessages(this.server, this.zcodeSid);
|
|
264
|
+
for (let i = msgs.length - 1; i >= Math.min(since, msgs.length); i--) {
|
|
265
|
+
const m = msgs[i];
|
|
266
|
+
if (m.info.role !== "assistant")
|
|
267
|
+
continue;
|
|
268
|
+
const text = m.parts
|
|
269
|
+
.filter((p) => p.type === "text")
|
|
270
|
+
.map((p) => p.text)
|
|
271
|
+
.join("");
|
|
272
|
+
if (text.trim())
|
|
273
|
+
return text;
|
|
274
|
+
}
|
|
275
|
+
return "";
|
|
276
|
+
}
|
|
277
|
+
/** Tool-part count in the messages appended since `before` (stall signal). */
|
|
278
|
+
async toolActivitySince(before) {
|
|
279
|
+
const { fetchMessages } = await import("../handlers/replay.js");
|
|
280
|
+
const msgs = await fetchMessages(this.server, this.zcodeSid);
|
|
281
|
+
let tools = 0;
|
|
282
|
+
for (let i = Math.min(before, msgs.length); i < msgs.length; i++) {
|
|
283
|
+
if (msgs[i].parts.some((p) => p.type === "tool"))
|
|
284
|
+
tools++;
|
|
285
|
+
}
|
|
286
|
+
return tools;
|
|
287
|
+
}
|
|
288
|
+
async messageCount() {
|
|
289
|
+
const { fetchMessages } = await import("../handlers/replay.js");
|
|
290
|
+
return (await fetchMessages(this.server, this.zcodeSid)).length;
|
|
291
|
+
}
|
|
292
|
+
async contextUsed() {
|
|
293
|
+
const resp = await this.server
|
|
294
|
+
.ensureBackend()
|
|
295
|
+
.request(this.server.nextId(), "session/read", { sessionId: this.zcodeSid }, 5000);
|
|
296
|
+
if (resp.error)
|
|
297
|
+
return 0;
|
|
298
|
+
return ((resp.result ?? {}).projection?.contextUsed ??
|
|
299
|
+
0);
|
|
300
|
+
}
|
|
301
|
+
async announce(text) {
|
|
302
|
+
await sendTextChunk(this.server.clients.broadcast(), this.acpSid, text, `goal-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`).catch(() => undefined);
|
|
303
|
+
}
|
|
304
|
+
persist() {
|
|
305
|
+
writeGoalState(this.server.projectCwd(), this.zcodeSid, this.state);
|
|
306
|
+
}
|
|
307
|
+
async endLoop(status, reason, note) {
|
|
308
|
+
this.state.status = status;
|
|
309
|
+
this.state.endedReason = reason;
|
|
310
|
+
// Settle parked prompts BEFORE persisting: keepText stores their text in
|
|
311
|
+
// the state that the persist below must include.
|
|
312
|
+
this.settleParked({ stopReason: "cancelled" }, status.startsWith("paused"));
|
|
313
|
+
this.persist();
|
|
314
|
+
if (status === "stopped")
|
|
315
|
+
clearGoalState(this.server.projectCwd(), this.zcodeSid);
|
|
316
|
+
await this.announce(note ?? messages().goalPaused(reason));
|
|
317
|
+
log(`goal-loop: ${this.zcodeSid.slice(0, 8)} → ${status} (${reason})`);
|
|
318
|
+
}
|
|
319
|
+
async run(myRun) {
|
|
320
|
+
// Any throw from the round machinery (runOneTurn permanent send errors,
|
|
321
|
+
// auth/quota failures, a corrupt state read) must still settle parked
|
|
322
|
+
// prompts, disarm the keepalive, and persist a paused status — the outer
|
|
323
|
+
// .catch in start() only logs (ADR-0022 §4: unrecoverable errors end the
|
|
324
|
+
// loop, they must not wedge the editor's prompt request forever).
|
|
325
|
+
try {
|
|
326
|
+
await this.runRounds(myRun);
|
|
327
|
+
}
|
|
328
|
+
catch (e) {
|
|
329
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
330
|
+
warn(`goal-loop: round machinery failed — pausing loop (${msg})`);
|
|
331
|
+
if (this.runId !== myRun)
|
|
332
|
+
return;
|
|
333
|
+
try {
|
|
334
|
+
await this.endLoop("paused-crash", msg);
|
|
335
|
+
}
|
|
336
|
+
catch (e2) {
|
|
337
|
+
warn(`goal-loop: crash recovery failed (${e2 instanceof Error ? e2.message : String(e2)})`);
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
async runRounds(myRun) {
|
|
342
|
+
const alive = () => this.runId === myRun && !this.server.backend?.isDead;
|
|
343
|
+
await this.waitForEditorTurnsIdle(myRun);
|
|
344
|
+
if (this.runId !== myRun)
|
|
345
|
+
return;
|
|
346
|
+
// Round 0 (uncounted): decompose the objective into tickets — unless
|
|
347
|
+
// resuming with a ticket list already on record.
|
|
348
|
+
if (this.state.tickets.length === 0) {
|
|
349
|
+
await this.announce(messages().goalStarted(this.state.objective));
|
|
350
|
+
const res = await this.runGoalTurn(decomposePrompt(this.state.objective));
|
|
351
|
+
if (this.runId !== myRun)
|
|
352
|
+
return;
|
|
353
|
+
if (res.stopReason === "cancelled") {
|
|
354
|
+
if (this.consumeSandboxRestart())
|
|
355
|
+
return this.runRounds(myRun);
|
|
356
|
+
return void (await this.endLoop("paused", "cancelled"));
|
|
357
|
+
}
|
|
358
|
+
const parsed = parseTickets(await this.lastAssistantText());
|
|
359
|
+
this.state.tickets = (parsed ?? [{ title: this.state.objective, acceptance: this.state.objective }]).map((t, i) => ({
|
|
360
|
+
id: `t${i + 1}`,
|
|
361
|
+
title: t.title,
|
|
362
|
+
acceptance: t.acceptance,
|
|
363
|
+
status: "pending",
|
|
364
|
+
}));
|
|
365
|
+
this.persist();
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
await this.announce(messages().goalResumed(this.state.rounds));
|
|
369
|
+
}
|
|
370
|
+
let stallCount = 0;
|
|
371
|
+
while (alive()) {
|
|
372
|
+
if (this.stopFlag)
|
|
373
|
+
return void (await this.endLoop("stopped", "/auto stop", messages().goalStopped));
|
|
374
|
+
if (this.pauseFlag)
|
|
375
|
+
return void (await this.endLoop("paused", "/auto pause"));
|
|
376
|
+
const ticket = this.state.tickets.find((t) => t.status === "pending" || t.status === "in_progress");
|
|
377
|
+
if (!ticket)
|
|
378
|
+
return void (await this.endLoop("complete", "all tickets done", messages().goalComplete(this.state.rounds)));
|
|
379
|
+
ticket.status = "in_progress";
|
|
380
|
+
if (this.state.rounds >= goalMaxTurns()) {
|
|
381
|
+
return void (await this.endLoop("paused-budget", `round budget ${goalMaxTurns()}`));
|
|
382
|
+
}
|
|
383
|
+
// Merge parked user text (plus any preserved across a resume) into this
|
|
384
|
+
// round; ONLY the prompts parked before dispatch settle with it — text
|
|
385
|
+
// parked DURING the round merges into the next one instead.
|
|
386
|
+
const roundParked = [...this.parked];
|
|
387
|
+
const userText = [this.state.parkedText, ...roundParked.map((p) => p.text)].filter(Boolean).join("\n\n") ||
|
|
388
|
+
undefined;
|
|
389
|
+
this.state.parkedText = undefined;
|
|
390
|
+
const before = await this.messageCount();
|
|
391
|
+
const result = await this.runGoalTurn(dispatchPrompt({
|
|
392
|
+
objective: this.state.objective,
|
|
393
|
+
ticket,
|
|
394
|
+
ticketIndex: this.state.tickets.indexOf(ticket) + 1,
|
|
395
|
+
ticketCount: this.state.tickets.length,
|
|
396
|
+
handoffFile: this.state.handoffFresh
|
|
397
|
+
? handoffPath(this.server.projectCwd(), this.zcodeSid)
|
|
398
|
+
: undefined,
|
|
399
|
+
userText,
|
|
400
|
+
}));
|
|
401
|
+
if (this.runId !== myRun)
|
|
402
|
+
return;
|
|
403
|
+
if (result.stopReason === "cancelled") {
|
|
404
|
+
ticket.status = "pending";
|
|
405
|
+
if (this.consumeSandboxRestart()) {
|
|
406
|
+
// Sandbox allow-restart killed the backend mid-round: re-dispatch
|
|
407
|
+
// the same ticket on the respawned backend (ensureBackend inside
|
|
408
|
+
// runGoalTurn), don't pause — the user answered "allow" to keep
|
|
409
|
+
// the loop going.
|
|
410
|
+
await this.announce(messages().sandboxResumedStatus);
|
|
411
|
+
continue;
|
|
412
|
+
}
|
|
413
|
+
// ESC / external cancel: the round was cut short — pause, keep text.
|
|
414
|
+
return void (await this.endLoop("paused", "cancelled"));
|
|
415
|
+
}
|
|
416
|
+
this.state.rounds++;
|
|
417
|
+
// Stall detection: rounds with zero tool activity accumulate to a pause.
|
|
418
|
+
const tools = await this.toolActivitySince(before);
|
|
419
|
+
stallCount = tools > 0 ? 0 : stallCount + 1;
|
|
420
|
+
const verdict = parseVerdict(await this.lastAssistantText());
|
|
421
|
+
if (verdict?.kind === "impossible") {
|
|
422
|
+
return void (await this.endLoop("impossible", verdict.why ?? "reported impossible", messages().goalImpossible(verdict.why ?? "")));
|
|
423
|
+
}
|
|
424
|
+
if (verdict?.kind === "met") {
|
|
425
|
+
// Verification turn (uncounted): re-run the acceptance criteria. The
|
|
426
|
+
// verdict arrives as a model-WRITTEN file in a fixed format; the prose
|
|
427
|
+
// reply is only a fallback (parsing prose looped forever on real
|
|
428
|
+
// backends — observed 2026-09, 11 rounds of re-doing finished work).
|
|
429
|
+
const vPath = verifyPath(this.server.projectCwd(), this.zcodeSid);
|
|
430
|
+
const readVerify = () => {
|
|
431
|
+
try {
|
|
432
|
+
return parseVerifyFile(readFileSync(vPath, "utf8"));
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
};
|
|
438
|
+
// Stale verdict from an earlier round must not be read as this one's.
|
|
439
|
+
try {
|
|
440
|
+
unlinkSync(vPath);
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
/* absent — fine */
|
|
444
|
+
}
|
|
445
|
+
// Prose fallback reads ONLY messages appended by the verify turn: a
|
|
446
|
+
// walk-back into the dispatch reply would let the worker's own "made
|
|
447
|
+
// the tests pass" verify its own work.
|
|
448
|
+
const vBefore = await this.messageCount();
|
|
449
|
+
const vRes = await this.runGoalTurn(verifyPrompt(ticket, vPath));
|
|
450
|
+
if (this.runId !== myRun)
|
|
451
|
+
return;
|
|
452
|
+
if (vRes.stopReason === "cancelled") {
|
|
453
|
+
if (this.consumeSandboxRestart()) {
|
|
454
|
+
await this.announce(messages().sandboxResumedStatus);
|
|
455
|
+
this.persist();
|
|
456
|
+
continue;
|
|
457
|
+
}
|
|
458
|
+
return void (await this.endLoop("paused", "cancelled"));
|
|
459
|
+
}
|
|
460
|
+
let v = readVerify() ?? parseVerifyReply(await this.lastAssistantText(vBefore));
|
|
461
|
+
if (!v) {
|
|
462
|
+
const rRes = await this.runGoalTurn(strictVerifyPrompt(ticket, vPath));
|
|
463
|
+
if (this.runId !== myRun)
|
|
464
|
+
return;
|
|
465
|
+
if (rRes.stopReason === "cancelled") {
|
|
466
|
+
if (this.consumeSandboxRestart()) {
|
|
467
|
+
await this.announce(messages().sandboxResumedStatus);
|
|
468
|
+
this.persist();
|
|
469
|
+
continue;
|
|
470
|
+
}
|
|
471
|
+
return void (await this.endLoop("paused", "cancelled"));
|
|
472
|
+
}
|
|
473
|
+
v = readVerify() ?? parseVerifyReply(await this.lastAssistantText(vBefore));
|
|
474
|
+
}
|
|
475
|
+
if (!v) {
|
|
476
|
+
// Still unreadable: pause for the user instead of looping or
|
|
477
|
+
// blindly trusting the worker's claim.
|
|
478
|
+
ticket.status = "pending";
|
|
479
|
+
this.persist();
|
|
480
|
+
return void (await this.endLoop("paused", "verification unreadable", messages().goalVerifyUnreadable(ticket.title)));
|
|
481
|
+
}
|
|
482
|
+
if (v.pass) {
|
|
483
|
+
ticket.status = "done";
|
|
484
|
+
ticket.feedback = undefined;
|
|
485
|
+
}
|
|
486
|
+
else {
|
|
487
|
+
ticket.feedback = v.reason ?? messages().goalVerifyUnparsed;
|
|
488
|
+
await this.announce(messages().goalVerifyFailed(ticket.title, ticket.feedback));
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
this.persist();
|
|
492
|
+
// Parked prompts merged into this round settle with it; ones parked
|
|
493
|
+
// DURING the round stay for the next merge.
|
|
494
|
+
this.settleParked({ stopReason: "end_turn" }, false, roundParked);
|
|
495
|
+
if (stallCount >= STALL_ROUND_LIMIT) {
|
|
496
|
+
return void (await this.endLoop("paused-stall", "no tool activity", messages().goalStallPaused));
|
|
497
|
+
}
|
|
498
|
+
// Compaction at the round boundary (ADR-0022 §2).
|
|
499
|
+
const { currentModelCached } = await import("../config/model-cache.js");
|
|
500
|
+
const { modelContextWindow, parseModelValue } = await import("../config/options.js");
|
|
501
|
+
const cur = parseModelValue(await currentModelCached(this.server, this.zcodeSid));
|
|
502
|
+
const window = modelContextWindow(cur?.providerId ?? "", cur?.modelId ?? "") ||
|
|
503
|
+
(await this.contextWindowFromRead());
|
|
504
|
+
const used = await this.contextUsed();
|
|
505
|
+
if (window > 0 && used >= goalCompactThreshold(window)) {
|
|
506
|
+
const handoffCancelled = await this.compactBoundary();
|
|
507
|
+
if (this.runId !== myRun)
|
|
508
|
+
return;
|
|
509
|
+
// ESC during the handoff turn pauses like any other round (a restart
|
|
510
|
+
// cancel just skips this boundary's compaction).
|
|
511
|
+
if (handoffCancelled && !this.consumeSandboxRestart()) {
|
|
512
|
+
return void (await this.endLoop("paused", "cancelled"));
|
|
513
|
+
}
|
|
514
|
+
}
|
|
515
|
+
await this.announce(messages().goalReport(this.state.rounds, goalMaxTurns(), ticket.title, ticket.status === "done"));
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
async contextWindowFromRead() {
|
|
519
|
+
const resp = await this.server
|
|
520
|
+
.ensureBackend()
|
|
521
|
+
.request(this.server.nextId(), "session/read", { sessionId: this.zcodeSid }, 5000);
|
|
522
|
+
if (resp.error)
|
|
523
|
+
return 0;
|
|
524
|
+
return ((resp.result ?? {}).projection
|
|
525
|
+
?.contextWindow ?? 0);
|
|
526
|
+
}
|
|
527
|
+
/**
|
|
528
|
+
* Handoff turn → session/compact → mark handoffFresh for the next round.
|
|
529
|
+
* Returns true when the handoff turn was cancelled (ESC / restart).
|
|
530
|
+
*/
|
|
531
|
+
async compactBoundary() {
|
|
532
|
+
const hPath = handoffPath(this.server.projectCwd(), this.zcodeSid);
|
|
533
|
+
const used = await this.contextUsed();
|
|
534
|
+
await this.announce(messages().autoCompactStart(used.toLocaleString(), "goal loop"));
|
|
535
|
+
try {
|
|
536
|
+
const hRes = await this.runGoalTurn(handoffPrompt({ objective: this.state.objective, handoffFile: hPath }));
|
|
537
|
+
if (hRes.stopReason === "cancelled")
|
|
538
|
+
return true;
|
|
539
|
+
// compact() waits out its internal turn itself; state rides along. Its
|
|
540
|
+
// waitForTurnIdle window (up to 300s) runs with no pendingTurns entry,
|
|
541
|
+
// so ESC there cannot flag a turn — cancel() parks the pause on the
|
|
542
|
+
// driver instead (requestPause), consumed at the next round boundary.
|
|
543
|
+
await compact(this.server, { sessionId: this.acpSid }, this.server.clients.broadcast());
|
|
544
|
+
this.state.handoffFresh = true;
|
|
545
|
+
this.persist();
|
|
546
|
+
await this.announce(messages().autoCompactDone);
|
|
547
|
+
return false;
|
|
548
|
+
}
|
|
549
|
+
catch (e) {
|
|
550
|
+
warn(`goal-loop: compaction failed (${e instanceof Error ? e.message : String(e)}) — continuing without`);
|
|
551
|
+
await this.announce(messages().autoCompactFailed(e instanceof Error ? e.message : String(e)));
|
|
552
|
+
return false;
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
//# sourceMappingURL=driver.js.map
|