@bridge4dev/runner 0.48.0 → 0.49.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/adapters/claude.js +60 -0
- package/dist/adapters/codex-protocol.d.ts +13 -0
- package/dist/adapters/codex-protocol.js +17 -1
- package/dist/adapters/codex.js +122 -12
- package/dist/adapters/types.d.ts +46 -2
- package/dist/stop-cycle.d.ts +148 -0
- package/dist/stop-cycle.js +86 -0
- package/dist/supervisor.d.ts +92 -0
- package/dist/supervisor.js +509 -14
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/adapters/claude.js
CHANGED
|
@@ -2012,12 +2012,18 @@ class ClaudeSession {
|
|
|
2012
2012
|
this.aborting = true;
|
|
2013
2013
|
try {
|
|
2014
2014
|
await this.q.interrupt();
|
|
2015
|
+
return 'accepted';
|
|
2015
2016
|
}
|
|
2016
2017
|
catch (error) {
|
|
2017
2018
|
// The abort never reached the SDK, so any failure that arrives now is the
|
|
2018
2019
|
// agent's own and must be reported as one.
|
|
2019
2020
|
this.aborting = false;
|
|
2020
2021
|
log.warn('claude: interrupt failed', { error: String(error) });
|
|
2022
|
+
// #373: answered rather than swallowed. `stopped` is the honest reading of
|
|
2023
|
+
// «we closed this query ourselves» — the SDK rejects every outstanding
|
|
2024
|
+
// control request with «Query closed before response received» on close,
|
|
2025
|
+
// and calling that a refusal would turn our own park into a failure.
|
|
2026
|
+
return this.stopped ? 'idle' : 'refused';
|
|
2021
2027
|
}
|
|
2022
2028
|
}
|
|
2023
2029
|
conversationAnchor() {
|
|
@@ -2281,6 +2287,19 @@ class ClaudeSession {
|
|
|
2281
2287
|
const aborted = this.aborting;
|
|
2282
2288
|
this.aborting = false;
|
|
2283
2289
|
const failure = msg.subtype === 'success' ? '' : classifyError(msg.subtype, msg.errors);
|
|
2290
|
+
// #373, plan stage D. What the incident could not answer: the second
|
|
2291
|
+
// result's origin was never recorded, so «is this the same turn
|
|
2292
|
+
// twice or a turn the CLI started by itself» had no evidence either
|
|
2293
|
+
// way. An allowlist of shapes the SDK marks optional, read through
|
|
2294
|
+
// type guards so a build that has none of them logs none of them —
|
|
2295
|
+
// no message text, no tool input, no environment, nothing to mask.
|
|
2296
|
+
log.info('claude: turn result', {
|
|
2297
|
+
sessionId: this.spec.sessionId,
|
|
2298
|
+
subtype: msg.subtype,
|
|
2299
|
+
aborted,
|
|
2300
|
+
produced: this.turnProduced,
|
|
2301
|
+
...optionalDiagnostics(msg),
|
|
2302
|
+
});
|
|
2284
2303
|
if (failure && isRewindError(failure)) {
|
|
2285
2304
|
// Not a failed turn — a refused resume. The CLI answers a bad
|
|
2286
2305
|
// `resumeSessionAt` with exactly this and nothing else (no
|
|
@@ -2351,6 +2370,12 @@ class ClaudeSession {
|
|
|
2351
2370
|
type: 'error',
|
|
2352
2371
|
message: classifyRunError(message),
|
|
2353
2372
|
...(code ? { code } : {}),
|
|
2373
|
+
// #373: this catch is the CLI process falling over under the read loop —
|
|
2374
|
+
// the only place in this adapter where that is what happened. Our own
|
|
2375
|
+
// `q.close()` does NOT come through here (the SDK ends the input stream
|
|
2376
|
+
// and the iteration finishes cleanly), so the flag stays an honest
|
|
2377
|
+
// answer to «did the process die on its own».
|
|
2378
|
+
processGone: true,
|
|
2354
2379
|
});
|
|
2355
2380
|
}
|
|
2356
2381
|
finally {
|
|
@@ -2367,6 +2392,41 @@ class ClaudeSession {
|
|
|
2367
2392
|
}
|
|
2368
2393
|
}
|
|
2369
2394
|
}
|
|
2395
|
+
/**
|
|
2396
|
+
* The optional shapes a `result` may carry, and nothing else (#373).
|
|
2397
|
+
*
|
|
2398
|
+
* An allowlist rather than «log the message»: an SDK message holds the whole
|
|
2399
|
+
* turn, and a runner log is not a place for a person's words, a tool's input or
|
|
2400
|
+
* an environment. Each field is read through a type guard and simply absent when
|
|
2401
|
+
* the build does not have it — no private SDK methods, and no minimum CLI
|
|
2402
|
+
* version required to read an optional field.
|
|
2403
|
+
*
|
|
2404
|
+
* - `uuid` names the RESULT, not the turn. Two results of one stop
|
|
2405
|
+
* have two of these, which is why it is evidence and not an
|
|
2406
|
+
* identifier to key anything on.
|
|
2407
|
+
* - `origin.kind` `task-notification` is a turn a background subagent woke
|
|
2408
|
+
* from inside the CLI — the one thing that tells a genuinely
|
|
2409
|
+
* new internal turn from a duplicate ending.
|
|
2410
|
+
* - `terminal_reason` the CLI's own word for why it stopped.
|
|
2411
|
+
*/
|
|
2412
|
+
function optionalDiagnostics(msg) {
|
|
2413
|
+
if (!msg || typeof msg !== 'object')
|
|
2414
|
+
return {};
|
|
2415
|
+
const record = msg;
|
|
2416
|
+
const out = {};
|
|
2417
|
+
if (typeof record['uuid'] === 'string')
|
|
2418
|
+
out['resultUuid'] = record['uuid'];
|
|
2419
|
+
if (typeof record['terminal_reason'] === 'string') {
|
|
2420
|
+
out['terminalReason'] = record['terminal_reason'];
|
|
2421
|
+
}
|
|
2422
|
+
const origin = record['origin'];
|
|
2423
|
+
if (origin && typeof origin === 'object') {
|
|
2424
|
+
const kind = origin['kind'];
|
|
2425
|
+
if (typeof kind === 'string')
|
|
2426
|
+
out['originKind'] = kind;
|
|
2427
|
+
}
|
|
2428
|
+
return out;
|
|
2429
|
+
}
|
|
2370
2430
|
// Recursive: MultiEdit-style inputs nest long strings inside arrays — a
|
|
2371
2431
|
// shallow pass let them blow past the API's 128KB event cap (QA-96 F2).
|
|
2372
2432
|
function truncateDeep(value, limit) {
|
|
@@ -7,6 +7,19 @@ export declare class RpcError extends Error {
|
|
|
7
7
|
readonly method: string;
|
|
8
8
|
constructor(code: number, message: string, method: string);
|
|
9
9
|
}
|
|
10
|
+
/**
|
|
11
|
+
* The far end never answered in time (#370).
|
|
12
|
+
*
|
|
13
|
+
* A class of its own rather than a number the caller has to recognise: opening
|
|
14
|
+
* a conversation has to tell «Codex refused» from «Codex was too slow», and the
|
|
15
|
+
* only other way to ask was the sentence in `message`. That sentence is prose,
|
|
16
|
+
* and prose is exactly what turned a slow `thread/resume` into a dead session —
|
|
17
|
+
* `isMissingRollout` matched «no rollout found», not «timed out after 60000ms»,
|
|
18
|
+
* so none of the recovery already written for a refusal ever ran.
|
|
19
|
+
*/
|
|
20
|
+
export declare class RpcTimeoutError extends RpcError {
|
|
21
|
+
constructor(method: string, timeoutMs: number);
|
|
22
|
+
}
|
|
10
23
|
export interface ServerRequest {
|
|
11
24
|
id: number | string;
|
|
12
25
|
method: string;
|
|
@@ -10,6 +10,22 @@ export class RpcError extends Error {
|
|
|
10
10
|
this.name = 'RpcError';
|
|
11
11
|
}
|
|
12
12
|
}
|
|
13
|
+
/**
|
|
14
|
+
* The far end never answered in time (#370).
|
|
15
|
+
*
|
|
16
|
+
* A class of its own rather than a number the caller has to recognise: opening
|
|
17
|
+
* a conversation has to tell «Codex refused» from «Codex was too slow», and the
|
|
18
|
+
* only other way to ask was the sentence in `message`. That sentence is prose,
|
|
19
|
+
* and prose is exactly what turned a slow `thread/resume` into a dead session —
|
|
20
|
+
* `isMissingRollout` matched «no rollout found», not «timed out after 60000ms»,
|
|
21
|
+
* so none of the recovery already written for a refusal ever ran.
|
|
22
|
+
*/
|
|
23
|
+
export class RpcTimeoutError extends RpcError {
|
|
24
|
+
constructor(method, timeoutMs) {
|
|
25
|
+
super(-32000, `${method} timed out after ${timeoutMs}ms`, method);
|
|
26
|
+
this.name = 'RpcTimeoutError';
|
|
27
|
+
}
|
|
28
|
+
}
|
|
13
29
|
const DEFAULT_REQUEST_TIMEOUT_MS = 60_000;
|
|
14
30
|
/** A line longer than this means the far end is misbehaving — drop the buffer. */
|
|
15
31
|
const MAX_LINE_BYTES = 8 * 1024 * 1024;
|
|
@@ -134,7 +150,7 @@ export class AppServerClient {
|
|
|
134
150
|
return new Promise((resolve, reject) => {
|
|
135
151
|
const timer = setTimeout(() => {
|
|
136
152
|
this.pending.delete(id);
|
|
137
|
-
reject(new
|
|
153
|
+
reject(new RpcTimeoutError(method, timeoutMs));
|
|
138
154
|
}, timeoutMs);
|
|
139
155
|
timer.unref();
|
|
140
156
|
this.pending.set(id, {
|
package/dist/adapters/codex.js
CHANGED
|
@@ -3,7 +3,7 @@ import { log } from '../log.js';
|
|
|
3
3
|
import { evaluateToolUse, maskSecrets, maskString, } from '../policy.js';
|
|
4
4
|
import { RUNNER_VERSION } from '../version.js';
|
|
5
5
|
import { repairCodexAuth } from './codex-home.js';
|
|
6
|
-
import { AppServerClient, asRecord, num, str } from './codex-protocol.js';
|
|
6
|
+
import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
|
|
7
7
|
import { truncate } from './claude.js';
|
|
8
8
|
import { availableModes, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
9
9
|
import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
|
|
@@ -326,13 +326,69 @@ class CodexSession {
|
|
|
326
326
|
* second; losing the conversation costs the whole context.
|
|
327
327
|
*/
|
|
328
328
|
static RESUME_RETRY_DELAY_MS = 1_500;
|
|
329
|
+
/**
|
|
330
|
+
* How long opening an existing conversation may take (#370).
|
|
331
|
+
*
|
|
332
|
+
* Its own budget rather than the protocol's 60 s default, because it is not a
|
|
333
|
+
* command — it is the app-server reading a conversation off disk, and the
|
|
334
|
+
* work grows with the conversation. Session #61 on Athanor died on exactly
|
|
335
|
+
* that: ~6 900 events, `thread/resume timed out after 60000ms`, twice, and
|
|
336
|
+
* the session was gone for good.
|
|
337
|
+
*
|
|
338
|
+
* Measured here on 06.09.2026, codex-cli 0.153.4, against a real 14 MB
|
|
339
|
+
* rollout (1 460 lines):
|
|
340
|
+
*
|
|
341
|
+
* without `excludeTurns` — 5 744 ms, a 4.41 MB response line
|
|
342
|
+
* with `excludeTurns` — 673 ms, a 27 KB response line
|
|
343
|
+
*
|
|
344
|
+
* So 120 s is ~20× the slowest answer this transport can even carry: the
|
|
345
|
+
* stdout reader drops any line over 8 MB (`MAX_LINE_BYTES`), which at the
|
|
346
|
+
* measured 0.77 MB/s is about ten seconds of hydration. The margin is not for
|
|
347
|
+
* a bigger conversation — it is for a loaded or slow machine, which is what
|
|
348
|
+
* #370 is actually about.
|
|
349
|
+
*/
|
|
350
|
+
static RESUME_REQUEST_TIMEOUT_MS = 120_000;
|
|
351
|
+
/**
|
|
352
|
+
* Does this build take `excludeTurns`? (#364)
|
|
353
|
+
*
|
|
354
|
+
* Remembered per session rather than probed: the answer belongs to the
|
|
355
|
+
* installed binary, and one refusal is enough to stop asking for the life of
|
|
356
|
+
* this process. Same shape as the `experimentalApi` fallback below it.
|
|
357
|
+
*/
|
|
358
|
+
excludeTurnsRefused = false;
|
|
359
|
+
/**
|
|
360
|
+
* Open a thread without asking for its whole history (#364).
|
|
361
|
+
*
|
|
362
|
+
* The history was always thrown away — `readThread` reads the id and the
|
|
363
|
+
* model and nothing else — so asking for it bought a deprecation warning in
|
|
364
|
+
* the user's feed, several megabytes through a pipe that drops anything over
|
|
365
|
+
* eight, and the seconds that killed #370. Old builds that do not know the
|
|
366
|
+
* field are answered by retrying once without it.
|
|
367
|
+
*/
|
|
368
|
+
async openThreadRequest(method, params) {
|
|
369
|
+
if (!this.excludeTurnsRefused) {
|
|
370
|
+
try {
|
|
371
|
+
return asRecord(await this.client.request(method, { ...params, excludeTurns: true }, CodexSession.RESUME_REQUEST_TIMEOUT_MS));
|
|
372
|
+
}
|
|
373
|
+
catch (error) {
|
|
374
|
+
if (!isUnknownParam(error))
|
|
375
|
+
throw error;
|
|
376
|
+
this.excludeTurnsRefused = true;
|
|
377
|
+
log.warn('codex: this build does not take excludeTurns — asking for the full history', {
|
|
378
|
+
sessionId: this.spec.sessionId,
|
|
379
|
+
method,
|
|
380
|
+
});
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
return asRecord(await this.client.request(method, params, CodexSession.RESUME_REQUEST_TIMEOUT_MS));
|
|
384
|
+
}
|
|
329
385
|
/** Branch the thread at `lastTurnId`, dropping every later turn. */
|
|
330
386
|
async forkThread(threadId, lastTurnId) {
|
|
331
|
-
const result =
|
|
387
|
+
const result = await this.openThreadRequest('thread/fork', {
|
|
332
388
|
threadId,
|
|
333
389
|
lastTurnId,
|
|
334
390
|
...this.threadParams(),
|
|
335
|
-
})
|
|
391
|
+
});
|
|
336
392
|
const thread = asRecord(result['thread']);
|
|
337
393
|
const id = str(thread['id']);
|
|
338
394
|
if (!id)
|
|
@@ -344,10 +400,10 @@ class CodexSession {
|
|
|
344
400
|
// overlay lives only in memory, so resuming with just a thread id brings the
|
|
345
401
|
// conversation back without DevBridge access — the agent then hunts for
|
|
346
402
|
// tickets it can no longer reach (found in the live check).
|
|
347
|
-
const result =
|
|
403
|
+
const result = await this.openThreadRequest('thread/resume', {
|
|
348
404
|
threadId: resumeId,
|
|
349
405
|
...this.threadParams(),
|
|
350
|
-
})
|
|
406
|
+
});
|
|
351
407
|
const thread = asRecord(result['thread']);
|
|
352
408
|
const id = str(thread['id']);
|
|
353
409
|
if (!id)
|
|
@@ -392,9 +448,10 @@ class CodexSession {
|
|
|
392
448
|
let error = firstError;
|
|
393
449
|
// Matched on the message rather than the error class: the same failure
|
|
394
450
|
// can arrive as an RPC error or as a transport error, and a missed match
|
|
395
|
-
// would fail the session instead of retrying it.
|
|
396
|
-
|
|
397
|
-
|
|
451
|
+
// would fail the session instead of retrying it. The one exception is a
|
|
452
|
+
// timeout, which IS an error class (#370) — see `isResumeRecoverable`.
|
|
453
|
+
if (isResumeRecoverable(error)) {
|
|
454
|
+
log.warn('codex: thread/resume did not open the conversation — retrying once', {
|
|
398
455
|
sessionId: this.spec.sessionId,
|
|
399
456
|
threadId: resumeId,
|
|
400
457
|
error: maskString(describe(error)).slice(0, 300),
|
|
@@ -411,14 +468,14 @@ class CodexSession {
|
|
|
411
468
|
error = retryError;
|
|
412
469
|
}
|
|
413
470
|
}
|
|
414
|
-
if (
|
|
471
|
+
if (isResumeRecoverable(error)) {
|
|
415
472
|
// Losing the conversation is expensive, so record WHY. Verified live
|
|
416
473
|
// (2026-07-25): resuming this exact thread with these exact params
|
|
417
474
|
// succeeds in isolation, so a failure here is transient — most likely
|
|
418
475
|
// the previous agent process still holding the thread during a rapid
|
|
419
476
|
// stop→continue cycle. Without this line the only trace is a feed
|
|
420
477
|
// notice that says the context is gone and nothing about the cause.
|
|
421
|
-
log.warn('codex: thread/resume
|
|
478
|
+
log.warn('codex: thread/resume gave up — falling back to a fresh thread', {
|
|
422
479
|
sessionId: this.spec.sessionId,
|
|
423
480
|
threadId: resumeId,
|
|
424
481
|
error: maskString(describe(error)).slice(0, 300),
|
|
@@ -767,18 +824,23 @@ class CodexSession {
|
|
|
767
824
|
}
|
|
768
825
|
}
|
|
769
826
|
async interrupt() {
|
|
827
|
+
// No turn of our own to stop — and that is not a failure (#373): a pause
|
|
828
|
+
// that lands between turns has nothing to interrupt and must not be read as
|
|
829
|
+
// a control channel that said no.
|
|
770
830
|
if (!this.threadId || !this.activeTurnId)
|
|
771
|
-
return;
|
|
831
|
+
return 'idle';
|
|
772
832
|
try {
|
|
773
833
|
await this.client.request('turn/interrupt', {
|
|
774
834
|
threadId: this.threadId,
|
|
775
835
|
turnId: this.activeTurnId,
|
|
776
836
|
});
|
|
837
|
+
return 'accepted';
|
|
777
838
|
}
|
|
778
839
|
catch (error) {
|
|
779
840
|
if (/no active turn/i.test(describe(error)))
|
|
780
|
-
return;
|
|
841
|
+
return 'idle';
|
|
781
842
|
log.warn('codex: interrupt failed', { error: describe(error) });
|
|
843
|
+
return this.stopped ? 'idle' : 'refused';
|
|
782
844
|
}
|
|
783
845
|
}
|
|
784
846
|
conversationAnchor() {
|
|
@@ -1805,6 +1867,9 @@ class CodexSession {
|
|
|
1805
1867
|
this.emit({
|
|
1806
1868
|
type: 'error',
|
|
1807
1869
|
message: `codex app-server exited before the session was ready (code ${info.code ?? 'null'})`,
|
|
1870
|
+
// #373: the process is what ended, not a turn. `stopped` is already
|
|
1871
|
+
// excluded above, so this is always an exit nobody here asked for.
|
|
1872
|
+
processGone: true,
|
|
1808
1873
|
});
|
|
1809
1874
|
}
|
|
1810
1875
|
this.finish();
|
|
@@ -2099,6 +2164,51 @@ function describe(error) {
|
|
|
2099
2164
|
function isMissingRollout(error) {
|
|
2100
2165
|
return /no rollout found|not found/i.test(describe(error));
|
|
2101
2166
|
}
|
|
2167
|
+
/**
|
|
2168
|
+
* The conversation did not open, and a fresh one is the way out (#370).
|
|
2169
|
+
*
|
|
2170
|
+
* Two ways for that to happen, and until this ticket only the first counted:
|
|
2171
|
+
*
|
|
2172
|
+
* - the app-server REFUSED — there is no such rollout. Survivable: the retry
|
|
2173
|
+
* below, then a fresh thread, and the branch and files are untouched.
|
|
2174
|
+
* - the app-server never ANSWERED. Read as «something we do not recognise»,
|
|
2175
|
+
* which fails the session outright — so a conversation that had merely grown
|
|
2176
|
+
* slow was more fatal than one that had been thrown away.
|
|
2177
|
+
*
|
|
2178
|
+
* The timeout is recognised by its class and not by its sentence: `RpcTimeoutError`
|
|
2179
|
+
* is minted in exactly one place, whereas «timed out» is a phrase any layer
|
|
2180
|
+
* between here and the provider may use about something else entirely.
|
|
2181
|
+
*/
|
|
2182
|
+
function isResumeRecoverable(error) {
|
|
2183
|
+
return error instanceof RpcTimeoutError || isMissingRollout(error);
|
|
2184
|
+
}
|
|
2185
|
+
/**
|
|
2186
|
+
* Did the app-server refuse a parameter it does not know? (#364)
|
|
2187
|
+
*
|
|
2188
|
+
* JSON-RPC says «invalid params» is -32602, and that code alone is taken at its
|
|
2189
|
+
* word. App-server also answers -32600 for a field gated behind a capability,
|
|
2190
|
+
* but -32600 is what a stale rollout arrives as too (see the QA-101 tests), so
|
|
2191
|
+
* there the message has to name the field as well — reading a lost conversation
|
|
2192
|
+
* as «this build is old» would double every attempt on the one path that is
|
|
2193
|
+
* already losing it.
|
|
2194
|
+
*
|
|
2195
|
+
* Anything else — a timeout, a missing rollout, a transport failure — must NOT
|
|
2196
|
+
* be re-sent, or one slow resume would become two.
|
|
2197
|
+
*/
|
|
2198
|
+
function isUnknownParam(error) {
|
|
2199
|
+
if (error instanceof RpcTimeoutError)
|
|
2200
|
+
return false;
|
|
2201
|
+
if (!(error instanceof RpcError))
|
|
2202
|
+
return false;
|
|
2203
|
+
// A stale rollout also arrives as -32600 (see the QA-101 tests), and reading
|
|
2204
|
+
// that as «this build is old» would double every resume attempt on the very
|
|
2205
|
+
// path that is already losing a conversation.
|
|
2206
|
+
if (isMissingRollout(error))
|
|
2207
|
+
return false;
|
|
2208
|
+
if (error.code === -32602)
|
|
2209
|
+
return true;
|
|
2210
|
+
return /unknown field|unknown parameter|unexpected field|excludeTurns/i.test(error.message);
|
|
2211
|
+
}
|
|
2102
2212
|
function delay(ms) {
|
|
2103
2213
|
return new Promise((resolve) => {
|
|
2104
2214
|
const timer = setTimeout(resolve, ms);
|
package/dist/adapters/types.d.ts
CHANGED
|
@@ -543,7 +543,45 @@ export type AgentEvent = {
|
|
|
543
543
|
* all. Both still have to withdraw the feed cut and say what happened.
|
|
544
544
|
*/
|
|
545
545
|
recovered?: boolean;
|
|
546
|
+
/**
|
|
547
|
+
* The agent PROCESS ended with this — it is not an error the running
|
|
548
|
+
* agent reported (#373).
|
|
549
|
+
*
|
|
550
|
+
* Set only where the adapter's own read loop broke, i.e. where the CLI
|
|
551
|
+
* exited under it. The supervisor needs the difference to tell the tail of
|
|
552
|
+
* a stop it asked for from a process that died on its own: closing a
|
|
553
|
+
* process during a pause produces an ending, and an ending must not read
|
|
554
|
+
* as a failure — while a process that fell over BEFORE we closed it has
|
|
555
|
+
* genuinely failed and must still say so.
|
|
556
|
+
*
|
|
557
|
+
* A flag rather than a sentence on purpose. The wording of these errors
|
|
558
|
+
* comes from the CLI and changes with it; «who ended this process» is a
|
|
559
|
+
* fact only the adapter knows.
|
|
560
|
+
*/
|
|
561
|
+
processGone?: boolean;
|
|
546
562
|
};
|
|
563
|
+
/**
|
|
564
|
+
* What became of an interrupt request (#373).
|
|
565
|
+
*
|
|
566
|
+
* `interrupt()` used to answer `void`, so «the CLI took the stop» and «the
|
|
567
|
+
* control channel refused it» were the same event from the supervisor's side —
|
|
568
|
+
* and a refusal that arrived one line before a pause was filed as a clean
|
|
569
|
+
* cancellation. The three outcomes are what a stop coordinator has to tell
|
|
570
|
+
* apart to decide whether the process may be closed quietly.
|
|
571
|
+
*/
|
|
572
|
+
export type InterruptOutcome =
|
|
573
|
+
/** The CLI acknowledged the request. A result for the turn should follow. */
|
|
574
|
+
'accepted'
|
|
575
|
+
/**
|
|
576
|
+
* There is nothing to stop.
|
|
577
|
+
*
|
|
578
|
+
* Either no turn was running, or this session has already been closed — by us
|
|
579
|
+
* — and the control request went with it. Both mean the same thing to the
|
|
580
|
+
* caller: no result is coming, and nobody refused anything.
|
|
581
|
+
*/
|
|
582
|
+
| 'idle'
|
|
583
|
+
/** The control channel said no, or died answering. The turn may still run. */
|
|
584
|
+
| 'refused';
|
|
547
585
|
export interface AgentSession {
|
|
548
586
|
/** Ends when the underlying agent process is gone. */
|
|
549
587
|
events: AsyncIterable<AgentEvent>;
|
|
@@ -615,8 +653,14 @@ export interface AgentSession {
|
|
|
615
653
|
*/
|
|
616
654
|
gitPolicy?: AgentGitPolicy;
|
|
617
655
|
}): void;
|
|
618
|
-
/**
|
|
619
|
-
|
|
656
|
+
/**
|
|
657
|
+
* Interrupt the current turn (session stays resumable).
|
|
658
|
+
*
|
|
659
|
+
* Answers what became of the request (#373). The supervisor closes the
|
|
660
|
+
* process itself after a pause, and «may I close it quietly» has a different
|
|
661
|
+
* answer for a stop the CLI took and one it refused.
|
|
662
|
+
*/
|
|
663
|
+
interrupt(): Promise<InterruptOutcome>;
|
|
620
664
|
/**
|
|
621
665
|
* An opaque id naming the conversation as it stands RIGHT NOW (ticket #126).
|
|
622
666
|
*
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import type { AgentEvent, AgentSession, InterruptOutcome } from './adapters/types.js';
|
|
2
|
+
/**
|
|
3
|
+
* One managed stop of one agent process (#373).
|
|
4
|
+
*
|
|
5
|
+
* The defect this exists for: stopping a turn used to be a boolean and a hope.
|
|
6
|
+
* The Claude adapter armed a single `aborting` flag, EVERY result read and
|
|
7
|
+
* cleared it, and the second result of the same stop therefore arrived looking
|
|
8
|
+
* like a failure — `turn_end{ok:false}` → `settleTurnStatus` → FAILED, which is
|
|
9
|
+
* terminal. A session that had merely run out of plan limit was buried with a
|
|
10
|
+
* live clock still ticking on it, and nothing would ever wake it again.
|
|
11
|
+
*
|
|
12
|
+
* A cycle is bound to ONE process instance and to one reason. Everything that
|
|
13
|
+
* arrives from that instance while the cycle runs — a late result, the tail of
|
|
14
|
+
* a tool the CLI had already started, the ending our own `close()` produces —
|
|
15
|
+
* belongs to the stop and is not news about the session's health. Everything
|
|
16
|
+
* from any OTHER instance (the process we start after the pause is lifted) is
|
|
17
|
+
* outside it, which is what keeps a real failure after a resume visible.
|
|
18
|
+
*
|
|
19
|
+
* Deliberately a small module of plain rules: the decisions below are the ones
|
|
20
|
+
* a mistake is expensive in, and they are worth being able to test without a
|
|
21
|
+
* supervisor, a fake CLI and a socket.
|
|
22
|
+
*/
|
|
23
|
+
/**
|
|
24
|
+
* How far the stop has got.
|
|
25
|
+
*
|
|
26
|
+
* - `interrupting` — the request is out; the process is still ours to lose.
|
|
27
|
+
* - `parking` — the turn has ended (or will not end), and we are closing
|
|
28
|
+
* the process ourselves. From here on its noises are ours.
|
|
29
|
+
* - `parked` — the event stream ended. Nothing else can arrive.
|
|
30
|
+
*/
|
|
31
|
+
export type StopPhase = 'interrupting' | 'parking' | 'parked';
|
|
32
|
+
/**
|
|
33
|
+
* Who asked.
|
|
34
|
+
*
|
|
35
|
+
* `user` keeps the old contract exactly: one Stop, one ending, and the NEXT
|
|
36
|
+
* genuine failure still reaches the person. `pause` is the one that closes the
|
|
37
|
+
* process and therefore has to own what closing it produces.
|
|
38
|
+
*/
|
|
39
|
+
export type StopReason = 'user' | 'pause';
|
|
40
|
+
export interface StopCycle {
|
|
41
|
+
/** The adapter session this cycle stops. Identity only — never called. */
|
|
42
|
+
readonly session: AgentSession;
|
|
43
|
+
/**
|
|
44
|
+
* Who asked — and it can be RAISED while the stop is in flight.
|
|
45
|
+
*
|
|
46
|
+
* A clock landing on a Stop the person pressed a second earlier is the same
|
|
47
|
+
* stop, not a second one: the CLI has one outstanding interrupt and will
|
|
48
|
+
* answer it once. But it is now a PAUSE, and a pause closes the process and
|
|
49
|
+
* owns what closing it produces. Left as `user` the cycle would step aside at
|
|
50
|
+
* the first ending and the second result would file the session FAILED —
|
|
51
|
+
* the incident, reached through a door the fix did not cover.
|
|
52
|
+
*/
|
|
53
|
+
reason: StopReason;
|
|
54
|
+
/**
|
|
55
|
+
* Which clock this stop belongs to, for the log.
|
|
56
|
+
*
|
|
57
|
+
* Deliberately NOT a guard: what decides whether two stops are the same one
|
|
58
|
+
* is the process instance, and «a clock lifted and set again» is already
|
|
59
|
+
* answered by the release dropping a cycle that closed nothing. The number is
|
|
60
|
+
* here so a line in journald can be tied to the pause that caused it —
|
|
61
|
+
* reading two stops in one session and not knowing whether they answered one
|
|
62
|
+
* clock or two is how the incident took a day to reconstruct.
|
|
63
|
+
*/
|
|
64
|
+
readonly epoch: number;
|
|
65
|
+
phase: StopPhase;
|
|
66
|
+
/** The single interrupt round-trip. Every later request joins this one. */
|
|
67
|
+
inFlight: Promise<void> | null;
|
|
68
|
+
/** What the CLI said about the request, once it said anything. */
|
|
69
|
+
outcome: InterruptOutcome | null;
|
|
70
|
+
/** The control channel refused while the process was still running. */
|
|
71
|
+
refused: boolean;
|
|
72
|
+
/**
|
|
73
|
+
* We closed this process ourselves.
|
|
74
|
+
*
|
|
75
|
+
* The line invariant 7 is drawn on. Everything a process says after we have
|
|
76
|
+
* closed it is the closing; everything it says before is still news about the
|
|
77
|
+
* session. `phase` alone cannot answer this — a stop that is refused parking
|
|
78
|
+
* (an open permission card) reaches `parking` with the process untouched.
|
|
79
|
+
*/
|
|
80
|
+
closed: boolean;
|
|
81
|
+
/** The one `turn_end` this cycle publishes has gone out. */
|
|
82
|
+
turnEndSent: boolean;
|
|
83
|
+
/**
|
|
84
|
+
* The stop has run its course.
|
|
85
|
+
*
|
|
86
|
+
* It still OWNS this process's endings — a late result after a refused park
|
|
87
|
+
* is exactly the incident's second result — but it no longer stands in the way
|
|
88
|
+
* of a new stop: a turn that starts minutes later, from inside a CLI nobody
|
|
89
|
+
* closed, has to be stoppable in its own right.
|
|
90
|
+
*/
|
|
91
|
+
settled: boolean;
|
|
92
|
+
/** There was a turn to stop — so an ending is owed to the feed. */
|
|
93
|
+
readonly hadTurn: boolean;
|
|
94
|
+
/**
|
|
95
|
+
* Tool calls already in flight when the stop began.
|
|
96
|
+
*
|
|
97
|
+
* The reason the incident fired a SECOND interrupt: the result of the
|
|
98
|
+
* question this runner had just withdrawn came back as an ordinary tool
|
|
99
|
+
* event, and «the agent is producing output» could not tell it from a turn
|
|
100
|
+
* starting up. Ownership answers it — a result for one of these ids is the
|
|
101
|
+
* tail of the work being stopped, not new work.
|
|
102
|
+
*/
|
|
103
|
+
readonly toolsAtStop: ReadonlySet<string>;
|
|
104
|
+
/** Waiting for the result that should follow an accepted interrupt. */
|
|
105
|
+
settleTimer?: ReturnType<typeof setTimeout>;
|
|
106
|
+
}
|
|
107
|
+
/** Is this cycle the one that owns `session`, and is it still live? */
|
|
108
|
+
export declare function ownsProcess(cycle: StopCycle | undefined, session: AgentSession | null): boolean;
|
|
109
|
+
/**
|
|
110
|
+
* What to do with a `turn_end` that arrived inside a stop cycle.
|
|
111
|
+
*
|
|
112
|
+
* - `ordinary` — nothing special: a manual Stop keeps the contract it has had
|
|
113
|
+
* since QA-120, including the rule that the next real failure is not hidden.
|
|
114
|
+
* - `stopped` — the one ending this cycle publishes: `ok`, `aborted`, and the
|
|
115
|
+
* facts the turn actually carried.
|
|
116
|
+
* - `tail` — a second (or third) ending of a turn that has already been
|
|
117
|
+
* closed out. The incident's `error_during_execution`, 1.2 s late.
|
|
118
|
+
*/
|
|
119
|
+
export declare function turnEndVerdict(cycle: StopCycle): 'ordinary' | 'stopped' | 'tail';
|
|
120
|
+
/**
|
|
121
|
+
* What to do with an `error` that arrived inside a stop cycle.
|
|
122
|
+
*
|
|
123
|
+
* The one place the difference between «the process fell over» and «the process
|
|
124
|
+
* was closed» has to be paid attention to, and the only honest source for it is
|
|
125
|
+
* the adapter's own `processGone` — never the sentence in the message, which is
|
|
126
|
+
* written by the CLI and changes with it.
|
|
127
|
+
*/
|
|
128
|
+
export declare function errorVerdict(cycle: StopCycle, processGone: boolean): 'failure' | 'tail';
|
|
129
|
+
/**
|
|
130
|
+
* Is this event work STARTING, rather than the tail of what is being stopped?
|
|
131
|
+
*
|
|
132
|
+
* Only a genuinely new turn deserves a second stop. A tool result belonging to
|
|
133
|
+
* a call that was already running when the stop began is the first stop still
|
|
134
|
+
* finishing — firing another interrupt at it is what gave the incident two
|
|
135
|
+
* interrupts, two `aborting` arms and one result too few to spend them.
|
|
136
|
+
*/
|
|
137
|
+
export declare function isNewWork(event: AgentEvent, cycle: StopCycle | undefined): boolean;
|
|
138
|
+
/**
|
|
139
|
+
* Is this the result of a tool call that was already running when we stopped?
|
|
140
|
+
*
|
|
141
|
+
* The incident's `tool_result` at seq 832 was exactly this: the withdrawn
|
|
142
|
+
* question's own refusal, arriving three seconds after the stop. Kept apart
|
|
143
|
+
* from «a result we never saw start» rather than lumping every result together
|
|
144
|
+
* — the two say different things about the process, and the diagnostic log has
|
|
145
|
+
* to be able to say which one happened.
|
|
146
|
+
*/
|
|
147
|
+
export declare function isStoppedToolTail(cycle: StopCycle, event: AgentEvent): boolean;
|
|
148
|
+
//# sourceMappingURL=stop-cycle.d.ts.map
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/** Is this cycle the one that owns `session`, and is it still live? */
|
|
2
|
+
export function ownsProcess(cycle, session) {
|
|
3
|
+
return cycle !== undefined && session !== null && cycle.session === session;
|
|
4
|
+
}
|
|
5
|
+
/**
|
|
6
|
+
* What to do with a `turn_end` that arrived inside a stop cycle.
|
|
7
|
+
*
|
|
8
|
+
* - `ordinary` — nothing special: a manual Stop keeps the contract it has had
|
|
9
|
+
* since QA-120, including the rule that the next real failure is not hidden.
|
|
10
|
+
* - `stopped` — the one ending this cycle publishes: `ok`, `aborted`, and the
|
|
11
|
+
* facts the turn actually carried.
|
|
12
|
+
* - `tail` — a second (or third) ending of a turn that has already been
|
|
13
|
+
* closed out. The incident's `error_during_execution`, 1.2 s late.
|
|
14
|
+
*/
|
|
15
|
+
export function turnEndVerdict(cycle) {
|
|
16
|
+
if (cycle.reason === 'user')
|
|
17
|
+
return 'ordinary';
|
|
18
|
+
if (cycle.turnEndSent)
|
|
19
|
+
return 'tail';
|
|
20
|
+
return 'stopped';
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* What to do with an `error` that arrived inside a stop cycle.
|
|
24
|
+
*
|
|
25
|
+
* The one place the difference between «the process fell over» and «the process
|
|
26
|
+
* was closed» has to be paid attention to, and the only honest source for it is
|
|
27
|
+
* the adapter's own `processGone` — never the sentence in the message, which is
|
|
28
|
+
* written by the CLI and changes with it.
|
|
29
|
+
*/
|
|
30
|
+
export function errorVerdict(cycle, processGone) {
|
|
31
|
+
// A manual Stop does not close anything, so nothing here is ours.
|
|
32
|
+
if (cycle.reason === 'user')
|
|
33
|
+
return 'failure';
|
|
34
|
+
// We have not closed it: a process dying on its own is a real failure, and
|
|
35
|
+
// burying it under the pause would be the original defect wearing the fix's
|
|
36
|
+
// clothes. Everything else from a process we asked to stop is the stopping.
|
|
37
|
+
if (!cycle.closed)
|
|
38
|
+
return processGone ? 'failure' : 'tail';
|
|
39
|
+
// We closed it. Whatever it says on the way out is the closing.
|
|
40
|
+
return 'tail';
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Is this event work STARTING, rather than the tail of what is being stopped?
|
|
44
|
+
*
|
|
45
|
+
* Only a genuinely new turn deserves a second stop. A tool result belonging to
|
|
46
|
+
* a call that was already running when the stop began is the first stop still
|
|
47
|
+
* finishing — firing another interrupt at it is what gave the incident two
|
|
48
|
+
* interrupts, two `aborting` arms and one result too few to spend them.
|
|
49
|
+
*/
|
|
50
|
+
export function isNewWork(event, cycle) {
|
|
51
|
+
if (event.type !== 'tool')
|
|
52
|
+
return true;
|
|
53
|
+
if (event.phase === 'use')
|
|
54
|
+
return true;
|
|
55
|
+
// A result is always the tail of something, and the only question is whose.
|
|
56
|
+
// The tail of a call that was ALREADY RUNNING when we stopped is the stop
|
|
57
|
+
// finishing — the incident's seq 832, which fired a second interrupt. A
|
|
58
|
+
// result for a call we did not see start, on a process we asked to stop, is
|
|
59
|
+
// the opposite: something began after the stop and has now finished.
|
|
60
|
+
//
|
|
61
|
+
// Without a cycle there is no ownership to read, and a result on its own is
|
|
62
|
+
// no evidence of a turn beginning — a turn begins with a call, a thought or a
|
|
63
|
+
// sentence. Excluding every result regardless of whose it is is what the plan
|
|
64
|
+
// forbids, and this is the distinction it asks for.
|
|
65
|
+
if (!cycle)
|
|
66
|
+
return false;
|
|
67
|
+
if (event.toolUseId === undefined)
|
|
68
|
+
return false;
|
|
69
|
+
return !cycle.toolsAtStop.has(event.toolUseId);
|
|
70
|
+
}
|
|
71
|
+
/**
|
|
72
|
+
* Is this the result of a tool call that was already running when we stopped?
|
|
73
|
+
*
|
|
74
|
+
* The incident's `tool_result` at seq 832 was exactly this: the withdrawn
|
|
75
|
+
* question's own refusal, arriving three seconds after the stop. Kept apart
|
|
76
|
+
* from «a result we never saw start» rather than lumping every result together
|
|
77
|
+
* — the two say different things about the process, and the diagnostic log has
|
|
78
|
+
* to be able to say which one happened.
|
|
79
|
+
*/
|
|
80
|
+
export function isStoppedToolTail(cycle, event) {
|
|
81
|
+
return (event.type === 'tool' &&
|
|
82
|
+
event.phase === 'result' &&
|
|
83
|
+
event.toolUseId !== undefined &&
|
|
84
|
+
cycle.toolsAtStop.has(event.toolUseId));
|
|
85
|
+
}
|
|
86
|
+
//# sourceMappingURL=stop-cycle.js.map
|