@peakflo/agent-harness 0.0.0-stage → 0.2.1-rc.3
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/LICENSE +21 -0
- package/README.md +38 -2
- package/dist/adapters/acp-adapter.d.ts +177 -0
- package/dist/adapters/acp-adapter.js +1732 -0
- package/dist/adapters/claude-code-adapter.d.ts +171 -0
- package/dist/adapters/claude-code-adapter.js +1918 -0
- package/dist/adapters/codex-adapter.d.ts +67 -0
- package/dist/adapters/codex-adapter.js +681 -0
- package/dist/adapters/codex-app-server-adapter.d.ts +105 -0
- package/dist/adapters/codex-app-server-adapter.js +1309 -0
- package/dist/adapters/coding-agent-adapter.d.ts +289 -0
- package/dist/adapters/coding-agent-adapter.js +31 -0
- package/dist/adapters/index.d.ts +17 -0
- package/dist/adapters/index.js +35 -0
- package/dist/adapters/model-gateway.d.ts +84 -0
- package/dist/adapters/model-gateway.js +270 -0
- package/dist/adapters/opencode-adapter.d.ts +403 -0
- package/dist/adapters/opencode-adapter.js +2710 -0
- package/dist/adapters/opencode-config.d.ts +41 -0
- package/dist/adapters/opencode-config.js +147 -0
- package/dist/adapters/opencode-server-policy.d.ts +82 -0
- package/dist/adapters/opencode-server-policy.js +148 -0
- package/dist/adapters/pi-adapter.d.ts +168 -0
- package/dist/adapters/pi-adapter.js +1468 -0
- package/dist/capabilities.d.ts +58 -0
- package/dist/capabilities.js +161 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +231 -0
- package/dist/contract.d.ts +273 -0
- package/dist/contract.js +357 -0
- package/dist/control-plane.d.ts +178 -0
- package/dist/control-plane.js +431 -0
- package/dist/credential-store.d.ts +110 -0
- package/dist/credential-store.js +181 -0
- package/dist/enrol/browser-enrol.d.ts +85 -0
- package/dist/enrol/browser-enrol.js +387 -0
- package/dist/github-command-guards.d.ts +20 -0
- package/dist/github-command-guards.js +330 -0
- package/dist/github-proxy-certificates.d.ts +25 -0
- package/dist/github-proxy-certificates.js +145 -0
- package/dist/github-proxy.d.ts +37 -0
- package/dist/github-proxy.js +476 -0
- package/dist/github-workspace.d.ts +55 -0
- package/dist/github-workspace.js +537 -0
- package/dist/harness.d.ts +333 -0
- package/dist/harness.js +1414 -0
- package/dist/health.d.ts +45 -0
- package/dist/health.js +116 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +123 -0
- package/dist/model-probe.d.ts +96 -0
- package/dist/model-probe.js +195 -0
- package/dist/options.d.ts +15 -0
- package/dist/options.js +212 -0
- package/dist/ports-default.d.ts +212 -0
- package/dist/ports-default.js +333 -0
- package/dist/ports.d.ts +436 -0
- package/dist/ports.js +27 -0
- package/dist/runtime/attachments.d.ts +92 -0
- package/dist/runtime/attachments.js +377 -0
- package/dist/runtime/child-stream-guards.d.ts +45 -0
- package/dist/runtime/child-stream-guards.js +111 -0
- package/dist/runtime/conversation.d.ts +87 -0
- package/dist/runtime/conversation.js +156 -0
- package/dist/runtime/lease-renewal.d.ts +169 -0
- package/dist/runtime/lease-renewal.js +433 -0
- package/dist/runtime/mcp-auth-proxy.d.ts +82 -0
- package/dist/runtime/mcp-auth-proxy.js +300 -0
- package/dist/runtime/permission-mode.d.ts +116 -0
- package/dist/runtime/permission-mode.js +146 -0
- package/dist/runtime/projection.d.ts +71 -0
- package/dist/runtime/projection.js +211 -0
- package/dist/runtime/session-runner.d.ts +520 -0
- package/dist/runtime/session-runner.js +2122 -0
- package/dist/shared/reasoning-effort.d.ts +11 -0
- package/dist/shared/reasoning-effort.js +20 -0
- package/dist/store/conversation-store.d.ts +100 -0
- package/dist/store/conversation-store.js +305 -0
- package/dist/store/sqlite-store.d.ts +120 -0
- package/dist/store/sqlite-store.js +346 -0
- package/dist/types.d.ts +952 -0
- package/dist/types.js +55 -0
- package/dist/version.d.ts +32 -0
- package/dist/version.js +77 -0
- package/package.json +53 -4
|
@@ -0,0 +1,2122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* THE SESSION RUNNER — one leased turn, start to finish.
|
|
4
|
+
*
|
|
5
|
+
* This is the loop 20x's `AgentManager` runs, reduced to the part that is
|
|
6
|
+
* actually about running an agent: prepare a workspace, wire the MCP servers
|
|
7
|
+
* through the loopback auth proxy, drive a backend adapter, and push every part
|
|
8
|
+
* it produces into the durable projection and then out to the control plane.
|
|
9
|
+
*
|
|
10
|
+
* WHAT IT DELIBERATELY IS NOT. `AgentManager` also owns tasks, worktrees, pull
|
|
11
|
+
* requests, plugin actions, skills sync, output-field extraction and heartbeat
|
|
12
|
+
* scheduling. None of that is a property of running an agent; all of it is a
|
|
13
|
+
* property of 20x's TASK model. Dragging it across would have made this package
|
|
14
|
+
* "20x without a window" rather than a harness, and would have required the
|
|
15
|
+
* control plane to grow a task table to match. The seam is cut at the turn.
|
|
16
|
+
*
|
|
17
|
+
* THE TURN IS THE UNIT OF WORK, which is what makes the lease design work: one
|
|
18
|
+
* lease is one turn, a lease that expires is requeued, and a runner that dies
|
|
19
|
+
* mid-turn loses at most that turn — because the parts it already produced were
|
|
20
|
+
* ingested as it went, not at the end.
|
|
21
|
+
*/
|
|
22
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
23
|
+
exports.SessionRunner = void 0;
|
|
24
|
+
exports.qualifiedModelId = qualifiedModelId;
|
|
25
|
+
exports.describeProviderError = describeProviderError;
|
|
26
|
+
exports.modelGatewayRefusal = modelGatewayRefusal;
|
|
27
|
+
exports.permissionFingerprint = permissionFingerprint;
|
|
28
|
+
exports.decodeQuestionAnswers = decodeQuestionAnswers;
|
|
29
|
+
exports.isApprovingOptionId = isApprovingOptionId;
|
|
30
|
+
const node_crypto_1 = require("node:crypto");
|
|
31
|
+
const model_gateway_1 = require("../adapters/model-gateway");
|
|
32
|
+
const contract_1 = require("../contract");
|
|
33
|
+
const attachments_1 = require("./attachments");
|
|
34
|
+
const conversation_1 = require("./conversation");
|
|
35
|
+
const permission_mode_1 = require("./permission-mode");
|
|
36
|
+
const projection_1 = require("./projection");
|
|
37
|
+
/** How often the runner asks the adapter for new output when nothing pushes. */
|
|
38
|
+
const POLL_INTERVAL_MS = 2_000;
|
|
39
|
+
/**
|
|
40
|
+
* How long an idle status must persist before the turn is considered over.
|
|
41
|
+
*
|
|
42
|
+
* A backend reports IDLE between tool calls as well as at the end of a turn, so
|
|
43
|
+
* ending on the first idle would truncate almost every transcript at the first
|
|
44
|
+
* tool. Two consecutive quiet polls is the 20x behaviour and the reason its
|
|
45
|
+
* transcripts are complete.
|
|
46
|
+
*/
|
|
47
|
+
const IDLE_CONFIRMATIONS = 2;
|
|
48
|
+
/**
|
|
49
|
+
* THE PRIMARY GUARD, per the LONG TURNS fix.
|
|
50
|
+
*
|
|
51
|
+
* A wall clock cannot tell a runaway model from a slow-but-healthy one — a
|
|
52
|
+
* real coding turn routinely runs 30-90 minutes. Progress is the honest
|
|
53
|
+
* signal: as long as the backend keeps producing a new message part, a tool
|
|
54
|
+
* call start/end, or streamed text, the turn keeps running no matter how long
|
|
55
|
+
* it has been open. Only silence this long means the backend is hung.
|
|
56
|
+
*
|
|
57
|
+
* 20 MINUTES, NOT 10 — raised on review. `pollMessages` only reports a tool
|
|
58
|
+
* call's completion (or an incremental streamed chunk), not that one is still
|
|
59
|
+
* running: a single tool that legitimately takes a long time — a large test
|
|
60
|
+
* suite, a slow build, a big migration — produces NOTHING for the guard to
|
|
61
|
+
* see until it finishes. 10 minutes cut that kind of tool call outright; 20
|
|
62
|
+
* gives real long-running commands room while still catching a backend that
|
|
63
|
+
* has actually gone silent (crashed, wedged, or lost its connection) well
|
|
64
|
+
* short of the old fixed 10-minute wall clock this whole fix replaces.
|
|
65
|
+
*
|
|
66
|
+
* Used when the lease does not name its own `maxTurnIdleMs`
|
|
67
|
+
* (`CODING_AGENT_MAX_TURN_IDLE_MINUTES` on the control plane).
|
|
68
|
+
*/
|
|
69
|
+
const DEFAULT_MAX_TURN_IDLE_MS = 20 * 60_000;
|
|
70
|
+
/**
|
|
71
|
+
* Where the soft warning fires, as a fraction of the hard ceiling.
|
|
72
|
+
*
|
|
73
|
+
* Only meaningful when an operator has actually set
|
|
74
|
+
* `CODING_AGENT_MAX_TURN_MINUTES` — `maxTurnDurationMs: null` (the default)
|
|
75
|
+
* has no ceiling and therefore no 90% mark either.
|
|
76
|
+
*/
|
|
77
|
+
const HARD_CEILING_WARNING_FRACTION = 0.9;
|
|
78
|
+
/** "1 minute" / "10 minutes" — never "0 minutes", which reads like a bug. */
|
|
79
|
+
function formatMinutes(ms) {
|
|
80
|
+
const minutes = Math.max(1, Math.round(ms / 60_000));
|
|
81
|
+
return `${minutes} minute${minutes === 1 ? '' : 's'}`;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* The model id the backend actually understands.
|
|
85
|
+
*
|
|
86
|
+
* The lease carries `provider` and `model` as SEPARATE fields, but Pi selects
|
|
87
|
+
* a provider/model pair from ONE qualified string at the adapter boundary.
|
|
88
|
+
* Passing `model.model` alone therefore threw the provider away: a lease
|
|
89
|
+
* saying `{provider:'anthropic', model:'claude-sonnet-4'}` reached the adapter
|
|
90
|
+
* as `claude-sonnet-4`, which has no `/`, so the adapter sent NO model at all
|
|
91
|
+
* and the backend silently used its own default. The control plane's choice of
|
|
92
|
+
* model was being ignored on every turn.
|
|
93
|
+
*
|
|
94
|
+
* Splitting on the FIRST slash is why this is a plain join and not a
|
|
95
|
+
* "does it already contain a slash" test: a real gateway model id is
|
|
96
|
+
* `peakflo/vertex_ai/gemini-3-flash-preview`, whose modelID legitimately
|
|
97
|
+
* contains a slash of its own.
|
|
98
|
+
*/
|
|
99
|
+
function qualifiedModelId(model) {
|
|
100
|
+
const name = (model.model ?? '').trim();
|
|
101
|
+
if (!name)
|
|
102
|
+
return undefined;
|
|
103
|
+
const provider = (model.provider ?? '').trim();
|
|
104
|
+
return provider ? `${provider}/${name}` : name;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Refuse to run a turn that would fall back to the host's own credentials.
|
|
108
|
+
*
|
|
109
|
+
* THIS IS THE SECURITY POINT OF THE GATEWAY, not a nicety. When the lease
|
|
110
|
+
* names a gateway but carries no `modelToken`, the adapter builds no `peakflo`
|
|
111
|
+
* provider, Pi can fall through to whatever is in the host's provider store,
|
|
112
|
+
* and the turn may run anyway — silently spending
|
|
113
|
+
* the machine owner's credentials, unmetered, with no per-tenant budget. On a
|
|
114
|
+
* customer VM, which is outside our security perimeter, that is somebody
|
|
115
|
+
* else's key being used by our software without anybody noticing.
|
|
116
|
+
*
|
|
117
|
+
* A missing token must therefore be LOUD. Returning a reason rather than
|
|
118
|
+
* throwing keeps the decision testable on its own.
|
|
119
|
+
*/
|
|
120
|
+
/**
|
|
121
|
+
* Say WHICH model was refused, and by whom.
|
|
122
|
+
*
|
|
123
|
+
* The bare provider text is often one word. A turn on
|
|
124
|
+
* a non-gateway model reported exactly `Forbidden`, which names
|
|
125
|
+
* neither the model nor the provider that refused it, so it reads as a fault
|
|
126
|
+
* in this runner. It is not: the provider rejected a model this machine holds
|
|
127
|
+
* no credential for.
|
|
128
|
+
*
|
|
129
|
+
* A model id names its provider (decision recorded in `modelGatewayRefusal`),
|
|
130
|
+
* and only the gateway provider is credentialed per turn from the lease.
|
|
131
|
+
* Every other provider needs a credential on the RUNNER, so for those the
|
|
132
|
+
* refusal is worth naming as a likely missing credential — likely, not
|
|
133
|
+
* certain, because a quota or a withdrawn model refuses identically and
|
|
134
|
+
* claiming otherwise would send the reader to the wrong place.
|
|
135
|
+
*/
|
|
136
|
+
function describeProviderError(error, qualifiedModel) {
|
|
137
|
+
// `qualifiedModelId` returns undefined when the lease named no model at all.
|
|
138
|
+
const model = (qualifiedModel ?? '').trim();
|
|
139
|
+
const head = model
|
|
140
|
+
? `The model provider refused this turn on ${model}: ${error}`
|
|
141
|
+
: `The model provider refused this turn: ${error}`;
|
|
142
|
+
const provider = model.includes('/')
|
|
143
|
+
? model.slice(0, model.indexOf('/'))
|
|
144
|
+
: '';
|
|
145
|
+
if (!provider || provider === model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID) {
|
|
146
|
+
return head;
|
|
147
|
+
}
|
|
148
|
+
return (`${head}\n\n${provider} is not the Peakflo model gateway, so this runner ` +
|
|
149
|
+
`must hold its own credential for it. Check that the runner is signed in ` +
|
|
150
|
+
`to ${provider}, or choose a ${model_gateway_1.ENTERPRISE_AI_GATEWAY_PROVIDER_ID}/ model, ` +
|
|
151
|
+
`which is credentialed for each turn.`);
|
|
152
|
+
}
|
|
153
|
+
function modelGatewayRefusal(lease) {
|
|
154
|
+
const gatewayUrl = (lease.session.agent.model.gatewayUrl ?? '').trim();
|
|
155
|
+
if (!gatewayUrl)
|
|
156
|
+
return null;
|
|
157
|
+
const token = (lease.modelToken ?? '').trim();
|
|
158
|
+
if (!token) {
|
|
159
|
+
return (`Lease names model gateway ${gatewayUrl} but carries no modelToken. ` +
|
|
160
|
+
"Refusing the turn: running it would fall back to this host's own " +
|
|
161
|
+
'provider credentials, which are neither tenant-metered nor ' +
|
|
162
|
+
'budget-capped.');
|
|
163
|
+
}
|
|
164
|
+
/*
|
|
165
|
+
* THE PROVIDER IS PI'S TO CHOOSE, ON EVERY RUNTIME.
|
|
166
|
+
*
|
|
167
|
+
* There used to be a second refusal here: a lease naming `anthropic/...`
|
|
168
|
+
* built the `peakflo` provider and then never routed to it, because the backend
|
|
169
|
+
* routes by the provider in the model id — so the turn ran on whatever
|
|
170
|
+
* credentials the host had. That was treated as credential-borrowing and
|
|
171
|
+
* refused outside the desktop.
|
|
172
|
+
*
|
|
173
|
+
* It is now allowed everywhere, deliberately. A model id names its provider,
|
|
174
|
+
* and Pi is the thing that knows how to reach it — peakflo, google, a local
|
|
175
|
+
* model. The gateway is one provider among them and the
|
|
176
|
+
* default, not the only permitted route. Refusing the rest meant the model
|
|
177
|
+
* picker offered models that could never run.
|
|
178
|
+
*
|
|
179
|
+
* WHAT THIS GIVES UP, RECORDED RATHER THAN GLOSSED: a turn may run outside
|
|
180
|
+
* the gateway and therefore outside tenant metering and budget caps, on any
|
|
181
|
+
* runtime including the managed sandbox. On a user's own machine that is
|
|
182
|
+
* their key and their bill. On the SANDBOX it is our machine — the mitigation
|
|
183
|
+
* there is that the image ships Pi with no provider credentials
|
|
184
|
+
* configured, so there is nothing ambient to borrow; a turn that names a
|
|
185
|
+
* provider the sandbox cannot reach fails at the model call rather than
|
|
186
|
+
* silently spending. If credentials are ever baked into that image, this
|
|
187
|
+
* decision needs revisiting.
|
|
188
|
+
*
|
|
189
|
+
* The EMPTY-TOKEN refusal above is untouched: a lease that names a gateway
|
|
190
|
+
* and carries no credential is a broken lease, not a choice of provider.
|
|
191
|
+
*/
|
|
192
|
+
return null;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Option ids that mean YES.
|
|
196
|
+
*
|
|
197
|
+
* AN ALLOWLIST, NOT "anything that is not deny". The difference matters the
|
|
198
|
+
* moment a backend adds a fifth option: under a denylist an unrecognised id
|
|
199
|
+
* would be APPROVED, and the cost of being wrong in that direction is an
|
|
200
|
+
* unattended agent running a command the user meant to refuse. Under this list
|
|
201
|
+
* an unrecognised id is refused, and the cost of being wrong is a prompt the
|
|
202
|
+
* user has to answer twice.
|
|
203
|
+
*
|
|
204
|
+
* `once` and `always` are OpenCode's own wire words; `allow`, `allow-always`
|
|
205
|
+
* and `approved-for-session` are what `getPendingApproval` offers and what 20x
|
|
206
|
+
* sends. All five are accepted because both vocabularies reach this function.
|
|
207
|
+
*/
|
|
208
|
+
/**
|
|
209
|
+
* What happened to an `answer` command.
|
|
210
|
+
*
|
|
211
|
+
* A DISCRIMINATED RESULT, NOT A BOOLEAN, and the distinction is load-bearing.
|
|
212
|
+
* `false` used to mean both "there was nothing to answer" and "the backend
|
|
213
|
+
* refused the answer", and the caller's recovery path — forgetting the
|
|
214
|
+
* commandId, telling the user — is right for the second and WRONG for the
|
|
215
|
+
* first. Collapsing them is what made that recovery path unreachable: the
|
|
216
|
+
* catch below swallowed the delivery failure and returned the same `false` a
|
|
217
|
+
* stale answer returns, so the harness's `.catch()` never fired and the user's
|
|
218
|
+
* click vanished with no error anywhere.
|
|
219
|
+
*/
|
|
220
|
+
/**
|
|
221
|
+
* The permission identity a backend session was created under.
|
|
222
|
+
*
|
|
223
|
+
* THE FULL PAIR, NOT JUST `permissionMode`. `default`, `plan` and `acceptEdits`
|
|
224
|
+
* all map to `'ask'` and differ only in `sandboxMode`, so comparing the
|
|
225
|
+
* two-valued mode alone would miss a `default` -> `plan` change entirely.
|
|
226
|
+
* Dormant on OpenCode, where those are indistinguishable anyway — but Codex
|
|
227
|
+
* reads `sandboxMode` directly, and a guard written generically should not
|
|
228
|
+
* quietly depend on which backend happens to be wired.
|
|
229
|
+
*
|
|
230
|
+
* A LEGACY VALUE ('ask' / 'allow', written before this carried the pair) can
|
|
231
|
+
* never equal a fingerprint, so it reads as "unrecorded" and forces a rebuild.
|
|
232
|
+
* That is the intended fail-closed outcome, not an accident of the format.
|
|
233
|
+
*/
|
|
234
|
+
function permissionFingerprint(mode) {
|
|
235
|
+
const mapped = (0, permission_mode_1.mapPermissionMode)(mode);
|
|
236
|
+
return `${mapped.permissionMode}:${mapped.sandboxMode}`;
|
|
237
|
+
}
|
|
238
|
+
/**
|
|
239
|
+
* Decode the wire format produced by the browser's question card.
|
|
240
|
+
*
|
|
241
|
+
* This deliberately matches 20x's AgentManager: each line is either
|
|
242
|
+
* `Header: answer` or an unlabelled single answer. OpenCode needs the headers
|
|
243
|
+
* to align answers with a multi-question request.
|
|
244
|
+
*/
|
|
245
|
+
function decodeQuestionAnswers(answer) {
|
|
246
|
+
const answers = {};
|
|
247
|
+
for (const line of (answer ?? '').split('\n')) {
|
|
248
|
+
const trimmed = line.trim();
|
|
249
|
+
if (!trimmed)
|
|
250
|
+
continue;
|
|
251
|
+
const colon = trimmed.indexOf(':');
|
|
252
|
+
if (colon > 0) {
|
|
253
|
+
answers[trimmed.slice(0, colon).trim()] = trimmed.slice(colon + 1).trim();
|
|
254
|
+
}
|
|
255
|
+
else {
|
|
256
|
+
answers.answer = trimmed;
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
return answers;
|
|
260
|
+
}
|
|
261
|
+
const APPROVING_OPTION_IDS = new Set([
|
|
262
|
+
// OpenCode / the shape `getPendingApproval` offers.
|
|
263
|
+
'allow',
|
|
264
|
+
'allow-always',
|
|
265
|
+
'approved-for-session',
|
|
266
|
+
'always',
|
|
267
|
+
'once',
|
|
268
|
+
'approved',
|
|
269
|
+
/*
|
|
270
|
+
* CODEX. ITS "ALLOW" BUTTON IS `accept`, AND OMITTING IT WAS A REAL BUG:
|
|
271
|
+
* `codex-app-server-adapter.ts:995` offers `{ optionId: 'accept', name:
|
|
272
|
+
* 'Allow' }` by default. That id passed the "was this option offered" check
|
|
273
|
+
* and then FAILED this one, so a user clicking Allow on a Codex session had
|
|
274
|
+
* their approval relayed as `approved: false` and saw "Denied" in the
|
|
275
|
+
* transcript for something they had explicitly permitted.
|
|
276
|
+
*
|
|
277
|
+
* The cause was duplication: Codex has its OWN approving list at
|
|
278
|
+
* `codex-app-server-adapter.ts:588` which does include these, and this set
|
|
279
|
+
* was written without consulting it. `approving-option-ids.test.ts` now
|
|
280
|
+
* derives the expectation from what the adapters actually offer, so a backend
|
|
281
|
+
* adding a new id cannot silently become a denial again.
|
|
282
|
+
*/
|
|
283
|
+
'accept',
|
|
284
|
+
'acceptForSession',
|
|
285
|
+
'approved_for_session'
|
|
286
|
+
]);
|
|
287
|
+
/**
|
|
288
|
+
* Is this option id an approval?
|
|
289
|
+
*
|
|
290
|
+
* Exported so `approving-option-ids.test.ts` can check it against what the
|
|
291
|
+
* ADAPTERS actually offer, rather than against a restatement of the same list —
|
|
292
|
+
* a hardcoded copy in the test would have passed against the bug that made
|
|
293
|
+
* Codex's "Allow" read as a denial.
|
|
294
|
+
*/
|
|
295
|
+
function isApprovingOptionId(optionId) {
|
|
296
|
+
return APPROVING_OPTION_IDS.has(optionId);
|
|
297
|
+
}
|
|
298
|
+
/** Free-text answers that mean NO, for the paths that carry no `optionId`. */
|
|
299
|
+
const DENYING_ANSWER_TEXT = /^\s*(no|deny|denied|reject|rejected\b.*|cancel)\s*\.?\s*$/i;
|
|
300
|
+
/**
|
|
301
|
+
* Decide what a user's answer actually means, and fail closed when it is not
|
|
302
|
+
* clear.
|
|
303
|
+
*
|
|
304
|
+
* THREE PATHS REACH HERE AND THEY DO NOT CARRY THE SAME INFORMATION:
|
|
305
|
+
*
|
|
306
|
+
* 1. An option BUTTON in the question card. Sends `optionId`. Unambiguous,
|
|
307
|
+
* and this is the ordinary case.
|
|
308
|
+
* 2. The card's Reject button. Sends no `optionId` and the fixed text
|
|
309
|
+
* "Rejected by the user."
|
|
310
|
+
* 3. The COMPOSER. When a question is open, typing a message posts it as an
|
|
311
|
+
* answer instead (composer.tsx:76-106) — with no `optionId` at all.
|
|
312
|
+
*
|
|
313
|
+
* So free text has to be interpreted, and the rule is: interpret it only when
|
|
314
|
+
* it plainly matches something the backend offered. Anything else DENIES, and
|
|
315
|
+
* says so in the transcript rather than silently doing the safer thing without
|
|
316
|
+
* telling the user why they are being asked again.
|
|
317
|
+
*/
|
|
318
|
+
function resolveApproval(command, open) {
|
|
319
|
+
const answer = (command.answer ?? '').trim();
|
|
320
|
+
// Path 1 — an explicit option id.
|
|
321
|
+
if (command.optionId) {
|
|
322
|
+
const approved = isApprovingOptionId(command.optionId);
|
|
323
|
+
// An id the backend never offered is not trusted even if it LOOKS
|
|
324
|
+
// approving: it did not come from a button we rendered.
|
|
325
|
+
const offered = open.knownOptionIds.has(command.optionId);
|
|
326
|
+
if (!offered) {
|
|
327
|
+
return {
|
|
328
|
+
approved: false,
|
|
329
|
+
transcriptText: `Denied — the answer named an option (${command.optionId}) that was not offered.`
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
return {
|
|
333
|
+
approved,
|
|
334
|
+
optionId: command.optionId,
|
|
335
|
+
transcriptText: approved
|
|
336
|
+
? `Approved${answer ? `: ${answer}` : ''}`
|
|
337
|
+
: `Denied${answer ? `: ${answer}` : ''}`
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
// Path 2/3 — free text. Match it against the labels the backend offered.
|
|
341
|
+
const matched = open.optionIdByLabel.get(answer.toLowerCase());
|
|
342
|
+
if (matched) {
|
|
343
|
+
const approved = isApprovingOptionId(matched);
|
|
344
|
+
return {
|
|
345
|
+
approved,
|
|
346
|
+
optionId: matched,
|
|
347
|
+
transcriptText: approved ? `Approved: ${answer}` : `Denied: ${answer}`
|
|
348
|
+
};
|
|
349
|
+
}
|
|
350
|
+
if (DENYING_ANSWER_TEXT.test(answer)) {
|
|
351
|
+
return { approved: false, transcriptText: `Denied: ${answer}` };
|
|
352
|
+
}
|
|
353
|
+
/*
|
|
354
|
+
* Unrecognised free text. DENY.
|
|
355
|
+
*
|
|
356
|
+
* The user typed something into the composer while a permission was open —
|
|
357
|
+
* very likely a new instruction, not consent. Treating "actually, do it a
|
|
358
|
+
* different way" as approval for the pending shell command would be the worst
|
|
359
|
+
* possible reading of it. The turn continues; the agent is told it was
|
|
360
|
+
* refused and why, and the user's words are carried through so the agent can
|
|
361
|
+
* act on them.
|
|
362
|
+
*/
|
|
363
|
+
return {
|
|
364
|
+
approved: false,
|
|
365
|
+
transcriptText: answer
|
|
366
|
+
? `Denied (no option chosen): ${answer}`
|
|
367
|
+
: 'Denied (no option chosen).'
|
|
368
|
+
};
|
|
369
|
+
}
|
|
370
|
+
class SessionRunner {
|
|
371
|
+
deps;
|
|
372
|
+
logger;
|
|
373
|
+
/**
|
|
374
|
+
* What the running turn needs in order to ANSWER a permission from outside
|
|
375
|
+
* the turn's own call stack.
|
|
376
|
+
*
|
|
377
|
+
* `run()` is a single long await; the answer arrives on a control-plane
|
|
378
|
+
* command handled by the harness, on a completely different stack. These
|
|
379
|
+
* three are what `answerPendingApproval` needs and cannot otherwise reach.
|
|
380
|
+
*/
|
|
381
|
+
turnContext = null;
|
|
382
|
+
/** The approval currently shown to the user, if any. */
|
|
383
|
+
openApproval = null;
|
|
384
|
+
/** The transcript identity of an ordinary agent question, if one is open. */
|
|
385
|
+
openQuestionPartId = null;
|
|
386
|
+
/**
|
|
387
|
+
* Expiry of the model token THIS RUNNING BACKEND is holding, when the
|
|
388
|
+
* control plane reported one — see `LeaseResponse.modelTokenExpiresAt`.
|
|
389
|
+
* Updated by `updateModelCredential` on a mid-turn refresh. `null` for a
|
|
390
|
+
* sandbox turn (its per-SANDBOX key has no per-turn expiry to track).
|
|
391
|
+
*/
|
|
392
|
+
modelTokenExpiresAt = null;
|
|
393
|
+
/**
|
|
394
|
+
* Written once per turn, the first time `modelTokenExpiresAt` is found to
|
|
395
|
+
* be in the past on a backend that cannot take a live credential update
|
|
396
|
+
* (`notifyConfigChanged` absent — currently Pi). Without this a self-hosted
|
|
397
|
+
* or desktop Pi turn that outlives its 1h key fails its NEXT model call
|
|
398
|
+
* with an ordinary-looking auth error and no indication why, which reads as
|
|
399
|
+
* a random provider outage rather than the known, disclosed limitation it
|
|
400
|
+
* is.
|
|
401
|
+
*/
|
|
402
|
+
modelKeyExpiryNoticeShown = false;
|
|
403
|
+
/** toolCallIds already turned into a question part, so it is emitted once. */
|
|
404
|
+
emittedApprovals = new Set();
|
|
405
|
+
/**
|
|
406
|
+
* Total wall-clock this turn has spent blocked on a human.
|
|
407
|
+
*
|
|
408
|
+
* ADDED BACK ONTO THE TURN DEADLINE. `maxTurnDurationMs` is 600 s and it
|
|
409
|
+
* exists to stop a runaway MODEL, not to put a stopwatch on the user: a
|
|
410
|
+
* person who takes eleven minutes to decide whether to allow a shell command
|
|
411
|
+
* is behaving normally, and killing their turn for it would make the approval
|
|
412
|
+
* feature useless for exactly the cautious user it is built for.
|
|
413
|
+
*/
|
|
414
|
+
approvalWaitMs = 0;
|
|
415
|
+
approvalWaitStartedAt = null;
|
|
416
|
+
constructor(deps) {
|
|
417
|
+
this.deps = deps;
|
|
418
|
+
this.logger = deps.logger ?? console;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* True while a permission prompt is outstanding. Read by the harness to
|
|
422
|
+
* decide how hard to poll the control plane for the answer.
|
|
423
|
+
*/
|
|
424
|
+
isAwaitingApproval() {
|
|
425
|
+
return this.openApproval !== null || this.openQuestionPartId !== null;
|
|
426
|
+
}
|
|
427
|
+
/**
|
|
428
|
+
* Run the leased turn to completion, or until `signal` aborts it.
|
|
429
|
+
*
|
|
430
|
+
* NEVER THROWS FOR AN AGENT-SIDE FAILURE. A backend that crashes, a model that
|
|
431
|
+
* errors, a tool that hangs — all of those are OUTCOMES of a turn and are
|
|
432
|
+
* reported in `TurnMetadata.stopReason`, because the control plane needs to
|
|
433
|
+
* record them and the user needs to see them. Only a failure to talk to the
|
|
434
|
+
* control plane at all propagates, since that is the one thing the runner
|
|
435
|
+
* cannot report through the control plane.
|
|
436
|
+
*/
|
|
437
|
+
async run(lease, signal) {
|
|
438
|
+
const { session } = lease;
|
|
439
|
+
const startedAt = new Date().toISOString();
|
|
440
|
+
const release = this.deps.notifier.keepAwake();
|
|
441
|
+
let workspaceDir = null;
|
|
442
|
+
let adapter = null;
|
|
443
|
+
let backendSessionId = null;
|
|
444
|
+
let secretToken = null;
|
|
445
|
+
let partsProduced = 0;
|
|
446
|
+
let stopReason = 'completed';
|
|
447
|
+
let error;
|
|
448
|
+
let repositoryStatuses = [];
|
|
449
|
+
/** True when the backend session was resumed rather than created. */
|
|
450
|
+
let resumed = false;
|
|
451
|
+
/** Part ids already ingested before this turn. See `drain` and the log. */
|
|
452
|
+
let priorPartIdsForLog = new Set();
|
|
453
|
+
/** True when history had to be replayed into a fresh backend session. */
|
|
454
|
+
let replayed = false;
|
|
455
|
+
/** This runner's own backend instance id — see `loadMemory`. */
|
|
456
|
+
let ownBackendInstanceId = null;
|
|
457
|
+
/**
|
|
458
|
+
* The user's message as the MODEL receives it: `input.content` plus, when
|
|
459
|
+
* the turn carries attachments, the note saying where they were written.
|
|
460
|
+
* Also what the conversation log records, so a later turn still knows the
|
|
461
|
+
* files exist.
|
|
462
|
+
*/
|
|
463
|
+
let turnContent = session.input.content;
|
|
464
|
+
this.turnParts = [];
|
|
465
|
+
this.modelTokenExpiresAt = lease.modelTokenExpiresAt ?? null;
|
|
466
|
+
this.modelKeyExpiryNoticeShown = false;
|
|
467
|
+
try {
|
|
468
|
+
const refusal = modelGatewayRefusal(lease);
|
|
469
|
+
if (refusal)
|
|
470
|
+
throw new Error(refusal);
|
|
471
|
+
try {
|
|
472
|
+
workspaceDir = await this.deps.workspace.prepare({
|
|
473
|
+
sessionId: session.sessionId,
|
|
474
|
+
mode: session.workspace.mode,
|
|
475
|
+
repositoryUrl: session.workspace.repositoryUrl,
|
|
476
|
+
ref: session.workspace.ref,
|
|
477
|
+
repositories: session.workspace.repositories,
|
|
478
|
+
requestedRepositories: session.workspace.requestedRepositories,
|
|
479
|
+
onRepositoryStatus: (status) => {
|
|
480
|
+
repositoryStatuses = [
|
|
481
|
+
...repositoryStatuses.filter((current) => current.fullName !== status.fullName),
|
|
482
|
+
status
|
|
483
|
+
];
|
|
484
|
+
},
|
|
485
|
+
refusalReason: session.workspace.refusalReason
|
|
486
|
+
});
|
|
487
|
+
const repositoryNames = session.workspace.requestedRepositories ??
|
|
488
|
+
(session.workspace.repositories ?? []).map(({ fullName }) => fullName);
|
|
489
|
+
for (const fullName of repositoryNames) {
|
|
490
|
+
if (!repositoryStatuses.some((current) => current.fullName === fullName)) {
|
|
491
|
+
repositoryStatuses.push({ fullName, status: 'checkedOut' });
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
catch (caught) {
|
|
496
|
+
const reason = caught instanceof Error ? caught.message : String(caught);
|
|
497
|
+
const status = session.workspace.refusalReason ? 'refused' : 'failed';
|
|
498
|
+
const names = session.workspace.requestedRepositories ??
|
|
499
|
+
(session.workspace.repositories ?? []).map(({ fullName }) => fullName);
|
|
500
|
+
for (const fullName of names) {
|
|
501
|
+
if (!repositoryStatuses.some((current) => current.fullName === fullName)) {
|
|
502
|
+
repositoryStatuses.push({ fullName, status, reason });
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
const failures = repositoryStatuses.filter((repository) => repository.status !== 'checkedOut');
|
|
506
|
+
throw new Error(failures.length > 0
|
|
507
|
+
? failures
|
|
508
|
+
.map((repository) => `Repository ${repository.fullName} was not checked out: ${repository.reason ?? reason}`)
|
|
509
|
+
.join('\n')
|
|
510
|
+
: `Repository workspace was not prepared: ${reason}`);
|
|
511
|
+
}
|
|
512
|
+
/*
|
|
513
|
+
* THE USER'S ATTACHMENTS, INTO THIS TURN'S WORKSPACE — every turn, since
|
|
514
|
+
* each lease carries its own turn's files and the session may have moved
|
|
515
|
+
* runner since the last one. Never fatal: a file that cannot be fetched
|
|
516
|
+
* is named in the note and the turn runs without it.
|
|
517
|
+
*/
|
|
518
|
+
const hasLeasedAttachments = (session.input.attachments?.length ?? 0) > 0;
|
|
519
|
+
/*
|
|
520
|
+
* ATTACHMENTS THE CONTROL PLANE ITSELF COULD NOT PREPARE — a
|
|
521
|
+
* `local://` URL it cannot fetch (local dev storage), or a signing
|
|
522
|
+
* failure. These never appear in `session.input.attachments` at all,
|
|
523
|
+
* so without this the block below would not even RUN when every
|
|
524
|
+
* attachment fell into this bucket: the turn would proceed as if
|
|
525
|
+
* nothing had ever been attached, silently, for both the model and
|
|
526
|
+
* (via the transcript note) the user. See `LeasedTurnInput
|
|
527
|
+
* .droppedAttachments`'s doc comment.
|
|
528
|
+
*/
|
|
529
|
+
const hasDroppedAttachments = (session.input.droppedAttachments?.length ?? 0) > 0;
|
|
530
|
+
if (workspaceDir && (hasLeasedAttachments || hasDroppedAttachments)) {
|
|
531
|
+
const sync = await (0, attachments_1.syncAttachmentsToWorkspace)(session.input.attachments, workspaceDir, {
|
|
532
|
+
fetchImpl: this.deps.fetchAttachment,
|
|
533
|
+
logger: this.logger,
|
|
534
|
+
// Namespaces this turn's filenames from every other turn's, so a
|
|
535
|
+
// later turn's same-named attachment cannot silently overwrite
|
|
536
|
+
// an earlier turn's file that an earlier point in the
|
|
537
|
+
// conversation log still refers to. See `syncAttachmentsToWorkspace`'s
|
|
538
|
+
// `turnId` doc comment for why re-leasing THIS turn stays idempotent.
|
|
539
|
+
turnId: session.turnId
|
|
540
|
+
});
|
|
541
|
+
/*
|
|
542
|
+
* MERGED INTO THE SAME `failed` LIST `syncAttachmentsToWorkspace`'s
|
|
543
|
+
* OWN download failures land in, so `attachmentPromptNote` reports
|
|
544
|
+
* both kinds identically — the model and the user should not have
|
|
545
|
+
* to care WHERE in the pipeline a file was lost. The name is
|
|
546
|
+
* sanitised again here: this list crossed the wire in the lease,
|
|
547
|
+
* same as `session.input.attachments` does, and is exactly as
|
|
548
|
+
* untrusted (see the module's own doc comment).
|
|
549
|
+
*/
|
|
550
|
+
if (session.input.droppedAttachments?.length) {
|
|
551
|
+
for (const dropped of session.input.droppedAttachments) {
|
|
552
|
+
sync.failed.push({
|
|
553
|
+
name: (0, attachments_1.sanitizeAttachmentName)(dropped?.name),
|
|
554
|
+
reason: String(dropped?.reason ?? 'could not be prepared')
|
|
555
|
+
});
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
const note = (0, attachments_1.attachmentPromptNote)(sync);
|
|
559
|
+
if (note)
|
|
560
|
+
turnContent = `${session.input.content}\n\n${note}`;
|
|
561
|
+
this.deps.telemetry.record('provider.turn.attachments', {
|
|
562
|
+
synced: sync.synced.length,
|
|
563
|
+
failed: sync.failed.length
|
|
564
|
+
});
|
|
565
|
+
}
|
|
566
|
+
// Point the shared holder at THIS turn's credential before the adapter
|
|
567
|
+
// builds its provider config. Revoked in `finally`, so the credential
|
|
568
|
+
// never outlives the turn it was minted for.
|
|
569
|
+
if (session.agent.model.gatewayUrl) {
|
|
570
|
+
/*
|
|
571
|
+
* REGISTER THE GATEWAY'S WHOLE CATALOGUE, as 20x does — not just the
|
|
572
|
+
* one model this turn happens to use.
|
|
573
|
+
*
|
|
574
|
+
* This used to build a provider containing a single model. The turn ran
|
|
575
|
+
* either way, but the backend's own provider list was then a lie:
|
|
576
|
+
* `config.providers()` is what an agent form reads to offer a choice,
|
|
577
|
+
* so a one-entry provider can never report what is actually reachable.
|
|
578
|
+
*
|
|
579
|
+
* The fetch is best-effort. A gateway that will not answer must not
|
|
580
|
+
* stop a turn whose model is already known, so we fall back to exactly
|
|
581
|
+
* the previous behaviour.
|
|
582
|
+
*/
|
|
583
|
+
const fetched = await (0, model_gateway_1.fetchGatewayModels)({
|
|
584
|
+
baseUrl: session.agent.model.gatewayUrl,
|
|
585
|
+
apiKey: lease.modelToken
|
|
586
|
+
});
|
|
587
|
+
const named = session.agent.model.model
|
|
588
|
+
? [
|
|
589
|
+
{
|
|
590
|
+
id: session.agent.model.model,
|
|
591
|
+
name: session.agent.model.model
|
|
592
|
+
}
|
|
593
|
+
]
|
|
594
|
+
: [];
|
|
595
|
+
/*
|
|
596
|
+
* The lease's model is ALWAYS included, even when the gateway does not
|
|
597
|
+
* advertise it. Measured on this deployment: the model that works
|
|
598
|
+
* (`vertex_ai/gemini-3-flash-preview`) is absent from `/models`, while
|
|
599
|
+
* one that is listed (`gpt-4.1`) is refused at call time. Trusting the
|
|
600
|
+
* catalogue to be complete would drop the working model out of the
|
|
601
|
+
* provider and break the turn.
|
|
602
|
+
*/
|
|
603
|
+
const merged = [...fetched];
|
|
604
|
+
for (const model of named) {
|
|
605
|
+
if (!merged.some((m) => m.id === model.id))
|
|
606
|
+
merged.push(model);
|
|
607
|
+
}
|
|
608
|
+
this.deps.modelGateway?.set({
|
|
609
|
+
apiKey: lease.modelToken,
|
|
610
|
+
baseUrl: session.agent.model.gatewayUrl,
|
|
611
|
+
models: merged
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
else {
|
|
615
|
+
// Null means "no gateway provider" — never "use whatever credentials
|
|
616
|
+
// are on this machine".
|
|
617
|
+
this.deps.modelGateway?.set(null);
|
|
618
|
+
}
|
|
619
|
+
adapter = this.deps.getAdapter(session.agent.backend);
|
|
620
|
+
await adapter.initialize();
|
|
621
|
+
secretToken = this.setUpSecrets(session);
|
|
622
|
+
const config = this.buildSessionConfig(session, workspaceDir, secretToken, signal);
|
|
623
|
+
/*
|
|
624
|
+
* REMEMBER THE CONVERSATION. This block is the fix for the defect that
|
|
625
|
+
* made this a chat product that could not hold a conversation: the
|
|
626
|
+
* previous code went straight to `createSession` here, every turn, and
|
|
627
|
+
* sent only `session.input.content` — so the model met the user for the
|
|
628
|
+
* first time on every message while the UI transcript looked complete.
|
|
629
|
+
*/
|
|
630
|
+
/*
|
|
631
|
+
* WHICH BACKEND INSTANCE THIS RUNNER IS. Optional-chained through a cast
|
|
632
|
+
* because it is a backend-specific affordance, not part of
|
|
633
|
+
* `CodingAgentAdapter`, and a backend
|
|
634
|
+
* that cannot answer keeps the previous behaviour.
|
|
635
|
+
*/
|
|
636
|
+
ownBackendInstanceId =
|
|
637
|
+
adapter.backendInstanceId?.() ?? null;
|
|
638
|
+
const memory = await this.loadMemory(session, ownBackendInstanceId);
|
|
639
|
+
/*
|
|
640
|
+
* What this runner has ALREADY ingested for this session.
|
|
641
|
+
*
|
|
642
|
+
* Read before the turn starts, from the durable projection rather than
|
|
643
|
+
* from anything held in memory, so it is correct after a restart too.
|
|
644
|
+
*/
|
|
645
|
+
const priorPartIds = new Set(this.deps.store.getParts(session.sessionId).map((part) => part.partId));
|
|
646
|
+
// PREFERRED: keep talking to the backend session that already holds the
|
|
647
|
+
// context. Nothing is reconstructed and no tokens are re-sent.
|
|
648
|
+
if (memory.backendSessionId) {
|
|
649
|
+
try {
|
|
650
|
+
/*
|
|
651
|
+
* `resumeSession` RETURNS THE SESSION'S HISTORY, and that return
|
|
652
|
+
* value is load-bearing rather than incidental.
|
|
653
|
+
*
|
|
654
|
+
* `pollMessages` is asked for "everything not in these sets". On a
|
|
655
|
+
* resumed session that is every message the conversation has ever
|
|
656
|
+
* had, so the parts already in the backend must be marked seen or the
|
|
657
|
+
* turn re-emits the whole conversation. Seeding from OUR OWN STORE is
|
|
658
|
+
* not enough: a cold runner resuming a still-live backend session has
|
|
659
|
+
* an empty store and a full backend, which is exactly the
|
|
660
|
+
* harness-restart case. The backend's own answer is the only source
|
|
661
|
+
* that is right in both.
|
|
662
|
+
*/
|
|
663
|
+
/*
|
|
664
|
+
* REFUSE TO RESUME WHEN THE PERMISSION MODE HAS CHANGED — this is
|
|
665
|
+
* what makes exit test 4 ("change the mode; the NEXT turn honours it
|
|
666
|
+
* with no session restart") actually true.
|
|
667
|
+
*
|
|
668
|
+
* PI BINDS THE PERMISSION EXTENSION TO THE PROCESS AT CREATION. A
|
|
669
|
+
* resumed native session must therefore be started with the current
|
|
670
|
+
* decision; a checkpoint made under a different decision is rebuilt
|
|
671
|
+
* from the runtime-neutral conversation log.
|
|
672
|
+
*
|
|
673
|
+
* THE CHECK LIVES HERE, NOT IN THE ADAPTER, because the runner is the
|
|
674
|
+
* only place that both owns the resume decision and can see a durable
|
|
675
|
+
* record. Two earlier attempts inside the adapter could never fire:
|
|
676
|
+
* the workspace is deleted and rebuilt every turn, and `getAdapter`
|
|
677
|
+
* constructs a fresh adapter per turn, so neither the config file nor
|
|
678
|
+
* the adapter's own map survives to be compared against.
|
|
679
|
+
*
|
|
680
|
+
* AN UNKNOWN PREVIOUS DECISION NOW FORCES A REBUILD, and an earlier
|
|
681
|
+
* version of this guard did the opposite. That was wrong, and review
|
|
682
|
+
* caught it: the decision is read from the LOCAL conversation store,
|
|
683
|
+
* so it is absent on a checkpoint written before this existed AND on
|
|
684
|
+
* ANY OTHER RUNNER — and a lease changing hands is described
|
|
685
|
+
* elsewhere in this file as an ordinary event, not an edge case. So
|
|
686
|
+
* "unknown skips the check" meant the guard was routinely bypassed
|
|
687
|
+
* exactly when a session had travelled, while the UI now presents the
|
|
688
|
+
* mode as enforced.
|
|
689
|
+
*
|
|
690
|
+
* It is also the wrong DIRECTION for this change. Everything else
|
|
691
|
+
* here fails closed — an unknown mode maps to `ask`, an unoffered
|
|
692
|
+
* option denies. Unknown must therefore mean "rebuild", not "trust
|
|
693
|
+
* the old session".
|
|
694
|
+
*
|
|
695
|
+
* THE COST, STATED: one rebuild and one replay on the first turn
|
|
696
|
+
* after an upgrade, and on the first turn after a lease moves to a
|
|
697
|
+
* different runner. Bounded, one-off per runner per session, and paid
|
|
698
|
+
* in the direction of enforcing the policy the user chose. The
|
|
699
|
+
* conversation is preserved either way — the fallback replays it.
|
|
700
|
+
*/
|
|
701
|
+
const wantedDecision = permissionFingerprint(session.agent.permissionMode);
|
|
702
|
+
if (memory.permissionDecision !== wantedDecision) {
|
|
703
|
+
throw new Error('permission mode changed or unrecorded ' +
|
|
704
|
+
`(${memory.permissionDecision ?? 'unrecorded'} -> ${wantedDecision}); ` +
|
|
705
|
+
'the backend session must be rebuilt so the new policy takes effect');
|
|
706
|
+
}
|
|
707
|
+
const history = await adapter.resumeSession(memory.backendSessionId, config);
|
|
708
|
+
for (const message of history ?? []) {
|
|
709
|
+
for (const part of message.parts ?? []) {
|
|
710
|
+
if (part?.id)
|
|
711
|
+
priorPartIds.add(part.id);
|
|
712
|
+
}
|
|
713
|
+
}
|
|
714
|
+
backendSessionId = memory.backendSessionId;
|
|
715
|
+
resumed = true;
|
|
716
|
+
this.deps.telemetry.record('provider.session.resumed', {
|
|
717
|
+
backend: session.agent.backend,
|
|
718
|
+
runtime: session.runtime
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
catch (caught) {
|
|
722
|
+
// AN ORDINARY OUTCOME, NOT AN ERROR. A swept sandbox, a redeployed
|
|
723
|
+
// runner or a restarted OpenCode all land here. Falling through to a
|
|
724
|
+
// fresh session with the log replayed is the whole point of keeping
|
|
725
|
+
// the log, so this is logged at warn and the turn continues.
|
|
726
|
+
this.logger.warn(`[SessionRunner] could not resume backend session ` +
|
|
727
|
+
`${memory.backendSessionId} for ${session.sessionId}: ` +
|
|
728
|
+
`${caught instanceof Error ? caught.message : String(caught)}. ` +
|
|
729
|
+
'Falling back to replay.');
|
|
730
|
+
this.deps.conversation?.setCheckpoint(session.sessionId, null, session.agent.backend, { backendOwnerId: ownBackendInstanceId });
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
if (!backendSessionId) {
|
|
734
|
+
backendSessionId = await adapter.createSession(config);
|
|
735
|
+
this.deps.telemetry.record('provider.session.started', {
|
|
736
|
+
backend: session.agent.backend,
|
|
737
|
+
runtime: session.runtime
|
|
738
|
+
});
|
|
739
|
+
}
|
|
740
|
+
// Record the checkpoint BEFORE the prompt runs. A turn that crashes
|
|
741
|
+
// half-way still leaves a resumable session behind, and the alternative
|
|
742
|
+
// — recording it only on success — loses the context in exactly the case
|
|
743
|
+
// where the user most wants it back.
|
|
744
|
+
this.deps.conversation?.setCheckpoint(session.sessionId, backendSessionId, session.agent.backend, {
|
|
745
|
+
// WHICH INSTANCE minted it, so only this runner resumes it (#1591).
|
|
746
|
+
backendOwnerId: ownBackendInstanceId,
|
|
747
|
+
// The decision this backend session was CREATED under. The next turn
|
|
748
|
+
// compares against it to decide whether the session can be reused
|
|
749
|
+
// (#1602). Independent of the owner; both are recorded.
|
|
750
|
+
permissionDecision: permissionFingerprint(session.agent.permissionMode)
|
|
751
|
+
});
|
|
752
|
+
/*
|
|
753
|
+
* FALLBACK: a fresh backend session knows nothing, so it has to be told.
|
|
754
|
+
* When the session was RESUMED this is skipped entirely and the prompt is
|
|
755
|
+
* byte-for-byte what it always was — a resumed backend already holds the
|
|
756
|
+
* history, and replaying it as well would both duplicate it and pay for
|
|
757
|
+
* it twice.
|
|
758
|
+
*/
|
|
759
|
+
const promptText = resumed
|
|
760
|
+
? turnContent
|
|
761
|
+
: (0, conversation_1.buildReplayPrompt)(memory.messages.map((message) => ({
|
|
762
|
+
role: message.role,
|
|
763
|
+
content: message.content
|
|
764
|
+
})), turnContent);
|
|
765
|
+
// There was no per-turn host-note mechanism in the harness. Keep the
|
|
766
|
+
// addition small: a workspace may supply one note, which is sent to the
|
|
767
|
+
// model but is not written into the durable user conversation.
|
|
768
|
+
const turnNote = this.deps.workspace.takeTurnNote?.(session.sessionId);
|
|
769
|
+
const promptWithHostNote = turnNote
|
|
770
|
+
? `${turnNote}\n\n${promptText}`
|
|
771
|
+
: promptText;
|
|
772
|
+
replayed = !resumed && memory.messages.length > 0;
|
|
773
|
+
priorPartIdsForLog = priorPartIds;
|
|
774
|
+
await adapter.sendPrompt(backendSessionId, [{ type: 'text', text: promptWithHostNote, role: 'user' }], config);
|
|
775
|
+
this.deps.telemetry.record('provider.turn.sent', {
|
|
776
|
+
backend: session.agent.backend
|
|
777
|
+
});
|
|
778
|
+
/*
|
|
779
|
+
* WIRE 3 NEEDS A WAY IN. `run` is one long await, and the user's answer
|
|
780
|
+
* arrives on a control-plane command that the harness handles on a
|
|
781
|
+
* completely different stack. This is what `answerPendingApproval`
|
|
782
|
+
* reaches through. Cleared in the `finally` below, so an answer can never
|
|
783
|
+
* be applied to a turn that has already ended.
|
|
784
|
+
*/
|
|
785
|
+
this.turnContext = {
|
|
786
|
+
sessionId: session.sessionId,
|
|
787
|
+
backendSessionId,
|
|
788
|
+
adapter,
|
|
789
|
+
config
|
|
790
|
+
};
|
|
791
|
+
const drained = await this.drain(adapter, backendSessionId, config, session, signal,
|
|
792
|
+
// RESUMING REPLAYS THE WHOLE BACKEND SESSION. `pollMessages` is asked
|
|
793
|
+
// for "everything not in these sets", and on a resumed session that is
|
|
794
|
+
// every message the conversation has ever had — so without seeding, a
|
|
795
|
+
// second turn re-emits turn one. The transcript upsert is idempotent so
|
|
796
|
+
// the UI survives it, but the MODEL LOG would not: each turn would
|
|
797
|
+
// absorb every previous turn's text and the stored conversation would
|
|
798
|
+
// grow quadratically until it filled the context window.
|
|
799
|
+
resumed ? priorPartIds : new Set(),
|
|
800
|
+
// THE PROMPT THIS RUNNER JUST SENT. Drain uses it to recognise the
|
|
801
|
+
// backend's own echo of it — see `drain`.
|
|
802
|
+
promptWithHostNote);
|
|
803
|
+
partsProduced = drained.parts;
|
|
804
|
+
if (signal.aborted) {
|
|
805
|
+
stopReason = 'aborted';
|
|
806
|
+
}
|
|
807
|
+
else if (drained.idleTimedOut) {
|
|
808
|
+
// THE PRIMARY GUARD. No progress at all for `maxTurnIdleMs` — the
|
|
809
|
+
// backend is hung, not merely slow. A healthy turn that keeps
|
|
810
|
+
// producing parts never reaches this no matter how long it runs.
|
|
811
|
+
stopReason = 'no_progress';
|
|
812
|
+
error = drained.idleTimedOutMessage ?? 'Turn stopped: no progress';
|
|
813
|
+
partsProduced += this.record(session.sessionId, [
|
|
814
|
+
{
|
|
815
|
+
id: `turn-idle-${(0, node_crypto_1.randomUUID)()}`,
|
|
816
|
+
role: 'system',
|
|
817
|
+
partType: 'error',
|
|
818
|
+
content: error
|
|
819
|
+
}
|
|
820
|
+
]);
|
|
821
|
+
}
|
|
822
|
+
else if (drained.timedOut) {
|
|
823
|
+
// THE LAST-LINE CEILING, and only reached when an operator has
|
|
824
|
+
// actually configured `CODING_AGENT_MAX_TURN_MINUTES` — the default
|
|
825
|
+
// is no ceiling at all, enforced by the idle guard above instead.
|
|
826
|
+
stopReason = 'turn_time_limit';
|
|
827
|
+
error = 'Turn stopped: turn_time_limit';
|
|
828
|
+
this.logger.warn(`[SessionRunner] turn-hard-ceiling session=${session.sessionId} ` +
|
|
829
|
+
`maxTurnDurationMs=${session.limits.maxTurnDurationMs}`);
|
|
830
|
+
partsProduced += this.record(session.sessionId, [
|
|
831
|
+
{
|
|
832
|
+
id: `turn-limit-${(0, node_crypto_1.randomUUID)()}`,
|
|
833
|
+
role: 'system',
|
|
834
|
+
partType: 'error',
|
|
835
|
+
content: error
|
|
836
|
+
}
|
|
837
|
+
]);
|
|
838
|
+
}
|
|
839
|
+
else if (drained.providerError) {
|
|
840
|
+
// A provider failure is a FAILED turn, never a completed one. Recorded
|
|
841
|
+
// on the turn so the control plane and the UI can both see it, and
|
|
842
|
+
// written into the transcript so the user is told rather than left
|
|
843
|
+
// looking at a conversation that silently stopped answering.
|
|
844
|
+
stopReason = 'error';
|
|
845
|
+
error = drained.providerError;
|
|
846
|
+
this.logger.error(`[SessionRunner] session ${session.sessionId} provider error: ${error}`);
|
|
847
|
+
partsProduced += this.record(session.sessionId, [
|
|
848
|
+
{
|
|
849
|
+
id: `error-${(0, node_crypto_1.randomUUID)()}`,
|
|
850
|
+
role: 'system',
|
|
851
|
+
content: describeProviderError(error ?? String(drained.providerError), qualifiedModelId(session.agent.model)),
|
|
852
|
+
partType: 'error'
|
|
853
|
+
}
|
|
854
|
+
]);
|
|
855
|
+
}
|
|
856
|
+
}
|
|
857
|
+
catch (caught) {
|
|
858
|
+
stopReason = signal.aborted ? 'aborted' : 'error';
|
|
859
|
+
error = caught instanceof Error ? caught.message : String(caught);
|
|
860
|
+
this.logger.error(`[SessionRunner] session ${session.sessionId} failed: ${error}`);
|
|
861
|
+
// The failure IS the transcript's last word. Recording it as a part is
|
|
862
|
+
// what makes an error visible to the user instead of a session that
|
|
863
|
+
// simply stops producing output and never says why.
|
|
864
|
+
partsProduced += this.record(session.sessionId, [
|
|
865
|
+
{
|
|
866
|
+
id: `error-${(0, node_crypto_1.randomUUID)()}`,
|
|
867
|
+
role: 'system',
|
|
868
|
+
partType: 'error',
|
|
869
|
+
content: error
|
|
870
|
+
}
|
|
871
|
+
]);
|
|
872
|
+
}
|
|
873
|
+
finally {
|
|
874
|
+
release();
|
|
875
|
+
this.deps.modelGateway?.revoke();
|
|
876
|
+
/*
|
|
877
|
+
* THE TURN CAN NO LONGER TAKE AN ANSWER.
|
|
878
|
+
*
|
|
879
|
+
* Cleared before anything else in teardown. `answerPendingApproval`
|
|
880
|
+
* checks `turnContext` first, so once this is null a late answer — one
|
|
881
|
+
* that raced the turn ending, or that arrived after an abort — is
|
|
882
|
+
* refused instead of being handed to an adapter whose backend session is
|
|
883
|
+
* on its way out. `closeApproval` also releases the harness from its
|
|
884
|
+
* tightened polling, which would otherwise keep running until the next
|
|
885
|
+
* turn happened to reset it.
|
|
886
|
+
*/
|
|
887
|
+
this.turnContext = null;
|
|
888
|
+
this.closeApproval(session.sessionId);
|
|
889
|
+
this.closeQuestion(session.sessionId);
|
|
890
|
+
this.emittedApprovals.clear();
|
|
891
|
+
/*
|
|
892
|
+
* STOP THE PER-TURN PI PROCESS BEFORE THE NEXT LEASE CAN START.
|
|
893
|
+
*
|
|
894
|
+
* Pi receives the virtual gateway key and MCP proxy URLs in its child
|
|
895
|
+
* environment. The adapter therefore owns one process per turn: no
|
|
896
|
+
* server-wide config push exists, and a later turn cannot inherit an old
|
|
897
|
+
* key or old MCP token from a warm process.
|
|
898
|
+
*
|
|
899
|
+
* THE CONVERSATION SURVIVES. Pi persists the native session file under
|
|
900
|
+
* its durable agent directory. The next turn starts a fresh process with
|
|
901
|
+
* the new credentials and resumes that file only when backend-owner and
|
|
902
|
+
* permission-decision checks match; otherwise it replays the neutral log.
|
|
903
|
+
*/
|
|
904
|
+
try {
|
|
905
|
+
await adapter?.stopServer?.();
|
|
906
|
+
}
|
|
907
|
+
catch {
|
|
908
|
+
// Never let teardown mask the turn's real outcome.
|
|
909
|
+
}
|
|
910
|
+
// Teardown is best-effort and ORDERED: revoke the secret grant first, so
|
|
911
|
+
// a backend that is slow to die cannot use it during the wait.
|
|
912
|
+
if (secretToken) {
|
|
913
|
+
try {
|
|
914
|
+
this.deps.secrets.unregisterSession(secretToken);
|
|
915
|
+
}
|
|
916
|
+
catch {
|
|
917
|
+
// Never let teardown mask the turn's real outcome.
|
|
918
|
+
}
|
|
919
|
+
}
|
|
920
|
+
/*
|
|
921
|
+
* THE BACKEND SESSION IS DELIBERATELY *NOT* DESTROYED HERE.
|
|
922
|
+
*
|
|
923
|
+
* It used to be, on every turn, and that single call was the defect: the
|
|
924
|
+
* session holds the conversation's context, so tearing it down at the end
|
|
925
|
+
* of each turn is what made the model start blank on the next one. It is
|
|
926
|
+
* kept alive as the resume checkpoint instead.
|
|
927
|
+
*
|
|
928
|
+
* WHAT THIS COSTS, SAID PLAINLY. The backend accumulates one live session
|
|
929
|
+
* per conversation for the life of the process, along with the adapter's
|
|
930
|
+
* per-session client maps. That is bounded by concurrent conversations
|
|
931
|
+
* rather than by turns, and it is how 20x has always behaved. On the
|
|
932
|
+
* sandbox runtime the sweep destroys the whole machine when the session
|
|
933
|
+
* ends, so nothing outlives it. Reclaiming a backend session on an
|
|
934
|
+
* explicit session-end signal is a real follow-up and is recorded as one;
|
|
935
|
+
* a leak measured in conversations is a far smaller problem than an agent
|
|
936
|
+
* that cannot remember the previous sentence.
|
|
937
|
+
*/
|
|
938
|
+
}
|
|
939
|
+
const turn = {
|
|
940
|
+
turnId: session.turnId,
|
|
941
|
+
startedAt,
|
|
942
|
+
finishedAt: new Date().toISOString(),
|
|
943
|
+
model: session.agent.model.model,
|
|
944
|
+
stopReason,
|
|
945
|
+
error,
|
|
946
|
+
repositories: repositoryStatuses.length > 0 ? repositoryStatuses : undefined,
|
|
947
|
+
// The checkpoint travels on the final ingest because the final ingest is
|
|
948
|
+
// the ONE message guaranteed to be sent — it is what releases the lease,
|
|
949
|
+
// and it is sent even when the turn failed.
|
|
950
|
+
backendSessionId,
|
|
951
|
+
backendName: backendSessionId ? session.agent.backend : undefined,
|
|
952
|
+
/*
|
|
953
|
+
* Reported with the handle, never separately. A handle whose owner is
|
|
954
|
+
* unknown is exactly the handle that caused this defect, so the control
|
|
955
|
+
* plane must never be able to hold one without the other.
|
|
956
|
+
*/
|
|
957
|
+
backendOwnerId: backendSessionId && ownBackendInstanceId
|
|
958
|
+
? ownBackendInstanceId
|
|
959
|
+
: undefined
|
|
960
|
+
};
|
|
961
|
+
/*
|
|
962
|
+
* WHAT THE MODEL WAS TOLD, AND WHAT IT SAID — recorded for the next turn.
|
|
963
|
+
*
|
|
964
|
+
* `session.input.content` and NOT the replayed prompt: the log is the
|
|
965
|
+
* conversation, and the replay preamble is scaffolding built FROM the log.
|
|
966
|
+
* Writing the preamble back in would nest the conversation inside itself,
|
|
967
|
+
* and it would do so once per turn, so the prompt would grow
|
|
968
|
+
* quadratically until the context window ended the session.
|
|
969
|
+
*/
|
|
970
|
+
/*
|
|
971
|
+
* ONLY WHAT THIS TURN SAID.
|
|
972
|
+
*
|
|
973
|
+
* Belt and braces behind the `seenPartIds` seeding above. Both guard the
|
|
974
|
+
* same failure — a resumed session re-emitting its whole history into the
|
|
975
|
+
* model log — and they fail independently: the seeding depends on the
|
|
976
|
+
* adapter honouring `seenPartIds`, this depends only on what is already in
|
|
977
|
+
* our own store. The consequence of getting it wrong is not a wrong answer
|
|
978
|
+
* but a conversation that silently doubles in size every turn, which is the
|
|
979
|
+
* kind of defect that looks fine for a week.
|
|
980
|
+
*/
|
|
981
|
+
const spoken = (0, conversation_1.assistantTextFrom)(this.turnParts.filter((part) => !priorPartIdsForLog.has(part.partId)));
|
|
982
|
+
const modelMessages = [
|
|
983
|
+
{ role: 'user', content: turnContent },
|
|
984
|
+
...(spoken ? [{ role: 'assistant', content: spoken }] : [])
|
|
985
|
+
];
|
|
986
|
+
// Store locally FIRST, for the same reason parts are: the local copy is
|
|
987
|
+
// what lets this runner answer the next turn without a network call, and a
|
|
988
|
+
// send that happens first would be lost on a crash between the two.
|
|
989
|
+
try {
|
|
990
|
+
this.deps.conversation?.appendModelMessages(session.sessionId, modelMessages);
|
|
991
|
+
}
|
|
992
|
+
catch (caught) {
|
|
993
|
+
// A cache that cannot be written is a slow next turn, not a failed one.
|
|
994
|
+
this.logger.warn(`[SessionRunner] could not cache model messages for ` +
|
|
995
|
+
`${session.sessionId}: ` +
|
|
996
|
+
`${caught instanceof Error ? caught.message : String(caught)}`);
|
|
997
|
+
}
|
|
998
|
+
// The FINAL ingest carries the turn metadata and releases the lease. It is
|
|
999
|
+
// sent even when the turn failed: a lease that is never released is a
|
|
1000
|
+
// session that hangs until the watchdog reaps it, and the user waits.
|
|
1001
|
+
/*
|
|
1002
|
+
* THE FINAL FLUSH MUST CARRY ANY PARTS STILL PENDING, not an empty array.
|
|
1003
|
+
*
|
|
1004
|
+
* `this.pending` is otherwise drained only INSIDE `drain()`, so anything
|
|
1005
|
+
* recorded after it returns never reached the control plane. That silently
|
|
1006
|
+
* broke the error path: the catch block below records the failure as a
|
|
1007
|
+
* transcript part and its comment calls that "the transcript's last word",
|
|
1008
|
+
* but the part went into the local store and no further — the user was left
|
|
1009
|
+
* with a conversation that stopped answering and never said why.
|
|
1010
|
+
*
|
|
1011
|
+
* Sending `[]` here was safe only for as long as nothing was recorded after
|
|
1012
|
+
* the drain. That has not been true since the error path was added.
|
|
1013
|
+
*/
|
|
1014
|
+
await this.flush(session.sessionId, this.pending.splice(0), turn, true, modelMessages);
|
|
1015
|
+
this.deps.telemetry.record('provider.turn.memory', {
|
|
1016
|
+
resumed,
|
|
1017
|
+
replayed,
|
|
1018
|
+
priorMessages: this.priorMessageCount
|
|
1019
|
+
});
|
|
1020
|
+
this.deps.telemetry.record('provider.session.stopped', {
|
|
1021
|
+
backend: session.agent.backend,
|
|
1022
|
+
stopReason
|
|
1023
|
+
});
|
|
1024
|
+
return {
|
|
1025
|
+
turn,
|
|
1026
|
+
maxRev: this.deps.store.getMaxRev(session.sessionId),
|
|
1027
|
+
partsProduced
|
|
1028
|
+
};
|
|
1029
|
+
}
|
|
1030
|
+
/* ---------------------------------------------------------------- */
|
|
1031
|
+
/* The poll loop */
|
|
1032
|
+
/* ---------------------------------------------------------------- */
|
|
1033
|
+
/**
|
|
1034
|
+
* Does this polled part merely mirror the prompt the runner itself sent?
|
|
1035
|
+
*
|
|
1036
|
+
* Matched on TEXT, not on id, because the echo's id belongs to the backend
|
|
1037
|
+
* (`user-message-3`, `user-173…`, `<uuid>-text`) and no runner can predict
|
|
1038
|
+
* it. Matched as a PREFIX of the sent prompt, not equality, because ACP
|
|
1039
|
+
* agents deliver the echo as `user_message_chunk` updates whose accumulated
|
|
1040
|
+
* text grows to the full prompt over several polls — dropping only the final
|
|
1041
|
+
* equal one would strand the intermediate versions in the store.
|
|
1042
|
+
*
|
|
1043
|
+
* Both sides are trimmed first: a backend that strips a trailing newline
|
|
1044
|
+
* from the echoed text must not slip past the prefix check on that alone.
|
|
1045
|
+
*/
|
|
1046
|
+
isEchoOfTurnPrompt(part, sentPromptText) {
|
|
1047
|
+
if (typeof part.role !== 'string' || part.role.toLowerCase() !== 'user') {
|
|
1048
|
+
return false;
|
|
1049
|
+
}
|
|
1050
|
+
const text = part.content ?? part.text;
|
|
1051
|
+
if (typeof text !== 'string')
|
|
1052
|
+
return false;
|
|
1053
|
+
const echoed = text.trim();
|
|
1054
|
+
if (echoed.length === 0)
|
|
1055
|
+
return false;
|
|
1056
|
+
return sentPromptText.trim().startsWith(echoed);
|
|
1057
|
+
}
|
|
1058
|
+
/**
|
|
1059
|
+
* Poll the adapter until the turn goes quiet or the signal aborts.
|
|
1060
|
+
*
|
|
1061
|
+
* The adapter's `onDataAvailable` callback wakes this immediately when the
|
|
1062
|
+
* backend has buffered output, so the 2s interval is a FLOOR for liveness
|
|
1063
|
+
* rather than the streaming latency. Without it every token would arrive up
|
|
1064
|
+
* to two seconds late and the UI would look broken.
|
|
1065
|
+
*/
|
|
1066
|
+
/**
|
|
1067
|
+
* Pump the backend until it goes quiet, returning what was produced AND any
|
|
1068
|
+
* provider error the backend reported.
|
|
1069
|
+
*
|
|
1070
|
+
* THE ERROR HALF OF THE RETURN IS THE POINT. `getStatus` reports a provider
|
|
1071
|
+
* failure as `type: 'error'` with a message, and this loop used to fold that
|
|
1072
|
+
* into `quiet` alongside `idle` — it stopped draining, which is correct, and
|
|
1073
|
+
* then threw the message away, which is not. The turn went on to be recorded
|
|
1074
|
+
* as `stopReason: 'completed'` with `error: null`.
|
|
1075
|
+
*
|
|
1076
|
+
* That is not theoretical. On 2026-08-22 a gateway stopped serving the
|
|
1077
|
+
* configured model; five consecutive turns logged
|
|
1078
|
+
* `Provider error … Invalid model name passed in model=gpt-4.1`, produced no
|
|
1079
|
+
* assistant output at all, and were all stored as SUCCESSFUL turns. The UI
|
|
1080
|
+
* faithfully showed five user messages and no replies, the runner showed
|
|
1081
|
+
* `ready`, and nothing anywhere said the model call had failed.
|
|
1082
|
+
*/
|
|
1083
|
+
async drain(adapter, backendSessionId, config, session, signal, alreadySeenPartIds = new Set(),
|
|
1084
|
+
/** The exact text `sendPrompt` was given this turn, for echo filtering. */
|
|
1085
|
+
sentPromptText = null) {
|
|
1086
|
+
let providerError;
|
|
1087
|
+
let timedOut = false;
|
|
1088
|
+
let idleTimedOut = false;
|
|
1089
|
+
let idleTimedOutMessage;
|
|
1090
|
+
const seenMessageIds = new Set();
|
|
1091
|
+
const seenPartIds = new Set(alreadySeenPartIds);
|
|
1092
|
+
const partContentLengths = new Map();
|
|
1093
|
+
let produced = 0;
|
|
1094
|
+
let idleSeen = 0;
|
|
1095
|
+
let wake = null;
|
|
1096
|
+
adapter.onDataAvailable = () => {
|
|
1097
|
+
wake?.();
|
|
1098
|
+
};
|
|
1099
|
+
/*
|
|
1100
|
+
* THE LAST-LINE CEILING. `null` means no operator-configured ceiling — see
|
|
1101
|
+
* `LeasedLimits.maxTurnDurationMs`. `<= 0` is treated THE SAME WAY, not as
|
|
1102
|
+
* "expire immediately": the control plane already normalises an explicit
|
|
1103
|
+
* `0` env value to `null` before it ever reaches the wire (config.ts
|
|
1104
|
+
* `maxTurnDurationMsFromEnv`), so a real lease never carries a zero or
|
|
1105
|
+
* negative number on purpose. Treating one as "no ceiling" here — rather
|
|
1106
|
+
* than as an instantly-past deadline — is what keeps this side of the
|
|
1107
|
+
* wire consistent with that normalisation, and is also just the safer
|
|
1108
|
+
* reading of a value nothing should ever legitimately send.
|
|
1109
|
+
*/
|
|
1110
|
+
const hardCeilingMs = session.limits.maxTurnDurationMs;
|
|
1111
|
+
const hardDeadline = hardCeilingMs === null ||
|
|
1112
|
+
hardCeilingMs === undefined ||
|
|
1113
|
+
hardCeilingMs <= 0
|
|
1114
|
+
? null
|
|
1115
|
+
: Date.now() + hardCeilingMs;
|
|
1116
|
+
const softWarningAt = hardDeadline === null
|
|
1117
|
+
? null
|
|
1118
|
+
: Date.now() + hardCeilingMs * HARD_CEILING_WARNING_FRACTION;
|
|
1119
|
+
let softWarningSent = false;
|
|
1120
|
+
/*
|
|
1121
|
+
* THE PRIMARY GUARD. Reset on every part, tool-call start/end or streamed
|
|
1122
|
+
* text — anything `pollMessages` returns as a new part. `lastProgressAt`
|
|
1123
|
+
* plus the approval total captured AT that moment is enough to compute,
|
|
1124
|
+
* later, exactly how much of the time since then was genuine silence
|
|
1125
|
+
* versus a human being waited on — see the idle check below.
|
|
1126
|
+
*/
|
|
1127
|
+
const idleLimitMs = session.limits.maxTurnIdleMs ?? DEFAULT_MAX_TURN_IDLE_MS;
|
|
1128
|
+
let lastProgressAt = Date.now();
|
|
1129
|
+
let approvalWaitAtLastProgress = this.approvalWaitMs;
|
|
1130
|
+
try {
|
|
1131
|
+
while (!signal.aborted) {
|
|
1132
|
+
const now = Date.now();
|
|
1133
|
+
// Historical approval wait PLUS whatever approval is open right now.
|
|
1134
|
+
// Added to both the hard deadline and, below, the idle window — a
|
|
1135
|
+
// turn stopped to ask a person something is blocked on them, not
|
|
1136
|
+
// running away, and ten minutes is an entirely ordinary time for a
|
|
1137
|
+
// person to be away from their desk.
|
|
1138
|
+
const pausedMs = this.approvalWaitMs + this.liveApprovalWaitMs();
|
|
1139
|
+
if (hardDeadline !== null && now > hardDeadline + pausedMs) {
|
|
1140
|
+
timedOut = true;
|
|
1141
|
+
break;
|
|
1142
|
+
}
|
|
1143
|
+
/*
|
|
1144
|
+
* THE SOFT WARNING, once, at 90% of a CONFIGURED ceiling. Written to
|
|
1145
|
+
* the transcript unconditionally; also offered to the backend as a
|
|
1146
|
+
* mid-turn nudge when the adapter can take one (`steerTurn`) — so the
|
|
1147
|
+
* turn has a chance to end with a summary instead of a cut.
|
|
1148
|
+
*/
|
|
1149
|
+
if (softWarningAt !== null &&
|
|
1150
|
+
!softWarningSent &&
|
|
1151
|
+
now > softWarningAt + pausedMs) {
|
|
1152
|
+
softWarningSent = true;
|
|
1153
|
+
const remainingMs = Math.max(0, hardDeadline + pausedMs - now);
|
|
1154
|
+
produced += await this.warnOfHardCeiling(adapter, backendSessionId, config, session, remainingMs);
|
|
1155
|
+
}
|
|
1156
|
+
/*
|
|
1157
|
+
* THE PI MODEL-KEY EXPIRY NOTICE — a disclosed gap, not a silent
|
|
1158
|
+
* failure. `updateModelCredential` can push a refreshed token into a
|
|
1159
|
+
* backend that implements `notifyConfigChanged` (OpenCode); one that
|
|
1160
|
+
* does not (currently Pi, whose credential is fixed in its child
|
|
1161
|
+
* process's environment at spawn) keeps running on whatever key it
|
|
1162
|
+
* started with even after the control plane has moved on. Once that
|
|
1163
|
+
* key's known expiry passes, this backend's NEXT model call is about
|
|
1164
|
+
* to fail with an ordinary-looking auth error that reads as a random
|
|
1165
|
+
* provider outage. Surfaced here instead, once, with the real reason.
|
|
1166
|
+
* Never fires for a sandbox turn: its per-SANDBOX key carries no
|
|
1167
|
+
* `modelTokenExpiresAt` to begin with (see `buildLeaseBody`).
|
|
1168
|
+
*/
|
|
1169
|
+
if (!this.modelKeyExpiryNoticeShown &&
|
|
1170
|
+
this.modelTokenExpiresAt &&
|
|
1171
|
+
typeof adapter.notifyConfigChanged !== 'function' &&
|
|
1172
|
+
now > Date.parse(this.modelTokenExpiresAt)) {
|
|
1173
|
+
this.modelKeyExpiryNoticeShown = true;
|
|
1174
|
+
this.logger.warn(`[SessionRunner] model-key-expired-mid-turn session=${session.sessionId} ` +
|
|
1175
|
+
`backend=${session.agent.backend} expiredAt=${this.modelTokenExpiresAt}`);
|
|
1176
|
+
produced += this.record(session.sessionId, [
|
|
1177
|
+
{
|
|
1178
|
+
id: `model-key-expired-${(0, node_crypto_1.randomUUID)()}`,
|
|
1179
|
+
role: 'system',
|
|
1180
|
+
partType: 'error',
|
|
1181
|
+
content: 'Model credential expired after 1 hour on this runner; send a new message to continue.'
|
|
1182
|
+
}
|
|
1183
|
+
]);
|
|
1184
|
+
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
1185
|
+
}
|
|
1186
|
+
const parts = (await adapter.pollMessages(backendSessionId, seenMessageIds, seenPartIds, partContentLengths, config));
|
|
1187
|
+
/*
|
|
1188
|
+
* THE PROMPT'S OWN ECHO IS NOT CONVERSATION.
|
|
1189
|
+
*
|
|
1190
|
+
* The user's message is written into the transcript by the control
|
|
1191
|
+
* plane when the turn is enqueued (`enqueueTurn` →
|
|
1192
|
+
* `recordUserTurnPart`), so it is durable before this runner is even
|
|
1193
|
+
* aware of the turn. Several backends ALSO echo the prompt back
|
|
1194
|
+
* through `pollMessages` — Codex pushes a synthetic user message into
|
|
1195
|
+
* its buffer, ACP agents stream `user_message` chunks, the Claude Code
|
|
1196
|
+
* SDK re-emits streamed input — and recording that echo would put the
|
|
1197
|
+
* same words in the transcript TWICE under two part ids, which no
|
|
1198
|
+
* idempotency key can merge.
|
|
1199
|
+
*
|
|
1200
|
+
* So anything a backend emits as a user part whose text is the prompt
|
|
1201
|
+
* this runner just handed it — or a streaming prefix of it — is
|
|
1202
|
+
* dropped. This is safe because every user word reaches the backend
|
|
1203
|
+
* through `sendPrompt`: mid-turn there is no OTHER user text it could
|
|
1204
|
+
* be. Answers to questions travel a different wire (`respondTo*`) and
|
|
1205
|
+
* are recorded by the answer path itself, with the `answer-…` part id.
|
|
1206
|
+
*/
|
|
1207
|
+
const freshParts = sentPromptText && sentPromptText.length > 0
|
|
1208
|
+
? parts.filter((part) => !this.isEchoOfTurnPrompt(part, sentPromptText))
|
|
1209
|
+
: parts;
|
|
1210
|
+
/*
|
|
1211
|
+
* WIRE 2: A PENDING PERMISSION BECOMES A TRANSCRIPT PART.
|
|
1212
|
+
*
|
|
1213
|
+
* Checked BEFORE the produced-parts branch `continue`s, or an approval
|
|
1214
|
+
* raised in the same pass as some output would not be seen until the
|
|
1215
|
+
* backend next went quiet — which, while it waits for the very approval
|
|
1216
|
+
* we failed to show, is never.
|
|
1217
|
+
*/
|
|
1218
|
+
produced += await this.emitPendingApproval(adapter, backendSessionId, session);
|
|
1219
|
+
if (freshParts.length > 0) {
|
|
1220
|
+
produced += this.record(session.sessionId, freshParts);
|
|
1221
|
+
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
1222
|
+
// Real output resets the quiet counter. A backend is idle BETWEEN
|
|
1223
|
+
// tool calls as well as at the end of a turn, so ending on the first
|
|
1224
|
+
// idle would truncate almost every transcript at the first tool.
|
|
1225
|
+
idleSeen = 0;
|
|
1226
|
+
// ...and resets the PRIMARY guard's clock, snapshotting the
|
|
1227
|
+
// approval total so a later stretch of silence is measured from
|
|
1228
|
+
// here, not double-counting wait time already excluded once.
|
|
1229
|
+
lastProgressAt = Date.now();
|
|
1230
|
+
approvalWaitAtLastProgress = this.approvalWaitMs;
|
|
1231
|
+
continue;
|
|
1232
|
+
}
|
|
1233
|
+
const status = await adapter.getStatus(backendSessionId, config);
|
|
1234
|
+
if (status.type === 'error' && status.message) {
|
|
1235
|
+
// Last one wins: a retry that also fails should report its own reason.
|
|
1236
|
+
providerError = status.message;
|
|
1237
|
+
}
|
|
1238
|
+
const quiet = status.type === 'idle' || status.type === 'error';
|
|
1239
|
+
idleSeen = quiet ? idleSeen + 1 : 0;
|
|
1240
|
+
if (idleSeen >= IDLE_CONFIRMATIONS) {
|
|
1241
|
+
break;
|
|
1242
|
+
}
|
|
1243
|
+
/*
|
|
1244
|
+
* THE IDLE CHECK ITSELF. Wall-clock time since the last real progress,
|
|
1245
|
+
* minus whatever of that stretch was spent waiting on a human — the
|
|
1246
|
+
* approval accrued SINCE `lastProgressAt`, not the turn's whole
|
|
1247
|
+
* history, or a long-closed approval from earlier in the turn would be
|
|
1248
|
+
* subtracted a second time and silence that started long after it
|
|
1249
|
+
* closed would be under-counted.
|
|
1250
|
+
*
|
|
1251
|
+
* SKIPPED OUTRIGHT WHILE AN APPROVAL IS OPEN RIGHT NOW, rather than
|
|
1252
|
+
* trusted to the arithmetic above — the race that arithmetic cannot
|
|
1253
|
+
* close on its own: `emitPendingApproval` can OPEN a brand-new
|
|
1254
|
+
* approval in THIS SAME ITERATION, moments before this check runs,
|
|
1255
|
+
* so `liveApprovalWaitMs()` is still ~0 and barely dents an
|
|
1256
|
+
* `idleElapsed` that had already been accumulating stale silence for
|
|
1257
|
+
* up to `idleLimitMs`. Without this guard a question could be posted
|
|
1258
|
+
* and the turn cut as `no_progress` in the same tick, before the
|
|
1259
|
+
* person it was raised for ever sees it.
|
|
1260
|
+
*
|
|
1261
|
+
* TODO(approval TTL): an approval left open suppresses BOTH this
|
|
1262
|
+
* guard and the hard ceiling for as long as it stays open — by
|
|
1263
|
+
* design, since a slow human must never read as a hung backend, but
|
|
1264
|
+
* that means an approval nobody ever answers can hold a turn open
|
|
1265
|
+
* indefinitely today. Pre-existing behaviour (unrelated to the LONG
|
|
1266
|
+
* TURNS fix), not changed here; a bounded approval wait — after
|
|
1267
|
+
* which the turn ends on its own rather than waiting forever — is a
|
|
1268
|
+
* real follow-up, not implemented in this PR.
|
|
1269
|
+
*/
|
|
1270
|
+
const approvalWaitSinceProgress = this.approvalWaitMs -
|
|
1271
|
+
approvalWaitAtLastProgress +
|
|
1272
|
+
this.liveApprovalWaitMs();
|
|
1273
|
+
const idleElapsed = Date.now() - lastProgressAt - approvalWaitSinceProgress;
|
|
1274
|
+
if (this.approvalWaitStartedAt === null && idleElapsed > idleLimitMs) {
|
|
1275
|
+
this.logger.warn(`[SessionRunner] session ${session.sessionId} had no progress for ` +
|
|
1276
|
+
`${Math.round(idleElapsed / 1000)}s (limit ${Math.round(idleLimitMs / 1000)}s)`);
|
|
1277
|
+
idleTimedOut = true;
|
|
1278
|
+
idleTimedOutMessage = `Turn stopped: no progress for ${formatMinutes(idleLimitMs)}`;
|
|
1279
|
+
break;
|
|
1280
|
+
}
|
|
1281
|
+
// Wait for whichever comes first: the poll interval, the backend
|
|
1282
|
+
// pushing (`wake`), or an abort. `finish` is idempotent because all
|
|
1283
|
+
// three can fire, and a second `resolveWait` on a settled promise
|
|
1284
|
+
// would otherwise leak the timer and the abort listener for the rest
|
|
1285
|
+
// of the turn.
|
|
1286
|
+
await new Promise((resolveWait) => {
|
|
1287
|
+
let settled = false;
|
|
1288
|
+
const timer = { handle: null };
|
|
1289
|
+
const finish = () => {
|
|
1290
|
+
if (settled) {
|
|
1291
|
+
return;
|
|
1292
|
+
}
|
|
1293
|
+
settled = true;
|
|
1294
|
+
if (timer.handle)
|
|
1295
|
+
clearTimeout(timer.handle);
|
|
1296
|
+
signal.removeEventListener('abort', finish);
|
|
1297
|
+
wake = null;
|
|
1298
|
+
resolveWait();
|
|
1299
|
+
};
|
|
1300
|
+
timer.handle = setTimeout(finish, POLL_INTERVAL_MS);
|
|
1301
|
+
wake = finish;
|
|
1302
|
+
signal.addEventListener('abort', finish, { once: true });
|
|
1303
|
+
});
|
|
1304
|
+
}
|
|
1305
|
+
}
|
|
1306
|
+
finally {
|
|
1307
|
+
adapter.onDataAvailable = undefined;
|
|
1308
|
+
// A part produced between the last flush and the loop ending must not be
|
|
1309
|
+
// lost; the final ingest below only carries turn metadata.
|
|
1310
|
+
if (this.pending.length > 0) {
|
|
1311
|
+
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
1312
|
+
}
|
|
1313
|
+
}
|
|
1314
|
+
if (signal.aborted || timedOut || idleTimedOut) {
|
|
1315
|
+
try {
|
|
1316
|
+
await adapter.abortPrompt(backendSessionId, config);
|
|
1317
|
+
}
|
|
1318
|
+
catch {
|
|
1319
|
+
// An abort that cannot be delivered still ends the turn here.
|
|
1320
|
+
}
|
|
1321
|
+
}
|
|
1322
|
+
return {
|
|
1323
|
+
parts: produced,
|
|
1324
|
+
providerError,
|
|
1325
|
+
timedOut,
|
|
1326
|
+
idleTimedOut,
|
|
1327
|
+
idleTimedOutMessage
|
|
1328
|
+
};
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* THE SOFT WARNING, fired once at 90% of a CONFIGURED hard ceiling.
|
|
1332
|
+
*
|
|
1333
|
+
* Always written into the transcript so the user sees the countdown even
|
|
1334
|
+
* when the backend cannot take a mid-turn nudge. `steerTurn` is optional on
|
|
1335
|
+
* `CodingAgentAdapter` for exactly that reason: Pi can deliver a message to
|
|
1336
|
+
* a running turn (`streamingBehavior: 'steer'`); OpenCode's `sendPrompt` is
|
|
1337
|
+
* a single long-lived HTTP call per session and calling it again while one
|
|
1338
|
+
* is already in flight is not safe, so it does not implement this and the
|
|
1339
|
+
* warning stays transcript-only there.
|
|
1340
|
+
*/
|
|
1341
|
+
async warnOfHardCeiling(adapter, backendSessionId, config, session, remainingMs) {
|
|
1342
|
+
const message = `You have ${formatMinutes(remainingMs)} left in this turn. ` +
|
|
1343
|
+
'Commit your work, write a short status, and stop.';
|
|
1344
|
+
// THE RETURN VALUE MATTERS. This part must count toward `produced` in
|
|
1345
|
+
// `drain()`'s caller, the same as every other part recorded there —
|
|
1346
|
+
// otherwise the turn's reported `partsProduced` silently undercounts by
|
|
1347
|
+
// one on every turn a configured ceiling's 90% mark actually fires.
|
|
1348
|
+
const produced = this.record(session.sessionId, [
|
|
1349
|
+
{
|
|
1350
|
+
id: `turn-warning-${(0, node_crypto_1.randomUUID)()}`,
|
|
1351
|
+
role: 'system',
|
|
1352
|
+
partType: 'text',
|
|
1353
|
+
content: message
|
|
1354
|
+
}
|
|
1355
|
+
]);
|
|
1356
|
+
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
1357
|
+
const steerTurn = adapter.steerTurn;
|
|
1358
|
+
if (!steerTurn) {
|
|
1359
|
+
this.logger.warn(`[SessionRunner] session ${session.sessionId}: backend '${session.agent.backend}' ` +
|
|
1360
|
+
'cannot take a mid-turn message; the ceiling warning is transcript-only.');
|
|
1361
|
+
return produced;
|
|
1362
|
+
}
|
|
1363
|
+
try {
|
|
1364
|
+
await steerTurn.call(adapter, backendSessionId, message, config);
|
|
1365
|
+
}
|
|
1366
|
+
catch (caught) {
|
|
1367
|
+
this.logger.warn(`[SessionRunner] session ${session.sessionId}: could not deliver the ` +
|
|
1368
|
+
`ceiling warning to the backend: ${caught instanceof Error ? caught.message : String(caught)}`);
|
|
1369
|
+
}
|
|
1370
|
+
return produced;
|
|
1371
|
+
}
|
|
1372
|
+
/* ---------------------------------------------------------------- */
|
|
1373
|
+
/* PERMISSION APPROVALS — wires 2 and 3 of defect G1 */
|
|
1374
|
+
/* ---------------------------------------------------------------- */
|
|
1375
|
+
/** Wall-clock spent in the approval we are in RIGHT NOW, if any. */
|
|
1376
|
+
liveApprovalWaitMs() {
|
|
1377
|
+
return this.approvalWaitStartedAt === null
|
|
1378
|
+
? 0
|
|
1379
|
+
: Date.now() - this.approvalWaitStartedAt;
|
|
1380
|
+
}
|
|
1381
|
+
/**
|
|
1382
|
+
* WIRE 2. Turn a pending backend permission into a `question` transcript part.
|
|
1383
|
+
*
|
|
1384
|
+
* The whole channel below this already existed and was simply never joined
|
|
1385
|
+
* up. The OpenCode adapter queues `permission.asked` events, reports
|
|
1386
|
+
* `WAITING_APPROVAL` from `getStatus` while any are outstanding, and exposes
|
|
1387
|
+
* `getPendingApproval`. The control plane already accepts an answer and
|
|
1388
|
+
* queues it as a session command. The UI already renders a `question` part,
|
|
1389
|
+
* already shows its options as buttons, and already posts `optionId` back.
|
|
1390
|
+
* The only thing missing was somebody asking the adapter.
|
|
1391
|
+
*
|
|
1392
|
+
* THIS IS DELIBERATELY THE SAME SHAPE 20x EMITS (agent-manager.ts:2342-2367),
|
|
1393
|
+
* because the two products render the same transcript and a second shape
|
|
1394
|
+
* would mean a second renderer. ONE DIFFERENCE, ON PURPOSE: 20x builds its
|
|
1395
|
+
* options as `{ label }` and throws `optionId` away, which forces its IPC
|
|
1396
|
+
* layer to recover the choice by matching a display label against a hardcoded
|
|
1397
|
+
* map of English strings (agent-manager.ts:4510-4516). We keep the id. Labels
|
|
1398
|
+
* are ambiguous, repeatable between questions and translatable; ids are none
|
|
1399
|
+
* of those things.
|
|
1400
|
+
*
|
|
1401
|
+
* `getPendingApproval` is DUCK-TYPED rather than declared on
|
|
1402
|
+
* `CodingAgentAdapter` — again matching 20x — so a backend with no permission
|
|
1403
|
+
* concept needs no stub and is simply never asked.
|
|
1404
|
+
*/
|
|
1405
|
+
async emitPendingApproval(adapter, backendSessionId, session) {
|
|
1406
|
+
const capable = adapter;
|
|
1407
|
+
if (typeof capable.getPendingApproval !== 'function') {
|
|
1408
|
+
return 0;
|
|
1409
|
+
}
|
|
1410
|
+
let approval;
|
|
1411
|
+
try {
|
|
1412
|
+
approval = capable.getPendingApproval(backendSessionId);
|
|
1413
|
+
}
|
|
1414
|
+
catch (error) {
|
|
1415
|
+
// An adapter that throws here must not take the turn down with it: the
|
|
1416
|
+
// turn is still running and its output is still worth collecting.
|
|
1417
|
+
this.logger.warn(`[SessionRunner] getPendingApproval threw for session ${session.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1418
|
+
return 0;
|
|
1419
|
+
}
|
|
1420
|
+
if (!approval) {
|
|
1421
|
+
// The backend has stopped waiting. That happens on our own answer, and
|
|
1422
|
+
// ALSO when the backend resolves the permission itself — a timeout, an
|
|
1423
|
+
// abort, an auto-approve. Either way the turn is no longer blocked on a
|
|
1424
|
+
// human, so the clock starts again and the harness may relax its polling.
|
|
1425
|
+
this.closeApproval(session.sessionId);
|
|
1426
|
+
return 0;
|
|
1427
|
+
}
|
|
1428
|
+
// Already shown. Do not re-emit: an upsert on the same partId is harmless
|
|
1429
|
+
// to the transcript but would bump `rev` on every 2s pass, and the UI polls
|
|
1430
|
+
// on the cursor — the conversation would flicker for as long as the user
|
|
1431
|
+
// took to decide.
|
|
1432
|
+
if (this.emittedApprovals.has(approval.toolCallId)) {
|
|
1433
|
+
return 0;
|
|
1434
|
+
}
|
|
1435
|
+
this.emittedApprovals.add(approval.toolCallId);
|
|
1436
|
+
const partId = `question-${approval.toolCallId}`;
|
|
1437
|
+
const optionIdByLabel = new Map();
|
|
1438
|
+
const knownOptionIds = new Set();
|
|
1439
|
+
for (const option of approval.options) {
|
|
1440
|
+
optionIdByLabel.set(option.name.trim().toLowerCase(), option.optionId);
|
|
1441
|
+
knownOptionIds.add(option.optionId);
|
|
1442
|
+
}
|
|
1443
|
+
this.openApproval = {
|
|
1444
|
+
partId,
|
|
1445
|
+
toolCallId: approval.toolCallId,
|
|
1446
|
+
optionIdByLabel,
|
|
1447
|
+
knownOptionIds
|
|
1448
|
+
};
|
|
1449
|
+
if (this.approvalWaitStartedAt === null) {
|
|
1450
|
+
this.approvalWaitStartedAt = Date.now();
|
|
1451
|
+
}
|
|
1452
|
+
this.deps.onApprovalStateChange?.(session.sessionId, true);
|
|
1453
|
+
this.logger.log(`[SessionRunner] session ${session.sessionId} is waiting for approval: ${approval.question}`);
|
|
1454
|
+
const produced = this.record(session.sessionId, [
|
|
1455
|
+
{
|
|
1456
|
+
id: partId,
|
|
1457
|
+
role: 'assistant',
|
|
1458
|
+
partType: 'question',
|
|
1459
|
+
content: approval.question,
|
|
1460
|
+
tool: {
|
|
1461
|
+
name: 'permission',
|
|
1462
|
+
status: 'running',
|
|
1463
|
+
title: approval.question
|
|
1464
|
+
},
|
|
1465
|
+
questions: [
|
|
1466
|
+
{
|
|
1467
|
+
header: 'Permission Required',
|
|
1468
|
+
question: approval.question,
|
|
1469
|
+
options: approval.options.map((option) => ({
|
|
1470
|
+
id: option.optionId,
|
|
1471
|
+
label: option.name
|
|
1472
|
+
}))
|
|
1473
|
+
}
|
|
1474
|
+
]
|
|
1475
|
+
}
|
|
1476
|
+
]);
|
|
1477
|
+
/*
|
|
1478
|
+
* FLUSHED HERE, NOT LEFT FOR THE NEXT PASS.
|
|
1479
|
+
*
|
|
1480
|
+
* Every other part in this loop is flushed by the `parts.length > 0`
|
|
1481
|
+
* branch. A question raised on a pass that produced no other output would
|
|
1482
|
+
* otherwise sit in `this.pending` until the NEXT flush — and the next flush
|
|
1483
|
+
* comes when the backend produces something, which it cannot do, because it
|
|
1484
|
+
* is blocked on the question we did not send. The turn would hang with an
|
|
1485
|
+
* empty transcript and no prompt: the exact "silently hangs" failure the
|
|
1486
|
+
* ordering warning in this work's brief is about.
|
|
1487
|
+
*/
|
|
1488
|
+
await this.flush(session.sessionId, this.pending.splice(0), undefined, false);
|
|
1489
|
+
return produced;
|
|
1490
|
+
}
|
|
1491
|
+
/**
|
|
1492
|
+
* Tell the USER that their answer could not be delivered.
|
|
1493
|
+
*
|
|
1494
|
+
* Without this, a failed delivery is a server-side log line and nothing else:
|
|
1495
|
+
* the question card stays open, the turn keeps waiting (approval time is
|
|
1496
|
+
* deliberately excluded from the turn deadline), and the person who clicked
|
|
1497
|
+
* Allow has no way to tell their click from a slow backend. An error part is
|
|
1498
|
+
* the only channel that reaches them.
|
|
1499
|
+
*
|
|
1500
|
+
* The approval is deliberately left OPEN — the backend never heard the
|
|
1501
|
+
* answer, so the prompt is still genuinely outstanding and answering again is
|
|
1502
|
+
* the right thing to do.
|
|
1503
|
+
*/
|
|
1504
|
+
async reportApprovalFailure(sessionId, reason) {
|
|
1505
|
+
if (!this.turnContext || this.turnContext.sessionId !== sessionId) {
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
this.record(sessionId, [
|
|
1509
|
+
{
|
|
1510
|
+
id: `approval-error-${(0, node_crypto_1.randomUUID)()}`,
|
|
1511
|
+
role: 'system',
|
|
1512
|
+
partType: 'error',
|
|
1513
|
+
content: 'Your answer could not be delivered to the agent, so the request is ' +
|
|
1514
|
+
`still waiting. Please answer it again. (${reason})`
|
|
1515
|
+
}
|
|
1516
|
+
]);
|
|
1517
|
+
await this.flush(sessionId, this.pending.splice(0), undefined, false);
|
|
1518
|
+
}
|
|
1519
|
+
/**
|
|
1520
|
+
* MID-TURN MODEL-CREDENTIAL REFRESH — the LONG TURNS audit's answer to a
|
|
1521
|
+
* fixed per-turn LiteLLM key TTL. Called from the harness's lease-renewal
|
|
1522
|
+
* callback when the control plane re-mints the token near its old expiry.
|
|
1523
|
+
*
|
|
1524
|
+
* Updates the shared gateway holder unconditionally (so the NEXT model call
|
|
1525
|
+
* on this turn already uses it), then asks the adapter to push it to a
|
|
1526
|
+
* running backend that can take a live config update. `notifyConfigChanged`
|
|
1527
|
+
* is optional on `CodingAgentAdapter` for exactly this reason: OpenCode
|
|
1528
|
+
* implements it (a shared server, config pushed over HTTP); Pi does not —
|
|
1529
|
+
* its credential is fixed in the child process's environment at spawn, so a
|
|
1530
|
+
* refresh here updates the holder for the SAME session's next turn (a fresh
|
|
1531
|
+
* Pi process) but not the one currently running. That is a real, disclosed
|
|
1532
|
+
* gap, not a silent one: Pi turns still rely on the 1h key outlasting the
|
|
1533
|
+
* turn, same as before this method existed — `expiresAt` is recorded
|
|
1534
|
+
* regardless, so `drain()`'s expiry notice still fires on the correct
|
|
1535
|
+
* (refreshed) deadline rather than the original, already-superseded one.
|
|
1536
|
+
*/
|
|
1537
|
+
updateModelCredential(sessionId, apiKey, expiresAt) {
|
|
1538
|
+
if (!this.turnContext || this.turnContext.sessionId !== sessionId) {
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
if (expiresAt) {
|
|
1542
|
+
this.modelTokenExpiresAt = expiresAt;
|
|
1543
|
+
this.modelKeyExpiryNoticeShown = false;
|
|
1544
|
+
}
|
|
1545
|
+
this.deps.modelGateway?.updateApiKey(apiKey);
|
|
1546
|
+
try {
|
|
1547
|
+
void this.turnContext.adapter.notifyConfigChanged?.();
|
|
1548
|
+
}
|
|
1549
|
+
catch {
|
|
1550
|
+
// A backend that cannot take the push keeps running on its old
|
|
1551
|
+
// credential until it, too, expires — not ideal, but no worse than
|
|
1552
|
+
// before this method existed, and never worth ending the turn over.
|
|
1553
|
+
}
|
|
1554
|
+
}
|
|
1555
|
+
/** Stop the approval clock and tell the harness it may relax. */
|
|
1556
|
+
closeApproval(sessionId) {
|
|
1557
|
+
if (this.approvalWaitStartedAt !== null) {
|
|
1558
|
+
this.approvalWaitMs += Date.now() - this.approvalWaitStartedAt;
|
|
1559
|
+
this.approvalWaitStartedAt = null;
|
|
1560
|
+
}
|
|
1561
|
+
if (this.openApproval) {
|
|
1562
|
+
this.openApproval = null;
|
|
1563
|
+
this.deps.onApprovalStateChange?.(sessionId, false);
|
|
1564
|
+
}
|
|
1565
|
+
}
|
|
1566
|
+
closeQuestion(sessionId) {
|
|
1567
|
+
if (this.openQuestionPartId) {
|
|
1568
|
+
this.openQuestionPartId = null;
|
|
1569
|
+
this.deps.onApprovalStateChange?.(sessionId, false);
|
|
1570
|
+
}
|
|
1571
|
+
}
|
|
1572
|
+
/**
|
|
1573
|
+
* WIRE 3. Apply an `answer` session command to the running turn.
|
|
1574
|
+
*
|
|
1575
|
+
* Before this existed, `harness.applyCommand` marked an `answer` as applied
|
|
1576
|
+
* and DROPPED IT. That is what made the permission mode unshippable rather
|
|
1577
|
+
* than merely unenforced: turning the mode on without this would have turned
|
|
1578
|
+
* "silently unrestricted" into "silently hangs on every turn", and because
|
|
1579
|
+
* the default agent stores `permissionMode: 'default'`, it would have hit
|
|
1580
|
+
* every session at once.
|
|
1581
|
+
*
|
|
1582
|
+
* Returns true when the answer reached the backend.
|
|
1583
|
+
*/
|
|
1584
|
+
async answerPendingApproval(command) {
|
|
1585
|
+
const context = this.turnContext;
|
|
1586
|
+
if (!context || context.sessionId !== command.sessionId) {
|
|
1587
|
+
return 'ignored';
|
|
1588
|
+
}
|
|
1589
|
+
const open = this.openApproval;
|
|
1590
|
+
if (!open) {
|
|
1591
|
+
/*
|
|
1592
|
+
* A NORMAL AGENT QUESTION, NOT A PERMISSION PROMPT.
|
|
1593
|
+
*
|
|
1594
|
+
* Both kinds are rendered as `partType: question` and travel back over
|
|
1595
|
+
* the same `answer` command, but only permission prompts populate
|
|
1596
|
+
* `openApproval`. The old code returned `ignored` here, even though every
|
|
1597
|
+
* extracted adapter already exposes `respondToQuestion`. Consequently a
|
|
1598
|
+
* click on an ordinary question reached the runner and vanished.
|
|
1599
|
+
*/
|
|
1600
|
+
const capable = context.adapter;
|
|
1601
|
+
if (!this.openQuestionPartId) {
|
|
1602
|
+
this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} arrived with no open question; ignoring`);
|
|
1603
|
+
return 'ignored';
|
|
1604
|
+
}
|
|
1605
|
+
if (command.partId && command.partId !== this.openQuestionPartId) {
|
|
1606
|
+
this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} names part ${command.partId}, but ${this.openQuestionPartId} is open; ignoring`);
|
|
1607
|
+
return 'ignored';
|
|
1608
|
+
}
|
|
1609
|
+
if (typeof capable.respondToQuestion !== 'function') {
|
|
1610
|
+
this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} arrived with no open approval and this backend cannot answer ordinary questions; ignoring`);
|
|
1611
|
+
return 'ignored';
|
|
1612
|
+
}
|
|
1613
|
+
const answers = decodeQuestionAnswers(command.answer);
|
|
1614
|
+
if (Object.keys(answers).length === 0) {
|
|
1615
|
+
this.logger.warn(`[SessionRunner] empty answer for ordinary question on session ${command.sessionId}; ignoring`);
|
|
1616
|
+
return 'ignored';
|
|
1617
|
+
}
|
|
1618
|
+
try {
|
|
1619
|
+
const delivered = await capable.respondToQuestion(context.backendSessionId, answers, context.config, command.partId ?? this.openQuestionPartId);
|
|
1620
|
+
if (delivered === false ||
|
|
1621
|
+
(typeof delivered === 'object' && delivered.handled === false)) {
|
|
1622
|
+
this.logger.error(`[SessionRunner] backend reported it did not accept the question answer for session ${command.sessionId}`);
|
|
1623
|
+
return 'failed';
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
catch (error) {
|
|
1627
|
+
this.logger.error(`[SessionRunner] failed to deliver question answer for session ${command.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1628
|
+
return 'failed';
|
|
1629
|
+
}
|
|
1630
|
+
// This user part closes the question card and restores the ordinary
|
|
1631
|
+
// composer. Without it every later message is posted as another answer.
|
|
1632
|
+
this.record(context.sessionId, [
|
|
1633
|
+
{
|
|
1634
|
+
id: `answer-${command.partId ?? (0, node_crypto_1.randomUUID)()}`,
|
|
1635
|
+
role: 'user',
|
|
1636
|
+
partType: 'text',
|
|
1637
|
+
content: command.answer ?? Object.values(answers).join('\n')
|
|
1638
|
+
}
|
|
1639
|
+
]);
|
|
1640
|
+
await this.flush(context.sessionId, this.pending.splice(0), undefined, false);
|
|
1641
|
+
this.closeQuestion(context.sessionId);
|
|
1642
|
+
return 'delivered';
|
|
1643
|
+
}
|
|
1644
|
+
/*
|
|
1645
|
+
* THE ANSWER MUST NAME THE QUESTION IT IS ANSWERING.
|
|
1646
|
+
*
|
|
1647
|
+
* The UI posts back the `partId` of the card the user clicked. If that is
|
|
1648
|
+
* not the prompt currently outstanding, the user answered a DIFFERENT
|
|
1649
|
+
* question — a stale tab, a double click, a card still on screen after the
|
|
1650
|
+
* backend moved on. Applying it would attach a human's "yes" to a tool call
|
|
1651
|
+
* they never read. `partId` is optional in the contract, so an answer
|
|
1652
|
+
* without one is accepted; an answer with the WRONG one never is.
|
|
1653
|
+
*/
|
|
1654
|
+
if (command.partId && command.partId !== open.partId) {
|
|
1655
|
+
this.logger.warn(`[SessionRunner] answer for session ${command.sessionId} names part ${command.partId}, but ${open.partId} is open; ignoring`);
|
|
1656
|
+
return 'ignored';
|
|
1657
|
+
}
|
|
1658
|
+
const decision = resolveApproval(command, open);
|
|
1659
|
+
const capable = context.adapter;
|
|
1660
|
+
if (typeof capable.respondToApproval !== 'function') {
|
|
1661
|
+
this.logger.warn(`[SessionRunner] backend for session ${command.sessionId} cannot take an approval response`);
|
|
1662
|
+
return 'ignored';
|
|
1663
|
+
}
|
|
1664
|
+
this.logger.log(`[SessionRunner] answering approval on session ${command.sessionId}: approved=${decision.approved} optionId=${decision.optionId ?? '(none)'}`);
|
|
1665
|
+
try {
|
|
1666
|
+
/*
|
|
1667
|
+
* THE RETURN VALUE IS LOAD-BEARING. An adapter reports a failed delivery
|
|
1668
|
+
* by RESOLVING FALSE as well as by throwing — OpenCode's HTTP relay does
|
|
1669
|
+
* exactly that on a non-OK status. Ignoring it meant a failed delivery was
|
|
1670
|
+
* recorded as 'delivered': the card closed, the transcript said
|
|
1671
|
+
* "Approved", and the backend had never heard the answer.
|
|
1672
|
+
*/
|
|
1673
|
+
const delivered = await capable.respondToApproval(context.backendSessionId, decision.approved, decision.optionId);
|
|
1674
|
+
if (delivered === false) {
|
|
1675
|
+
this.logger.error(`[SessionRunner] backend reported it did not accept the approval for session ${command.sessionId}`);
|
|
1676
|
+
return 'failed';
|
|
1677
|
+
}
|
|
1678
|
+
}
|
|
1679
|
+
catch (error) {
|
|
1680
|
+
/*
|
|
1681
|
+
* 'failed', NOT 'ignored'. THIS RETURN IS THE WHOLE POINT OF THE TYPE.
|
|
1682
|
+
*
|
|
1683
|
+
* It used to return `false`, the same value a stale or duplicate answer
|
|
1684
|
+
* returns — so the harness could not tell a lost approval from a no-op,
|
|
1685
|
+
* and its recovery path (forget the commandId, tell the user) was
|
|
1686
|
+
* UNREACHABLE CODE. The comment there described a failure mode the code
|
|
1687
|
+
* could not actually handle.
|
|
1688
|
+
*
|
|
1689
|
+
* The approval is deliberately left OPEN: the backend never heard the
|
|
1690
|
+
* answer, so the prompt is genuinely still outstanding.
|
|
1691
|
+
*/
|
|
1692
|
+
this.logger.error(`[SessionRunner] failed to deliver approval for session ${command.sessionId}: ${error instanceof Error ? error.message : String(error)}`);
|
|
1693
|
+
return 'failed';
|
|
1694
|
+
}
|
|
1695
|
+
/*
|
|
1696
|
+
* THE ANSWER GOES INTO THE TRANSCRIPT, AS A USER PART.
|
|
1697
|
+
*
|
|
1698
|
+
* Two reasons, and the second is not cosmetic.
|
|
1699
|
+
*
|
|
1700
|
+
* 1. The exit test asks for "the request, the answer and the result". This
|
|
1701
|
+
* is the answer.
|
|
1702
|
+
* 2. `selectOpenQuestion` in the UI decides a question is still open by
|
|
1703
|
+
* looking for ANY user part after it (projection.ts:605-608). Without a
|
|
1704
|
+
* user part here the card would stay open and interactive for ever, and
|
|
1705
|
+
* the composer would keep posting answers instead of messages — so the
|
|
1706
|
+
* user could never send another message in that session again.
|
|
1707
|
+
*/
|
|
1708
|
+
this.record(context.sessionId, [
|
|
1709
|
+
{
|
|
1710
|
+
id: `answer-${open.toolCallId}`,
|
|
1711
|
+
role: 'user',
|
|
1712
|
+
partType: 'text',
|
|
1713
|
+
content: decision.transcriptText
|
|
1714
|
+
}
|
|
1715
|
+
]);
|
|
1716
|
+
await this.flush(context.sessionId, this.pending.splice(0), undefined, false);
|
|
1717
|
+
this.closeApproval(context.sessionId);
|
|
1718
|
+
return 'delivered';
|
|
1719
|
+
}
|
|
1720
|
+
/* ---------------------------------------------------------------- */
|
|
1721
|
+
/* Store, then send */
|
|
1722
|
+
/* ---------------------------------------------------------------- */
|
|
1723
|
+
pending = [];
|
|
1724
|
+
/**
|
|
1725
|
+
* Everything this turn projected, in order.
|
|
1726
|
+
*
|
|
1727
|
+
* Reset at the start of every `run`. Used only to derive the assistant half
|
|
1728
|
+
* of the model-facing log; the transcript itself is already stored by then.
|
|
1729
|
+
*/
|
|
1730
|
+
turnParts = [];
|
|
1731
|
+
/**
|
|
1732
|
+
* STORE FIRST, SEND SECOND, and never the other way round.
|
|
1733
|
+
*
|
|
1734
|
+
* The local store is what lets a harness survive its own crash; if a part
|
|
1735
|
+
* were sent before it was stored, a crash between the two would leave the
|
|
1736
|
+
* control plane holding a part the runner does not know it produced, and a
|
|
1737
|
+
* resumed turn would produce it again with a NEW id — a visible duplicate in
|
|
1738
|
+
* the user's transcript. Storing first makes the send idempotent, because the
|
|
1739
|
+
* upsert is keyed on (sessionId, partId).
|
|
1740
|
+
*/
|
|
1741
|
+
record(sessionId, raw) {
|
|
1742
|
+
for (const part of raw) {
|
|
1743
|
+
const partType = part?.partType ?? part?.type;
|
|
1744
|
+
if (part?.id &&
|
|
1745
|
+
partType === 'question' &&
|
|
1746
|
+
part.id !== this.openApproval?.partId &&
|
|
1747
|
+
this.openQuestionPartId !== part.id) {
|
|
1748
|
+
this.openQuestionPartId = part.id;
|
|
1749
|
+
this.deps.onApprovalStateChange?.(sessionId, true);
|
|
1750
|
+
}
|
|
1751
|
+
}
|
|
1752
|
+
const projected = (0, projection_1.projectParts)(raw);
|
|
1753
|
+
if (projected.length === 0) {
|
|
1754
|
+
return 0;
|
|
1755
|
+
}
|
|
1756
|
+
const { maxRev } = this.deps.store.upsertParts(sessionId, projected);
|
|
1757
|
+
this.deps.events.transcriptChanged(sessionId, maxRev);
|
|
1758
|
+
// Kept so the turn can build its MODEL-facing log from what was actually
|
|
1759
|
+
// produced. `this.turnParts` is reset per turn by `run`.
|
|
1760
|
+
for (const part of projected) {
|
|
1761
|
+
this.turnParts.push({
|
|
1762
|
+
partId: part.partId,
|
|
1763
|
+
role: part.role,
|
|
1764
|
+
partType: part.partType,
|
|
1765
|
+
content: part.content
|
|
1766
|
+
});
|
|
1767
|
+
}
|
|
1768
|
+
for (const part of projected) {
|
|
1769
|
+
this.pending.push((0, projection_1.toIngestPart)(part));
|
|
1770
|
+
}
|
|
1771
|
+
return projected.length;
|
|
1772
|
+
}
|
|
1773
|
+
/**
|
|
1774
|
+
* Push a batch to the control plane, chunked to the contract's ceiling.
|
|
1775
|
+
*
|
|
1776
|
+
* The ceiling is enforced HERE rather than discovered as an opaque rejection
|
|
1777
|
+
* mid-turn. `MAX_INGEST_PARTS` is shared with the control plane precisely so
|
|
1778
|
+
* both sides agree on it; a limit only one side knows is not a limit.
|
|
1779
|
+
*/
|
|
1780
|
+
async flush(sessionId, parts, turn, final, modelMessages) {
|
|
1781
|
+
if (parts.length === 0 && !final && turn === undefined) {
|
|
1782
|
+
return;
|
|
1783
|
+
}
|
|
1784
|
+
// Oversized parts are dropped with a note rather than sent to be refused.
|
|
1785
|
+
// A single enormous tool result must not take the rest of the batch down.
|
|
1786
|
+
const sendable = parts.filter((part) => {
|
|
1787
|
+
const size = (0, contract_1.ingestPartByteLength)(part);
|
|
1788
|
+
if (size <= 1_000_000) {
|
|
1789
|
+
return true;
|
|
1790
|
+
}
|
|
1791
|
+
this.logger.warn(`[SessionRunner] dropping oversized part ${part.partId} (${size} bytes)`);
|
|
1792
|
+
return false;
|
|
1793
|
+
});
|
|
1794
|
+
const chunks = [];
|
|
1795
|
+
for (let i = 0; i < sendable.length; i += contract_1.MAX_INGEST_PARTS) {
|
|
1796
|
+
chunks.push(sendable.slice(i, i + contract_1.MAX_INGEST_PARTS));
|
|
1797
|
+
}
|
|
1798
|
+
if (chunks.length === 0) {
|
|
1799
|
+
chunks.push([]);
|
|
1800
|
+
}
|
|
1801
|
+
for (let i = 0; i < chunks.length; i += 1) {
|
|
1802
|
+
const isLast = i === chunks.length - 1;
|
|
1803
|
+
await this.deps.ingest(sessionId, chunks[i], isLast ? turn : undefined, isLast ? final : false, isLast ? modelMessages : undefined);
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/* ---------------------------------------------------------------- */
|
|
1807
|
+
/* Wiring */
|
|
1808
|
+
/* ---------------------------------------------------------------- */
|
|
1809
|
+
/**
|
|
1810
|
+
* WHERE `resumeFromRev` FINALLY DOES SOMETHING.
|
|
1811
|
+
*
|
|
1812
|
+
* The field was specified in the frozen contract, declared in `types.ts`,
|
|
1813
|
+
* documented in the README and named in three test files. The control plane
|
|
1814
|
+
* computed it and put it on every lease. Nothing in the runtime had ever read
|
|
1815
|
+
* it. This is the read.
|
|
1816
|
+
*
|
|
1817
|
+
* ITS MEANING, taken from the contract's own comment: "the harness already
|
|
1818
|
+
* holds transcript up to this rev and need not replay it. Null means start
|
|
1819
|
+
* cold." So it is a statement about what the SERVER has, which the runner
|
|
1820
|
+
* compares against what IT has:
|
|
1821
|
+
*
|
|
1822
|
+
* null -> a brand-new conversation. Nothing to load.
|
|
1823
|
+
* local maxRev >= it -> this runner ran the earlier turns and its
|
|
1824
|
+
* cache is current. No network call.
|
|
1825
|
+
* local maxRev < it -> this runner is behind, or has never seen this
|
|
1826
|
+
* session at all. The control plane is the only
|
|
1827
|
+
* place the conversation still exists; fetch it.
|
|
1828
|
+
*
|
|
1829
|
+
* That third case is the one that matters, and it is not an edge case: it is
|
|
1830
|
+
* every cold sandbox, every redeployed runner, and every turn that happens to
|
|
1831
|
+
* be leased to a different machine than the one before it.
|
|
1832
|
+
*
|
|
1833
|
+
* NEVER THROWS. A conversation that cannot be loaded is a turn that starts
|
|
1834
|
+
* cold — degraded, and worth a warning — whereas a throw here would refuse a
|
|
1835
|
+
* turn the user is waiting on. The warning is loud because a fleet silently
|
|
1836
|
+
* forgetting everything is precisely the failure this whole change exists to
|
|
1837
|
+
* end.
|
|
1838
|
+
*/
|
|
1839
|
+
priorMessageCount = 0;
|
|
1840
|
+
async loadMemory(session, ownBackendInstanceId) {
|
|
1841
|
+
this.priorMessageCount = 0;
|
|
1842
|
+
const store = this.deps.conversation;
|
|
1843
|
+
const local = store?.getModelMessages(session.sessionId) ?? [];
|
|
1844
|
+
const localCheckpoint = store?.getCheckpoint(session.sessionId) ?? null;
|
|
1845
|
+
/*
|
|
1846
|
+
* Only meaningful when the checkpoint we are about to RESUME is the local
|
|
1847
|
+
* one. If the lease hands us a different backend session, the local record
|
|
1848
|
+
* describes a session we are not reusing, and comparing against it would
|
|
1849
|
+
* rebuild for no reason.
|
|
1850
|
+
*/
|
|
1851
|
+
const permissionDecision = localCheckpoint?.backendSessionId &&
|
|
1852
|
+
(session.backendSessionId ?? localCheckpoint.backendSessionId) ===
|
|
1853
|
+
localCheckpoint.backendSessionId
|
|
1854
|
+
? localCheckpoint.permissionDecision
|
|
1855
|
+
: undefined;
|
|
1856
|
+
/*
|
|
1857
|
+
* A CHECKPOINT MINTED BY A DIFFERENT BACKEND *INSTANCE* IS NOT A CHECKPOINT
|
|
1858
|
+
* EITHER — and this, not the backend-kind check below, is what had never
|
|
1859
|
+
* once worked.
|
|
1860
|
+
*
|
|
1861
|
+
* A Pi session file is a key into ONE Pi agent directory. The control plane
|
|
1862
|
+
* stores whatever the last runner reported and hands it to whoever takes
|
|
1863
|
+
* the next turn, so the moment a conversation is leased to a SECOND runner
|
|
1864
|
+
* the handle stops meaning anything: the second runner's Pi store has never
|
|
1865
|
+
* heard of it, answers "Session not found", and the whole history is
|
|
1866
|
+
* replayed and re-billed. Measured on the owner's machine, one conversation
|
|
1867
|
+
* bounced between a laptop runner and a cloud-sandbox runner reached over
|
|
1868
|
+
* ngrok and did exactly this on every turn after the second runner joined.
|
|
1869
|
+
*
|
|
1870
|
+
* Turns 2 to 7 of that same conversation DID resume — across three
|
|
1871
|
+
* successive runner restarts — because all three processes used one durable
|
|
1872
|
+
* Pi agent directory. That is the whole rule, and
|
|
1873
|
+
* it is why the identity is the state directory rather than the process.
|
|
1874
|
+
*
|
|
1875
|
+
* FAIL-SAFE DIRECTION. Unknown ownership is read as "not mine": replaying
|
|
1876
|
+
* once is recoverable, resuming somebody else's handle is not. A backend
|
|
1877
|
+
* that cannot report an instance id keeps the previous behaviour rather
|
|
1878
|
+
* than being refused outright.
|
|
1879
|
+
*/
|
|
1880
|
+
const mine = (checkpoint) => {
|
|
1881
|
+
if (!checkpoint)
|
|
1882
|
+
return false;
|
|
1883
|
+
if (!ownBackendInstanceId)
|
|
1884
|
+
return true; // backend cannot say; unchanged.
|
|
1885
|
+
return checkpoint.backendOwnerId === ownBackendInstanceId;
|
|
1886
|
+
};
|
|
1887
|
+
const leaseCheckpoint = session.backendSessionId
|
|
1888
|
+
? {
|
|
1889
|
+
backendSessionId: session.backendSessionId,
|
|
1890
|
+
backend: session.backendName ?? null,
|
|
1891
|
+
backendOwnerId: session.backendOwnerId ?? null
|
|
1892
|
+
}
|
|
1893
|
+
: null;
|
|
1894
|
+
/*
|
|
1895
|
+
* The lease is still PREFERRED — the control plane saw the last turn
|
|
1896
|
+
* wherever it ran, and this runner may only have seen an older one — but
|
|
1897
|
+
* only among checkpoints this runner can actually honour.
|
|
1898
|
+
*/
|
|
1899
|
+
const usable = [leaseCheckpoint, localCheckpoint].find((candidate) => mine(candidate ?? null));
|
|
1900
|
+
if (!usable && (leaseCheckpoint || localCheckpoint)) {
|
|
1901
|
+
/*
|
|
1902
|
+
* LOG, NOT WARN, AND NOT "could not resume".
|
|
1903
|
+
*
|
|
1904
|
+
* Nothing failed. The conversation is simply being continued somewhere it
|
|
1905
|
+
* has never run, which is an ordinary consequence of a session that can
|
|
1906
|
+
* be leased to any runner. Logging it as a failure is what made a
|
|
1907
|
+
* fallback that ran on every single turn look like a rare edge case.
|
|
1908
|
+
*/
|
|
1909
|
+
this.logger.log(`[SessionRunner] session ${session.sessionId} has a checkpoint from ` +
|
|
1910
|
+
`another backend instance (${leaseCheckpoint?.backendOwnerId ??
|
|
1911
|
+
localCheckpoint?.backendOwnerId ??
|
|
1912
|
+
'unknown'}); this runner is ${ownBackendInstanceId ?? 'unidentified'}. ` +
|
|
1913
|
+
'Replaying history once into a session of its own.');
|
|
1914
|
+
}
|
|
1915
|
+
let backendSessionId = usable?.backendSessionId ?? null;
|
|
1916
|
+
/*
|
|
1917
|
+
* A CHECKPOINT MINTED BY A DIFFERENT BACKEND IS NOT A CHECKPOINT.
|
|
1918
|
+
*
|
|
1919
|
+
* An OpenCode session id handed to Claude Code is not a session, it is a
|
|
1920
|
+
* string; resuming it fails confusingly, while ignoring it replays cleanly.
|
|
1921
|
+
* This arises for an ordinary reason — an administrator changes the agent's
|
|
1922
|
+
* backend part-way through a conversation.
|
|
1923
|
+
*
|
|
1924
|
+
* The lease's `backendName` is consulted FIRST and the local checkpoint
|
|
1925
|
+
* second. An earlier version checked only the local record, which meant the
|
|
1926
|
+
* guard could not fire on a cold runner — precisely the runner most likely
|
|
1927
|
+
* to be handed a checkpoint it cannot use.
|
|
1928
|
+
*/
|
|
1929
|
+
const checkpointBackend = usable?.backend ?? null;
|
|
1930
|
+
if (backendSessionId &&
|
|
1931
|
+
checkpointBackend &&
|
|
1932
|
+
checkpointBackend !== session.agent.backend) {
|
|
1933
|
+
this.logger.warn(`[SessionRunner] session ${session.sessionId} carries a checkpoint from ` +
|
|
1934
|
+
`backend "${checkpointBackend}" but this turn runs on ` +
|
|
1935
|
+
`"${session.agent.backend}". Replaying history into a fresh session.`);
|
|
1936
|
+
backendSessionId = null;
|
|
1937
|
+
}
|
|
1938
|
+
const serverRev = session.resumeFromRev;
|
|
1939
|
+
if (serverRev === null || serverRev === undefined) {
|
|
1940
|
+
// A brand-new conversation. `local` is normally empty here; if it is not,
|
|
1941
|
+
// it is stale state for a reused session id and must not be replayed.
|
|
1942
|
+
this.priorMessageCount = 0;
|
|
1943
|
+
return { backendSessionId, messages: [], permissionDecision };
|
|
1944
|
+
}
|
|
1945
|
+
const localRev = this.deps.store.getMaxRev(session.sessionId);
|
|
1946
|
+
if (local.length > 0 && localRev >= serverRev) {
|
|
1947
|
+
this.priorMessageCount = local.length;
|
|
1948
|
+
return { backendSessionId, messages: local, permissionDecision };
|
|
1949
|
+
}
|
|
1950
|
+
if (!this.deps.fetchHistory) {
|
|
1951
|
+
this.logger.warn(`[SessionRunner] session ${session.sessionId} has server history ` +
|
|
1952
|
+
`(resumeFromRev=${serverRev}) that this runner does not hold, and no ` +
|
|
1953
|
+
'history read path is wired. The turn will start without it.');
|
|
1954
|
+
this.priorMessageCount = local.length;
|
|
1955
|
+
return { backendSessionId, messages: local, permissionDecision };
|
|
1956
|
+
}
|
|
1957
|
+
try {
|
|
1958
|
+
const remote = await this.deps.fetchHistory(session.sessionId);
|
|
1959
|
+
// Cache it, so a second turn on this runner costs no network call.
|
|
1960
|
+
try {
|
|
1961
|
+
store?.replaceModelMessages(session.sessionId, remote.messages);
|
|
1962
|
+
}
|
|
1963
|
+
catch {
|
|
1964
|
+
/* a cache that will not write is a slow turn, not a failed one */
|
|
1965
|
+
}
|
|
1966
|
+
this.priorMessageCount = remote.messages.length;
|
|
1967
|
+
/*
|
|
1968
|
+
* THE SAME OWNERSHIP TEST, ON THE PATH THAT NEEDED IT MOST.
|
|
1969
|
+
*
|
|
1970
|
+
* `remote` carries the checkpoint from the SAME control-plane row the
|
|
1971
|
+
* guard above already refused, so taking it here would quietly undo that
|
|
1972
|
+
* refusal — and it would do so on a COLD runner, which the comment above
|
|
1973
|
+
* calls "precisely the runner most likely to be handed a checkpoint it
|
|
1974
|
+
* cannot use". That is not an edge case: an empty local store plus a
|
|
1975
|
+
* server-side history is the definition of a session that has just moved
|
|
1976
|
+
* between machines, which is the whole defect this change exists to fix.
|
|
1977
|
+
*
|
|
1978
|
+
* `mine()` rather than a second inline comparison, so the two paths
|
|
1979
|
+
* cannot drift.
|
|
1980
|
+
*/
|
|
1981
|
+
const remoteCheckpoint = remote.backendSessionId
|
|
1982
|
+
? {
|
|
1983
|
+
backendSessionId: remote.backendSessionId,
|
|
1984
|
+
backendOwnerId: remote.backendOwnerId ?? null
|
|
1985
|
+
}
|
|
1986
|
+
: null;
|
|
1987
|
+
if (remoteCheckpoint && !mine(remoteCheckpoint)) {
|
|
1988
|
+
this.logger.log(`[SessionRunner] session ${session.sessionId}: the history read ` +
|
|
1989
|
+
`carries a checkpoint from another backend instance ` +
|
|
1990
|
+
`(${remoteCheckpoint.backendOwnerId ?? 'unknown'}); this runner is ` +
|
|
1991
|
+
`${ownBackendInstanceId ?? 'unidentified'}. Replaying instead.`);
|
|
1992
|
+
}
|
|
1993
|
+
/*
|
|
1994
|
+
* MERGE NOTE (#1591 × #1602). Both changes rewrote this return.
|
|
1995
|
+
*
|
|
1996
|
+
* #1591 gates the remote handle behind `mine()` — a checkpoint minted by
|
|
1997
|
+
* another backend instance is not a checkpoint here, and this cold path
|
|
1998
|
+
* is exactly where that happens.
|
|
1999
|
+
*
|
|
2000
|
+
* #1602 reports the permission decision, which the control plane does not
|
|
2001
|
+
* carry, so it is only known when the session we end up on is the one we
|
|
2002
|
+
* already hold locally.
|
|
2003
|
+
*
|
|
2004
|
+
* The two compose once the gate runs FIRST: resolve the id, then ask
|
|
2005
|
+
* whether that resolved id is the local one. Computing the decision from
|
|
2006
|
+
* `remote.backendSessionId` — as #1602 did — would answer for a handle the
|
|
2007
|
+
* gate has just refused.
|
|
2008
|
+
*/
|
|
2009
|
+
const resolvedBackendSessionId = (remoteCheckpoint && mine(remoteCheckpoint)
|
|
2010
|
+
? remoteCheckpoint.backendSessionId
|
|
2011
|
+
: null) ?? backendSessionId;
|
|
2012
|
+
return {
|
|
2013
|
+
backendSessionId: resolvedBackendSessionId,
|
|
2014
|
+
messages: remote.messages,
|
|
2015
|
+
permissionDecision: resolvedBackendSessionId === backendSessionId
|
|
2016
|
+
? permissionDecision
|
|
2017
|
+
: undefined
|
|
2018
|
+
};
|
|
2019
|
+
}
|
|
2020
|
+
catch (caught) {
|
|
2021
|
+
this.logger.warn(`[SessionRunner] could not load history for ${session.sessionId}: ` +
|
|
2022
|
+
`${caught instanceof Error ? caught.message : String(caught)}. ` +
|
|
2023
|
+
'The turn will start with whatever this runner holds locally.');
|
|
2024
|
+
this.priorMessageCount = local.length;
|
|
2025
|
+
return { backendSessionId, messages: local, permissionDecision };
|
|
2026
|
+
}
|
|
2027
|
+
}
|
|
2028
|
+
/**
|
|
2029
|
+
* Authorise this session for exactly the secrets it was granted.
|
|
2030
|
+
*
|
|
2031
|
+
* Returns null when the host has no secret broker, which is the ordinary case
|
|
2032
|
+
* on a runner — see `NoSecretProvider`, where holding no vendor credential is
|
|
2033
|
+
* a security position rather than a gap.
|
|
2034
|
+
*/
|
|
2035
|
+
setUpSecrets(session) {
|
|
2036
|
+
if (this.deps.secrets.getBrokerPort() === null) {
|
|
2037
|
+
return null;
|
|
2038
|
+
}
|
|
2039
|
+
const token = (0, node_crypto_1.randomUUID)();
|
|
2040
|
+
this.deps.secrets.registerSession(token, session.agent.agentId, []);
|
|
2041
|
+
return token;
|
|
2042
|
+
}
|
|
2043
|
+
/**
|
|
2044
|
+
* Build the backend's session config.
|
|
2045
|
+
*
|
|
2046
|
+
* EVERY MCP SERVER GOES THROUGH THE LOOPBACK PROXY. The real gateway URL and
|
|
2047
|
+
* the session token never reach the backend, never land in an MCP config file
|
|
2048
|
+
* on disk, and never appear in the backend's own session state — so a leaked
|
|
2049
|
+
* workspace does not leak the tenant's tool access. This is also what makes
|
|
2050
|
+
* the token refreshable mid-session, which is the defect the proxy was
|
|
2051
|
+
* originally written for.
|
|
2052
|
+
*
|
|
2053
|
+
* `allowedTools` from the lease is NOT enforced here, and that is deliberate.
|
|
2054
|
+
* It is advisory — for generating AGENTS.md and for what the UI shows. THE
|
|
2055
|
+
* GATEWAY IS AUTHORITATIVE, because a harness on a customer VM is outside our
|
|
2056
|
+
* security perimeter and trusting it to filter its own tools is exactly
|
|
2057
|
+
* defect G2.
|
|
2058
|
+
*/
|
|
2059
|
+
buildSessionConfig(session, workspaceDir, secretToken, signal) {
|
|
2060
|
+
const mcpServers = {};
|
|
2061
|
+
for (const server of session.agent.mcpServers) {
|
|
2062
|
+
const proxied = this.deps.proxy.registerTarget(server.url, server.name);
|
|
2063
|
+
mcpServers[server.name] = {
|
|
2064
|
+
name: server.name,
|
|
2065
|
+
type: 'http',
|
|
2066
|
+
// A null proxy URL means the proxy is not running. Falling back to the
|
|
2067
|
+
// direct URL would put the session token in the backend's config, so
|
|
2068
|
+
// the server is simply omitted instead — fewer tools is recoverable,
|
|
2069
|
+
// a leaked token is not.
|
|
2070
|
+
url: proxied
|
|
2071
|
+
};
|
|
2072
|
+
if (!proxied) {
|
|
2073
|
+
delete mcpServers[server.name];
|
|
2074
|
+
this.logger.warn(`[SessionRunner] MCP server "${server.name}" omitted: proxy not running`);
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
const abort = new AbortController();
|
|
2078
|
+
signal.addEventListener('abort', () => abort.abort(), { once: true });
|
|
2079
|
+
const brokerPort = this.deps.secrets.getBrokerPort();
|
|
2080
|
+
return {
|
|
2081
|
+
agentId: session.agent.agentId,
|
|
2082
|
+
// The adapters were written against 20x's task model and call this
|
|
2083
|
+
// `taskId`. It is the SESSION id here. Renaming the field in the adapters
|
|
2084
|
+
// would have made them no longer "extracted unchanged", which is worth
|
|
2085
|
+
// more than the tidier name.
|
|
2086
|
+
taskId: session.sessionId,
|
|
2087
|
+
workspaceDir,
|
|
2088
|
+
model: qualifiedModelId(session.agent.model),
|
|
2089
|
+
systemPrompt: session.agent.systemPrompt ?? undefined,
|
|
2090
|
+
skills: session.agent.skills.map((skill) => ({
|
|
2091
|
+
name: skill.name,
|
|
2092
|
+
content: skill.content
|
|
2093
|
+
})),
|
|
2094
|
+
promptAbort: abort,
|
|
2095
|
+
mcpServers: mcpServers,
|
|
2096
|
+
secretBrokerPort: brokerPort ?? undefined,
|
|
2097
|
+
secretSessionToken: secretToken ?? undefined,
|
|
2098
|
+
secretShellPath: this.deps.secrets.writeShellWrapper() ?? undefined,
|
|
2099
|
+
environment: this.deps.workspace.getEnvironment?.(session.sessionId, [
|
|
2100
|
+
session.agent.model.gatewayUrl ?? '',
|
|
2101
|
+
...session.agent.mcpServers.map((server) => server.url)
|
|
2102
|
+
]),
|
|
2103
|
+
/*
|
|
2104
|
+
* DEFECT G1, THE OTHER HALF.
|
|
2105
|
+
*
|
|
2106
|
+
* `session.agent.permissionMode` has been on the lease since the contract
|
|
2107
|
+
* was frozen, and until now it DIED HERE: this object was built without
|
|
2108
|
+
* the field, so every session fell to the adapter default no matter what
|
|
2109
|
+
* the user had chosen. The picker in the agent editor was decorative, and
|
|
2110
|
+
* the UI said so in a caveat.
|
|
2111
|
+
*
|
|
2112
|
+
* The lease's vocabulary is four-valued and the adapter's is two-valued,
|
|
2113
|
+
* so the translation is deliberate rather than incidental — the whole
|
|
2114
|
+
* table, and what it costs on each backend, is written down in
|
|
2115
|
+
* ./permission-mode.ts. `sandboxMode` is set from the same call because
|
|
2116
|
+
* it is what carries the part of the intent 'ask' | 'allow' cannot.
|
|
2117
|
+
*/
|
|
2118
|
+
...(0, permission_mode_1.mapPermissionMode)(session.agent.permissionMode)
|
|
2119
|
+
};
|
|
2120
|
+
}
|
|
2121
|
+
}
|
|
2122
|
+
exports.SessionRunner = SessionRunner;
|