@bridge4dev/runner 0.59.1 → 0.61.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 +197 -21
- package/dist/adapters/codex.js +22 -2
- package/dist/adapters/questions.js +1 -0
- package/dist/adapters/types.d.ts +42 -1
- package/dist/agent-binary.d.ts +45 -4
- package/dist/agent-binary.js +53 -4
- package/dist/attachments.d.ts +6 -0
- package/dist/attachments.js +162 -11
- package/dist/commit-message.js +8 -1
- package/dist/index.js +59 -2
- package/dist/journal.d.ts +40 -0
- package/dist/journal.js +82 -0
- package/dist/protocol.d.ts +52 -0
- package/dist/protocol.js +14 -0
- package/dist/self-update.d.ts +22 -0
- package/dist/self-update.js +22 -0
- package/dist/supervisor.d.ts +123 -2
- package/dist/supervisor.js +598 -91
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -19,7 +19,7 @@ import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs
|
|
|
19
19
|
import { fsView } from './fsview.js';
|
|
20
20
|
import { publishFile } from './file-publish.js';
|
|
21
21
|
import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
|
|
22
|
-
import { selfUpdate } from './self-update.js';
|
|
22
|
+
import { isSupervisedProcess, selfUpdate } from './self-update.js';
|
|
23
23
|
import { installAgent } from './agent-install.js';
|
|
24
24
|
import { autoUpdateRefusal, claimAgentAutoUpdate } from './agent-auto-update.js';
|
|
25
25
|
import { pruneNativeClaudeVersions } from './agent-cleanup.js';
|
|
@@ -123,6 +123,8 @@ export class Supervisor {
|
|
|
123
123
|
static EMPTY_TURN_SETTLE_MS = 25_000;
|
|
124
124
|
/** The window actually used — the constant, or a test's own shorter one. */
|
|
125
125
|
emptyTurnSettleMs;
|
|
126
|
+
/** The compaction net actually used — the constant, or a test's own. */
|
|
127
|
+
compactionWatchdogMs;
|
|
126
128
|
rateLimitsResendMs;
|
|
127
129
|
/** The stop-settle window actually used — the constant, or a test's own. */
|
|
128
130
|
stopSettleMs;
|
|
@@ -168,8 +170,31 @@ export class Supervisor {
|
|
|
168
170
|
* which of the two is running.
|
|
169
171
|
*/
|
|
170
172
|
installInFlight = null;
|
|
173
|
+
/**
|
|
174
|
+
* A `runner_restart` has been agreed to and the exit is coming (#396).
|
|
175
|
+
*
|
|
176
|
+
* Never cleared: the process has ~1.5 s left, and everything it must refuse in
|
|
177
|
+
* that window it must refuse for good.
|
|
178
|
+
*/
|
|
179
|
+
restarting = false;
|
|
171
180
|
/** A restore-point collection is running; a second reconnect must not start another (#388). */
|
|
172
181
|
checkpointGcInFlight = false;
|
|
182
|
+
/**
|
|
183
|
+
* Sessions the current `hello_ack` names, while `reconcile` is walking them
|
|
184
|
+
* (#392).
|
|
185
|
+
*
|
|
186
|
+
* The unacked replay at the top of `reconcile` sends every journal's
|
|
187
|
+
* leftovers before a single session is registered, and the acks come back
|
|
188
|
+
* while the loop is still awaiting an earlier session's worktree. The ack
|
|
189
|
+
* handler deletes the journal of a session it does not know once nothing is
|
|
190
|
+
* unacked — which used to be exactly the journal the loop was about to
|
|
191
|
+
* restore from: its open cards, its queued messages, its anchor, gone a
|
|
192
|
+
* moment before they were read. Membership here is what «this session is
|
|
193
|
+
* known» means until the loop reaches it.
|
|
194
|
+
*/
|
|
195
|
+
reconciling = new Map();
|
|
196
|
+
/** How long a dashboard-requested compaction may go unreported before the session is handed back. */
|
|
197
|
+
static COMPACTION_WATCHDOG_MS = 10 * 60_000;
|
|
173
198
|
/** Session 14: one project-recipe run per machine, and its verdict queue. */
|
|
174
199
|
verify;
|
|
175
200
|
verifyReports = new VerifyReportQueue();
|
|
@@ -178,6 +203,7 @@ export class Supervisor {
|
|
|
178
203
|
this.opts = opts;
|
|
179
204
|
this.journals = opts.journals ?? new JournalStore();
|
|
180
205
|
this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
|
|
206
|
+
this.compactionWatchdogMs = opts.compactionWatchdogMs ?? Supervisor.COMPACTION_WATCHDOG_MS;
|
|
181
207
|
this.rateLimitsResendMs = opts.rateLimitsResendMs ?? RATE_LIMITS_RESEND_INTERVAL_MS;
|
|
182
208
|
this.stopSettleMs = opts.stopSettleMs ?? Supervisor.STOP_SETTLE_MS;
|
|
183
209
|
this.verify = new VerifyRunner({
|
|
@@ -314,12 +340,41 @@ export class Supervisor {
|
|
|
314
340
|
* sent — the next `hello_ack` or the hourly tick carries the same fact, and
|
|
315
341
|
* the measurement behind it is cached, so retrying is nearly free.
|
|
316
342
|
*/
|
|
343
|
+
/**
|
|
344
|
+
* The adapter for this agent — built now if it was not there at boot (#395).
|
|
345
|
+
*
|
|
346
|
+
* `opts.adapters` is assembled once, when the daemon starts, from what was on
|
|
347
|
+
* PATH at that moment. That is the SAME frozen snapshot `capabilities.agents`
|
|
348
|
+
* was, one layer down: press «Install Codex» on a running machine and the
|
|
349
|
+
* measurement updates, the card lights up, the API lets the session through —
|
|
350
|
+
* and this map still has no CODEX in it, so the session is created and dies
|
|
351
|
+
* at once. Asking the factory closes the last of the three lists.
|
|
352
|
+
*
|
|
353
|
+
* Built at most once per process and remembered: preparing Codex's isolated
|
|
354
|
+
* home clones ~90 MB on a machine that has never run it, and that is a price
|
|
355
|
+
* to pay on the first session, not on every one.
|
|
356
|
+
*/
|
|
357
|
+
adapterFor(agent) {
|
|
358
|
+
const known = this.opts.adapters[agent];
|
|
359
|
+
if (known)
|
|
360
|
+
return known;
|
|
361
|
+
const built = this.opts.makeAdapter?.(agent) ?? null;
|
|
362
|
+
if (built)
|
|
363
|
+
this.opts.adapters[agent] = built;
|
|
364
|
+
return built ?? undefined;
|
|
365
|
+
}
|
|
317
366
|
/**
|
|
318
367
|
* Why an install cannot start right now, in words for the person who pressed.
|
|
319
368
|
*
|
|
320
369
|
* `null` means the way is clear.
|
|
321
370
|
*/
|
|
322
371
|
installBusyReason() {
|
|
372
|
+
// #396: the same window `self_update` documents below, for a plain restart.
|
|
373
|
+
// The process only exits ~1.5 s after the reply, and the API frees its own
|
|
374
|
+
// lock the moment the reply lands — so an install that slipped into that
|
|
375
|
+
// window would be an `npm install -g` the exit kills half-written.
|
|
376
|
+
if (this.restarting)
|
|
377
|
+
return 'This runner is restarting';
|
|
323
378
|
if (this.installInFlight === 'self_update')
|
|
324
379
|
return 'An update is already running';
|
|
325
380
|
if (this.installInFlight === 'agent_install') {
|
|
@@ -1775,7 +1830,8 @@ export class Supervisor {
|
|
|
1775
1830
|
const journal = this.journals.open(frame.sessionId);
|
|
1776
1831
|
journal.ack(frame.seq); // nack retires the seq too — it is permanent
|
|
1777
1832
|
const running = this.sessions.get(frame.sessionId);
|
|
1778
|
-
|
|
1833
|
+
// Not while a reconnect is still about to restore it — see `reconciling`.
|
|
1834
|
+
if (!running && !this.reconciling.has(frame.sessionId) && journal.unacked().length === 0) {
|
|
1779
1835
|
this.journals.closeAndDelete(frame.sessionId);
|
|
1780
1836
|
}
|
|
1781
1837
|
break;
|
|
@@ -1788,7 +1844,15 @@ export class Supervisor {
|
|
|
1788
1844
|
}
|
|
1789
1845
|
}
|
|
1790
1846
|
// ─── Lifecycle ─────────────────────────────────────────────────────
|
|
1791
|
-
async startSession(descriptor
|
|
1847
|
+
async startSession(descriptor,
|
|
1848
|
+
/**
|
|
1849
|
+
* What to tell a person about a card a previous life of this session left
|
|
1850
|
+
* open (#392): `runner_restarted` from the reconnect, `not_held` from an
|
|
1851
|
+
* ordinary `session_start` — a «Continue» after an ending the runner never
|
|
1852
|
+
* got to write down, where the honest fact is only that no process holds
|
|
1853
|
+
* the ask any more.
|
|
1854
|
+
*/
|
|
1855
|
+
withdrawReason = 'not_held') {
|
|
1792
1856
|
const existing = this.sessions.get(descriptor.id);
|
|
1793
1857
|
if (existing) {
|
|
1794
1858
|
// A resume (higher epoch) can land while the previous life is still
|
|
@@ -1823,7 +1887,7 @@ export class Supervisor {
|
|
|
1823
1887
|
});
|
|
1824
1888
|
return;
|
|
1825
1889
|
}
|
|
1826
|
-
if (!this.
|
|
1890
|
+
if (!this.adapterFor(descriptor.agent)) {
|
|
1827
1891
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
1828
1892
|
errorMessage: `${AGENT_LABELS[descriptor.agent] ?? descriptor.agent} is not installed on this server`,
|
|
1829
1893
|
});
|
|
@@ -1884,6 +1948,11 @@ export class Supervisor {
|
|
|
1884
1948
|
// exist (session 9).
|
|
1885
1949
|
running.pendingMessages.push(...running.journal.pending());
|
|
1886
1950
|
this.sessions.set(descriptor.id, running);
|
|
1951
|
+
// A card a previous life of this session left open is dead now: whatever
|
|
1952
|
+
// starts here is a new process, and the process that asked is gone (#392).
|
|
1953
|
+
// A first start finds nothing; a resume after an ungraceful ending finds
|
|
1954
|
+
// the cards the restore branch would otherwise have closed.
|
|
1955
|
+
this.withdrawJournaledQuestions(running, withdrawReason);
|
|
1887
1956
|
/**
|
|
1888
1957
|
* Messages that arrived for a session this runner did not know yet.
|
|
1889
1958
|
*
|
|
@@ -2073,7 +2142,7 @@ export class Supervisor {
|
|
|
2073
2142
|
*/
|
|
2074
2143
|
launchAgent(running, prompt, resumeId) {
|
|
2075
2144
|
const { descriptor } = running;
|
|
2076
|
-
const adapter = this.
|
|
2145
|
+
const adapter = this.adapterFor(descriptor.agent);
|
|
2077
2146
|
if (!adapter || !running.worktreePath || !running.branch)
|
|
2078
2147
|
return LAUNCH_REFUSED;
|
|
2079
2148
|
/**
|
|
@@ -2170,6 +2239,8 @@ export class Supervisor {
|
|
|
2170
2239
|
delete running.stopCycle;
|
|
2171
2240
|
running.liveToolUses.clear();
|
|
2172
2241
|
running.openPermissions.clear();
|
|
2242
|
+
// …and a compaction the previous process never reported the end of (#348).
|
|
2243
|
+
this.endCompaction(running, 'Compaction ended with the agent process.', 'warn');
|
|
2173
2244
|
try {
|
|
2174
2245
|
const agentPrompt = this.resolveAgentPrompt(running, resuming && !rewinding);
|
|
2175
2246
|
const workspaceContext = composeWorkspaceContext(descriptor, agentPrompt?.text);
|
|
@@ -2621,6 +2692,18 @@ export class Supervisor {
|
|
|
2621
2692
|
// Both sets describe THIS process and nothing else (#373).
|
|
2622
2693
|
running.liveToolUses.clear();
|
|
2623
2694
|
running.openPermissions.clear();
|
|
2695
|
+
// So does a compaction it was in the middle of (#348). Guarded like the
|
|
2696
|
+
// withdrawal below: a journal that cannot be written must not take the
|
|
2697
|
+
// exit path with it.
|
|
2698
|
+
try {
|
|
2699
|
+
this.endCompaction(running, 'Compaction ended with the agent process.', 'warn');
|
|
2700
|
+
}
|
|
2701
|
+
catch (error) {
|
|
2702
|
+
log.warn('supervisor: could not close the compaction', {
|
|
2703
|
+
sessionId: descriptor.id,
|
|
2704
|
+
error: String(error),
|
|
2705
|
+
});
|
|
2706
|
+
}
|
|
2624
2707
|
try {
|
|
2625
2708
|
this.clearApiRetry(running);
|
|
2626
2709
|
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
@@ -2849,6 +2932,37 @@ export class Supervisor {
|
|
|
2849
2932
|
});
|
|
2850
2933
|
}
|
|
2851
2934
|
}
|
|
2935
|
+
/**
|
|
2936
|
+
* Close out the asks a PREVIOUS process of this runner left open (#392).
|
|
2937
|
+
*
|
|
2938
|
+
* `withdrawOpenQuestions` above reads `running.openQuestions`, and that set
|
|
2939
|
+
* is memory: a process that died without reaching `shutdown()` — killed,
|
|
2940
|
+
* crashed, machine rebooted — took it along, and the restore used to create
|
|
2941
|
+
* a fresh empty set and write only «Runner reconnected». The card in the
|
|
2942
|
+
* browser is closed by one thing, a `question_resolved` with its askId in
|
|
2943
|
+
* the feed, so it stayed answerable for ever, and everything typed into it
|
|
2944
|
+
* was lost (the 07.09.2026 session in the ticket: six restarts, zero
|
|
2945
|
+
* tombstones).
|
|
2946
|
+
*
|
|
2947
|
+
* The journal is the set that survived: `SessionJournal.append` notes every
|
|
2948
|
+
* card this runner published and every resolution it published, whoever
|
|
2949
|
+
* wrote it. That is also the «already closed» check the plan asks to name:
|
|
2950
|
+
* a graceful `shutdown()` sends its `question_resolved` through the same
|
|
2951
|
+
* `append`, so by the time a restore runs, a card closed that way is no
|
|
2952
|
+
* longer in the set — one tombstone per card, never two.
|
|
2953
|
+
*/
|
|
2954
|
+
withdrawJournaledQuestions(running, reason) {
|
|
2955
|
+
for (const askId of running.journal.openAskIds()) {
|
|
2956
|
+
running.openQuestions.delete(askId);
|
|
2957
|
+
// `sendEvent` → `journal.append` marks the card closed as a side effect.
|
|
2958
|
+
this.sendEvent(running, 'question_resolved', {
|
|
2959
|
+
askId,
|
|
2960
|
+
outcome: 'invalidated',
|
|
2961
|
+
source: 'runner',
|
|
2962
|
+
reason,
|
|
2963
|
+
});
|
|
2964
|
+
}
|
|
2965
|
+
}
|
|
2852
2966
|
/**
|
|
2853
2967
|
* Deliver messages that were held because every slot was taken.
|
|
2854
2968
|
*
|
|
@@ -3049,6 +3163,11 @@ export class Supervisor {
|
|
|
3049
3163
|
// #366: the ring at rest must be exact – the value the gate held back
|
|
3050
3164
|
// during the turn goes out first, so it is older than `turn_end` by seq.
|
|
3051
3165
|
this.flushHeldContextUsage(running);
|
|
3166
|
+
// A turn ending is the agent speaking; the compaction net is for silence —
|
|
3167
|
+
// and on Claude the turn IS the compaction, so a compaction still open here
|
|
3168
|
+
// ended with it, whatever the adapter managed to say (a cancel before the
|
|
3169
|
+
// CLI's first «compacting» status, a status stream that lost the end).
|
|
3170
|
+
this.endCompaction(running, event.aborted ? 'Compaction cancelled.' : 'Compaction ended with the turn.');
|
|
3052
3171
|
this.sendEvent(running, 'turn_end', {
|
|
3053
3172
|
ok: event.ok,
|
|
3054
3173
|
errorMessage: event.errorMessage,
|
|
@@ -3205,6 +3324,73 @@ export class Supervisor {
|
|
|
3205
3324
|
clearTimeout(running.emptyTurnTimer);
|
|
3206
3325
|
running.emptyTurnTimer = undefined;
|
|
3207
3326
|
}
|
|
3327
|
+
/**
|
|
3328
|
+
* A compaction is over for a reason other than the agent saying so (#348):
|
|
3329
|
+
* the turn it rode on ended, the process died, a new process was launched.
|
|
3330
|
+
* The feed gets its closing line so the pair is complete, and the runner's
|
|
3331
|
+
* own state is dropped so the NEXT compaction opens a new pair — left set,
|
|
3332
|
+
* every later start would be read as a repeat and written nowhere (QA S4).
|
|
3333
|
+
*/
|
|
3334
|
+
endCompaction(running, text, level = 'info') {
|
|
3335
|
+
this.clearCompactionWatchdog(running);
|
|
3336
|
+
if (!running.compaction)
|
|
3337
|
+
return;
|
|
3338
|
+
delete running.compaction;
|
|
3339
|
+
this.sendEvent(running, 'system_note', {
|
|
3340
|
+
code: level === 'warn' ? 'compaction_failed' : 'compaction_finished',
|
|
3341
|
+
...(level === 'warn' ? { level } : {}),
|
|
3342
|
+
text,
|
|
3343
|
+
});
|
|
3344
|
+
}
|
|
3345
|
+
/** A compaction has begun: say so in the feed, once, and remember who started it (#348). */
|
|
3346
|
+
openCompaction(running, source) {
|
|
3347
|
+
running.compaction = { source };
|
|
3348
|
+
this.sendEvent(running, 'system_note', {
|
|
3349
|
+
code: 'compaction_started',
|
|
3350
|
+
source,
|
|
3351
|
+
text: 'Compacting the conversation…',
|
|
3352
|
+
});
|
|
3353
|
+
}
|
|
3354
|
+
/**
|
|
3355
|
+
* The net under a dashboard-requested compaction (#348) — see
|
|
3356
|
+
* `RunningSession.compactionWatchdog`. Fires only into a session that has
|
|
3357
|
+
* said nothing since: every agent event and every ending clears it.
|
|
3358
|
+
*/
|
|
3359
|
+
armCompactionWatchdog(running) {
|
|
3360
|
+
this.clearCompactionWatchdog(running);
|
|
3361
|
+
const timer = setTimeout(() => {
|
|
3362
|
+
delete running.compactionWatchdog;
|
|
3363
|
+
if (this.isStale(running))
|
|
3364
|
+
return;
|
|
3365
|
+
if (running.lastReported !== 'RUNNING' || running.stopRequested || running.budgetSpent) {
|
|
3366
|
+
return;
|
|
3367
|
+
}
|
|
3368
|
+
// Not over a card the agent is parked on (the compaction is queued
|
|
3369
|
+
// behind it and will run once the person answers), and not over a turn
|
|
3370
|
+
// that demonstrably has tools in flight: both are an agent that is
|
|
3371
|
+
// busy, not one that fell silent.
|
|
3372
|
+
if (running.openQuestions.size > 0 || running.liveToolUses.size > 0)
|
|
3373
|
+
return;
|
|
3374
|
+
log.warn('supervisor: no end of the compaction was reported — handing the session back', {
|
|
3375
|
+
sessionId: running.descriptor.id,
|
|
3376
|
+
});
|
|
3377
|
+
delete running.compaction;
|
|
3378
|
+
this.sendEvent(running, 'system_note', {
|
|
3379
|
+
code: 'compaction_failed',
|
|
3380
|
+
level: 'warn',
|
|
3381
|
+
text: 'The agent did not report the end of the compaction in ten minutes — the session is yours again.',
|
|
3382
|
+
});
|
|
3383
|
+
this.settleTurnStatus(running, running.descriptor, { ok: true });
|
|
3384
|
+
}, this.compactionWatchdogMs);
|
|
3385
|
+
timer.unref();
|
|
3386
|
+
running.compactionWatchdog = timer;
|
|
3387
|
+
}
|
|
3388
|
+
clearCompactionWatchdog(running) {
|
|
3389
|
+
if (!running.compactionWatchdog)
|
|
3390
|
+
return;
|
|
3391
|
+
clearTimeout(running.compactionWatchdog);
|
|
3392
|
+
delete running.compactionWatchdog;
|
|
3393
|
+
}
|
|
3208
3394
|
/**
|
|
3209
3395
|
* Record how many subagents are alive, and say so when it matters (#236).
|
|
3210
3396
|
*
|
|
@@ -3222,16 +3408,35 @@ export class Supervisor {
|
|
|
3222
3408
|
setBackgroundTasks(running, live) {
|
|
3223
3409
|
if (live === running.backgroundTasks)
|
|
3224
3410
|
return;
|
|
3225
|
-
const finished = running.backgroundTasks > 0 && live === 0;
|
|
3226
3411
|
running.backgroundTasks = live;
|
|
3227
3412
|
const resting = running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW'
|
|
3228
3413
|
? running.lastReported
|
|
3229
3414
|
: null;
|
|
3230
|
-
if (
|
|
3415
|
+
if (resting) {
|
|
3416
|
+
// On reaching zero this is the report that makes the session the
|
|
3417
|
+
// person's (#236); on any other change it is the same frame the
|
|
3418
|
+
// five-minute heartbeat would send later, sent now — so the number the
|
|
3419
|
+
// API holds is the number that is true, whenever a restart reads it.
|
|
3231
3420
|
this.reportStatus(running.descriptor.id, resting, {
|
|
3232
3421
|
costUsd: running.costUsd,
|
|
3233
3422
|
activeMs: Supervisor.spentMs(running),
|
|
3234
3423
|
});
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
/**
|
|
3427
|
+
* Mid-turn, the number moves with the frame that carries it (#393).
|
|
3428
|
+
*
|
|
3429
|
+
* The API's copy of the count is written only from `session_status`, and
|
|
3430
|
+
* until now a change during a turn sent none: subagents that finished (or
|
|
3431
|
+
* were spawned) between two status frames left the row holding the count
|
|
3432
|
+
* of the frame before. The row is what a restarting runner is told, and
|
|
3433
|
+
* «the restart stopped 2 background agents» about two agents that had
|
|
3434
|
+
* already reported — or silence about eight that had not — is the exact
|
|
3435
|
+
* lie #393 is about. A same-status frame is legal and moves nothing on the
|
|
3436
|
+
* API but the count and its stamp.
|
|
3437
|
+
*/
|
|
3438
|
+
if (running.lastReported === 'RUNNING') {
|
|
3439
|
+
this.reportStatus(running.descriptor.id, 'RUNNING', {});
|
|
3235
3440
|
}
|
|
3236
3441
|
}
|
|
3237
3442
|
isParkable(running) {
|
|
@@ -3290,8 +3495,9 @@ export class Supervisor {
|
|
|
3290
3495
|
* It happens for real: a background subagent's report wakes a new turn inside
|
|
3291
3496
|
* the agent process (SDK 0.3.226 emits a second `system:init` and a `result`
|
|
3292
3497
|
* carrying `origin: {kind:'task-notification'}`), and nothing here reports a
|
|
3293
|
-
* turn the runner did not start.
|
|
3294
|
-
*
|
|
3498
|
+
* turn the runner did not start. So does a question this runner withdraws by
|
|
3499
|
+
* itself. (A compaction used to be the third case; `compact_context` reports
|
|
3500
|
+
* RUNNING itself now, and #348 gave the compaction lines of its own.)
|
|
3295
3501
|
*
|
|
3296
3502
|
* An open question is the one thing that must survive: there, WAITING_INPUT
|
|
3297
3503
|
* means a tool call is parked on a person, and the agent narrating around its
|
|
@@ -3329,6 +3535,9 @@ export class Supervisor {
|
|
|
3329
3535
|
// before the switch so every such case gets it, including the ones added
|
|
3330
3536
|
// after this line was written.
|
|
3331
3537
|
if (event.type === 'message' ? event.role === 'assistant' : AGENT_OUTPUT_EVENTS.has(event.type)) {
|
|
3538
|
+
// A working agent is not a silent one: the compaction net is for the
|
|
3539
|
+
// session that hears nothing at all (#348).
|
|
3540
|
+
this.clearCompactionWatchdog(running);
|
|
3332
3541
|
// Ticket #196. The same signal, read twice for opposite reasons: when the
|
|
3333
3542
|
// session is free it proves the agent is working (#185), and when the
|
|
3334
3543
|
// session is held under a clock it proves something started that should
|
|
@@ -3414,6 +3623,28 @@ export class Supervisor {
|
|
|
3414
3623
|
// when it finally succeeds or finally gives up.
|
|
3415
3624
|
if (!event.ok && this.armApiRetry(running, descriptor, event))
|
|
3416
3625
|
return;
|
|
3626
|
+
/**
|
|
3627
|
+
* A turn that ended well closes the retry EPISODE (#406).
|
|
3628
|
+
*
|
|
3629
|
+
* `running.apiRetry` records «attempt N of rule R», and `armApiRetry`
|
|
3630
|
+
* reads it to answer two questions: have we run out of attempts, and
|
|
3631
|
+
* did the same failure come straight back. Both are questions about ONE
|
|
3632
|
+
* episode. Until now the record was only ever cleared when a person
|
|
3633
|
+
* typed, a session stopped, or a session was torn down — so a session
|
|
3634
|
+
* that recovered kept «attempt 1 of claude.server_error» for the rest of
|
|
3635
|
+
* its life, and the NEXT api_error hours later read as «the same failure
|
|
3636
|
+
* came back after an automatic retry», got no retry, and ended the
|
|
3637
|
+
* session with a note that was not true.
|
|
3638
|
+
*
|
|
3639
|
+
* Nobody met that before this release: an `api_error` turn was reported
|
|
3640
|
+
* as a SUCCESS and never reached this code at all. With #406 it does,
|
|
3641
|
+
* and a long unattended run meets two network hiccups more often than
|
|
3642
|
+
* one. `apiRetriesUsed` — the ceiling that does not depend on the table
|
|
3643
|
+
* being right — is deliberately NOT reset here: it is a fact about the
|
|
3644
|
+
* session, and only a person typing clears it.
|
|
3645
|
+
*/
|
|
3646
|
+
if (event.ok)
|
|
3647
|
+
this.clearApiRetry(running);
|
|
3417
3648
|
this.completeTurn(running, descriptor, event);
|
|
3418
3649
|
return;
|
|
3419
3650
|
}
|
|
@@ -3731,6 +3962,69 @@ export class Supervisor {
|
|
|
3731
3962
|
this.sendEvent(running, 'notice', { level: event.level, text: event.text });
|
|
3732
3963
|
return;
|
|
3733
3964
|
}
|
|
3965
|
+
case 'compaction': {
|
|
3966
|
+
/**
|
|
3967
|
+
* #348. A `system_note` with a code, never a `notice`: the de-dup
|
|
3968
|
+
* above is per session for life, so «Conversation compacted» reached
|
|
3969
|
+
* the feed once and every later compaction was three minutes of
|
|
3970
|
+
* silence and a bare `turn_end` (gotcha §148). The dashboard reads its
|
|
3971
|
+
* «compacting…» indicator off these lines — the last one open means a
|
|
3972
|
+
* compaction is under way, on any tab, after any reload — so every
|
|
3973
|
+
* compaction has to leave its pair.
|
|
3974
|
+
*
|
|
3975
|
+
* One pair per compaction: a start while one is open is the CLI
|
|
3976
|
+
* repeating itself (Claude re-says «compacting» every thirty seconds
|
|
3977
|
+
* while it waits on a precomputed summary), or the agent confirming a
|
|
3978
|
+
* compaction `compact_context` already announced. An end with no start
|
|
3979
|
+
* open is still written — a process that came up mid-compaction has
|
|
3980
|
+
* nothing else to say about it.
|
|
3981
|
+
*/
|
|
3982
|
+
if (event.phase === 'started') {
|
|
3983
|
+
// The agent confirmed it is compacting: the net under a REQUESTED
|
|
3984
|
+
// compaction is for an agent that never did, and a confirmed one
|
|
3985
|
+
// reports its end through the adapter — or ends with its turn or
|
|
3986
|
+
// its process, both of which close the pair. Left armed, it would
|
|
3987
|
+
// hand back a session in the middle of a long, honest compaction.
|
|
3988
|
+
this.clearCompactionWatchdog(running);
|
|
3989
|
+
if (running.compaction)
|
|
3990
|
+
return;
|
|
3991
|
+
this.openCompaction(running, 'agent');
|
|
3992
|
+
return;
|
|
3993
|
+
}
|
|
3994
|
+
delete running.compaction;
|
|
3995
|
+
this.clearCompactionWatchdog(running);
|
|
3996
|
+
if (event.ok) {
|
|
3997
|
+
this.sendEvent(running, 'system_note', {
|
|
3998
|
+
code: 'compaction_finished',
|
|
3999
|
+
text: event.skipped ? 'Compaction skipped.' : 'Conversation compacted.',
|
|
4000
|
+
});
|
|
4001
|
+
}
|
|
4002
|
+
else {
|
|
4003
|
+
this.sendEvent(running, 'system_note', {
|
|
4004
|
+
code: 'compaction_failed',
|
|
4005
|
+
level: 'warn',
|
|
4006
|
+
text: `Compaction failed: ${event.error ?? 'unknown'}`,
|
|
4007
|
+
});
|
|
4008
|
+
}
|
|
4009
|
+
/**
|
|
4010
|
+
* Codex runs a compaction started from the dashboard outside any turn,
|
|
4011
|
+
* so no `turn_end` is coming to take the session off RUNNING — which
|
|
4012
|
+
* `compact_context` put it on, for good reasons (a WAITING_INPUT
|
|
4013
|
+
* session with no open question is parkable). Left alone it read
|
|
4014
|
+
* «Working» until the person's next message: the «eternal Working» of
|
|
4015
|
+
* the ticket. Settled here, directly and without the phantom-turn
|
|
4016
|
+
* hold: that hold exists for a turn that produced nothing, and this
|
|
4017
|
+
* is not a turn. A compaction inside a live turn (`standalone: false`)
|
|
4018
|
+
* is left to that turn's own ending.
|
|
4019
|
+
*/
|
|
4020
|
+
if (event.standalone &&
|
|
4021
|
+
running.lastReported === 'RUNNING' &&
|
|
4022
|
+
!running.stopRequested &&
|
|
4023
|
+
!running.budgetSpent) {
|
|
4024
|
+
this.settleTurnStatus(running, descriptor, { ok: true });
|
|
4025
|
+
}
|
|
4026
|
+
return;
|
|
4027
|
+
}
|
|
3734
4028
|
case 'tool':
|
|
3735
4029
|
// #373: which calls are in flight, so a stop can tell its own tail from
|
|
3736
4030
|
// work starting up. Kept here rather than in the adapters — both of them
|
|
@@ -3791,11 +4085,44 @@ export class Supervisor {
|
|
|
3791
4085
|
this.sendEvent(running, 'message', { role: 'user', text: typed });
|
|
3792
4086
|
return 'answered';
|
|
3793
4087
|
}
|
|
3794
|
-
|
|
4088
|
+
// Was the ask ours at all? A refusal for a card this process still holds
|
|
4089
|
+
// (the adapter turned the answer down but kept the ask parked) is not a
|
|
4090
|
+
// card nobody holds, and must not be announced as one: the rescued words
|
|
4091
|
+
// below reach the adapter as an ordinary message, which closes the ask in
|
|
4092
|
+
// its own honest words (QA S4 №14).
|
|
4093
|
+
const held = running.openQuestions.delete(frame.askId);
|
|
3795
4094
|
// Remembered even though it was a miss: whatever happened to the ask, the
|
|
3796
4095
|
// words below have now been published once, and a redelivery must not
|
|
3797
4096
|
// publish them again.
|
|
3798
4097
|
running.answeredAsks.add(frame.askId);
|
|
4098
|
+
/**
|
|
4099
|
+
* The card dies where the browser looks (#392).
|
|
4100
|
+
*
|
|
4101
|
+
* The grey note below is words; the only thing that closes a question card
|
|
4102
|
+
* in the feed is a `question_resolved` carrying its askId, and this exit
|
|
4103
|
+
* never sent one. So the card stayed answerable: the next press met the
|
|
4104
|
+
* API's «no longer open» for a day, and the day after that a fresh
|
|
4105
|
+
* attempt reached this branch again and printed the note again — a card
|
|
4106
|
+
* that could not die and published its own obituary once a day. This is
|
|
4107
|
+
* the one second the runner is CERTAIN, and it now writes it down.
|
|
4108
|
+
* `not_held` rather than a guess at why: a card from a previous life of
|
|
4109
|
+
* the process and an ask withdrawn a moment ago look the same from here.
|
|
4110
|
+
*
|
|
4111
|
+
* Unless the journal says the card is ALREADY closed: an answer queued
|
|
4112
|
+
* while the runner was away arrives right after a restore that has just
|
|
4113
|
+
* closed the same card with `runner_restarted`, and a second tombstone
|
|
4114
|
+
* would overwrite that honest reason with this vaguer one. The words are
|
|
4115
|
+
* still rescued below; only the card is left as it is.
|
|
4116
|
+
*/
|
|
4117
|
+
const alreadyClosed = running.journal.askState(frame.askId) === 'closed';
|
|
4118
|
+
if (!alreadyClosed && !held) {
|
|
4119
|
+
this.sendEvent(running, 'question_resolved', {
|
|
4120
|
+
askId: frame.askId,
|
|
4121
|
+
outcome: 'invalidated',
|
|
4122
|
+
source: 'runner',
|
|
4123
|
+
reason: 'not_held',
|
|
4124
|
+
});
|
|
4125
|
+
}
|
|
3799
4126
|
/**
|
|
3800
4127
|
* The reply, whichever control the person used to give it (#401).
|
|
3801
4128
|
*
|
|
@@ -3814,16 +4141,16 @@ export class Supervisor {
|
|
|
3814
4141
|
* order the card puts them in.
|
|
3815
4142
|
*/
|
|
3816
4143
|
const rescued = [typed, answersAsMessage(frame.answers ?? [])].filter(Boolean).join('\n');
|
|
3817
|
-
this.sendEvent(running, 'system_note', {
|
|
3818
|
-
text: rescued
|
|
3819
|
-
? 'That question is no longer open — sending your reply as an ordinary message instead.'
|
|
3820
|
-
: 'That question is no longer open — the agent has already moved on.',
|
|
3821
|
-
});
|
|
3822
4144
|
// The words must not be eaten. This is the ordinary case after a runner
|
|
3823
4145
|
// restart: the card in the browser outlived the process that asked, and
|
|
3824
4146
|
// showing the user's own message in the feed while nothing receives it is
|
|
3825
4147
|
// the exact failure `acceptUserMessage` was hardened against (QA-106 M2).
|
|
4148
|
+
// The grey line explains where the words went; with nothing to rescue the
|
|
4149
|
+
// card's own «withdrawn» line already says everything there is to say.
|
|
3826
4150
|
if (rescued) {
|
|
4151
|
+
this.sendEvent(running, 'system_note', {
|
|
4152
|
+
text: 'That question is no longer open — sending your reply as an ordinary message instead.',
|
|
4153
|
+
});
|
|
3827
4154
|
this.acceptUserMessage(frame.sessionId, rescued);
|
|
3828
4155
|
return 'not_open';
|
|
3829
4156
|
}
|
|
@@ -4244,6 +4571,9 @@ export class Supervisor {
|
|
|
4244
4571
|
return;
|
|
4245
4572
|
}
|
|
4246
4573
|
settle();
|
|
4574
|
+
// A message handed to the agent starts a turn of its own: whatever the
|
|
4575
|
+
// compaction net was waiting for, this session is no longer silent (#348).
|
|
4576
|
+
this.clearCompactionWatchdog(running);
|
|
4247
4577
|
this.reportStatus(running.descriptor.id, 'RUNNING', {});
|
|
4248
4578
|
return;
|
|
4249
4579
|
}
|
|
@@ -4881,6 +5211,8 @@ export class Supervisor {
|
|
|
4881
5211
|
this.flushHeldContextUsage(running);
|
|
4882
5212
|
// A phantom held from an earlier turn must not fire after this one closed.
|
|
4883
5213
|
this.clearEmptyTurn(running);
|
|
5214
|
+
// As in `completeTurn`: a stopped turn takes its compaction with it (#348).
|
|
5215
|
+
this.endCompaction(running, 'Compaction cancelled.');
|
|
4884
5216
|
this.sendEvent(running, 'turn_end', {
|
|
4885
5217
|
ok: true,
|
|
4886
5218
|
// A turn that finished on its own in the same instant is not «stopped».
|
|
@@ -5259,11 +5591,107 @@ export class Supervisor {
|
|
|
5259
5591
|
this.publishSlots();
|
|
5260
5592
|
}
|
|
5261
5593
|
// ─── Reconciliation (hello_ack) ────────────────────────────────────
|
|
5594
|
+
/**
|
|
5595
|
+
* The terminal status a reconnect should replay for this session instead
|
|
5596
|
+
* of restoring it, or null (QA-96 F1).
|
|
5597
|
+
*
|
|
5598
|
+
* Only a terminal status from THIS life of the session. A resumed session
|
|
5599
|
+
* carries a higher epoch, and replaying the FAILED it was resumed from would
|
|
5600
|
+
* kill it again the moment the runner reconnects. A journal written by a
|
|
5601
|
+
* runner from before session 13 could hold a `DONE` — it is no longer a
|
|
5602
|
+
* status this runner may report, and replaying one would close the session
|
|
5603
|
+
* for the user. Drop it and let the session be picked back up like any
|
|
5604
|
+
* other. Pure: reads the journal and decides, so the pre-registration pass
|
|
5605
|
+
* and the loop answer the question the same way.
|
|
5606
|
+
*/
|
|
5607
|
+
terminalStatusToReplay(descriptor) {
|
|
5608
|
+
if (!this.journals.exists(descriptor.id))
|
|
5609
|
+
return null;
|
|
5610
|
+
const last = this.journals.open(descriptor.id).lastStatus;
|
|
5611
|
+
if (last &&
|
|
5612
|
+
isTerminal(last.status) &&
|
|
5613
|
+
last.status !== 'DONE' &&
|
|
5614
|
+
(last.epoch ?? 0) >= descriptor.epoch) {
|
|
5615
|
+
return last;
|
|
5616
|
+
}
|
|
5617
|
+
return null;
|
|
5618
|
+
}
|
|
5619
|
+
/**
|
|
5620
|
+
* Build and register the entry for a session a runner restart interrupted,
|
|
5621
|
+
* and close the cards its dead process left open (#392).
|
|
5622
|
+
*
|
|
5623
|
+
* Synchronous on purpose, and called for EVERY such session before the
|
|
5624
|
+
* reconnect awaits anything: registered, the session is known (a message
|
|
5625
|
+
* for it waits in its queue behind the `starting` gate of #401 instead of
|
|
5626
|
+
* coming back as `session_unknown`), and its cards go dark first thing —
|
|
5627
|
+
* above the line about the restart, so a person reading down sees the card
|
|
5628
|
+
* close before they read why.
|
|
5629
|
+
*/
|
|
5630
|
+
registerRestoredSession(descriptor) {
|
|
5631
|
+
const running = {
|
|
5632
|
+
descriptor,
|
|
5633
|
+
journal: this.journals.open(descriptor.id),
|
|
5634
|
+
session: null,
|
|
5635
|
+
lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
|
|
5636
|
+
costUsd: descriptor.costUsd,
|
|
5637
|
+
costBaseUsd: descriptor.costUsd,
|
|
5638
|
+
levels: freshLevels(),
|
|
5639
|
+
stopRequested: false,
|
|
5640
|
+
parkRequested: false,
|
|
5641
|
+
// Registered before the worktree await, so the gate has to be up
|
|
5642
|
+
// before it too (#401).
|
|
5643
|
+
starting: true,
|
|
5644
|
+
pendingMessages: [],
|
|
5645
|
+
// Seed from the API, not 0: a runner restart used to hand the session
|
|
5646
|
+
// a full fresh budget silently.
|
|
5647
|
+
activeMs: descriptor.activeMsBase,
|
|
5648
|
+
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
5649
|
+
epoch: descriptor.epoch,
|
|
5650
|
+
openQuestions: new Set(),
|
|
5651
|
+
openPermissions: new Set(),
|
|
5652
|
+
liveToolUses: new Set(),
|
|
5653
|
+
pauseEpoch: 0,
|
|
5654
|
+
processSeq: 0,
|
|
5655
|
+
answeredAsks: new Set(),
|
|
5656
|
+
deliveredMessageIds: new Set(),
|
|
5657
|
+
backgroundTasks: 0,
|
|
5658
|
+
...pausedUntilOf(descriptor),
|
|
5659
|
+
mode: descriptor.mode,
|
|
5660
|
+
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
5661
|
+
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
5662
|
+
lastPrompt: '',
|
|
5663
|
+
};
|
|
5664
|
+
running.journal.ensureSeqAbove(descriptor.lastSeq);
|
|
5665
|
+
// Anything the API handed us before the daemon stopped (session 9).
|
|
5666
|
+
running.pendingMessages.push(...running.journal.pending());
|
|
5667
|
+
this.sessions.set(descriptor.id, running);
|
|
5668
|
+
this.withdrawJournaledQuestions(running, 'runner_restarted');
|
|
5669
|
+
return running;
|
|
5670
|
+
}
|
|
5262
5671
|
async reconcile(descriptors) {
|
|
5263
5672
|
// Sessions the API no longer considers live (e.g. stopped from the
|
|
5264
5673
|
// dashboard while this runner was offline) must not keep an agent process
|
|
5265
5674
|
// running and holding the single runner slot (QA-99 MAJOR-2).
|
|
5266
5675
|
const known = new Set(descriptors.map((d) => d.id));
|
|
5676
|
+
// Counted, not assigned: a second `hello_ack` can start while this one is
|
|
5677
|
+
// still walking, and whichever finishes first must not strip the other's
|
|
5678
|
+
// sessions of their protection.
|
|
5679
|
+
for (const id of known)
|
|
5680
|
+
this.reconciling.set(id, (this.reconciling.get(id) ?? 0) + 1);
|
|
5681
|
+
try {
|
|
5682
|
+
await this.reconcileKnown(descriptors, known);
|
|
5683
|
+
}
|
|
5684
|
+
finally {
|
|
5685
|
+
for (const id of known) {
|
|
5686
|
+
const left = (this.reconciling.get(id) ?? 1) - 1;
|
|
5687
|
+
if (left <= 0)
|
|
5688
|
+
this.reconciling.delete(id);
|
|
5689
|
+
else
|
|
5690
|
+
this.reconciling.set(id, left);
|
|
5691
|
+
}
|
|
5692
|
+
}
|
|
5693
|
+
}
|
|
5694
|
+
async reconcileKnown(descriptors, known) {
|
|
5267
5695
|
// Ticket #126: this list is the only moment the runner learns which
|
|
5268
5696
|
// sessions still exist. Deleting a session while its dev server is
|
|
5269
5697
|
// switched off leaves restore points — a full copy of a working tree —
|
|
@@ -5330,6 +5758,41 @@ export class Supervisor {
|
|
|
5330
5758
|
});
|
|
5331
5759
|
}
|
|
5332
5760
|
}
|
|
5761
|
+
/**
|
|
5762
|
+
* Every session this pass is going to restore is registered NOW, before
|
|
5763
|
+
* the first await (#392, QA S4).
|
|
5764
|
+
*
|
|
5765
|
+
* The loop below awaits per session — a pause, a worktree, a launch — and
|
|
5766
|
+
* anything that arrives for a LATER session meanwhile (the API flushes its
|
|
5767
|
+
* outbox right after `hello_ack`) used to meet a runner that did not know
|
|
5768
|
+
* it: `session_unknown`, a `session_start` in reply, and `startSession`
|
|
5769
|
+
* restoring that session through the wrong door, with the wrong reason on
|
|
5770
|
+
* its cards and no line about the restart. Registered here, the session is
|
|
5771
|
+
* known and `starting`, so a message waits in its queue and the
|
|
5772
|
+
* descriptor re-sent for it is a no-op — and its cards are closed before
|
|
5773
|
+
* anybody can answer them.
|
|
5774
|
+
*/
|
|
5775
|
+
const restoring = new Map();
|
|
5776
|
+
for (const descriptor of descriptors) {
|
|
5777
|
+
// The whole classification is per session, like the loop below (#225):
|
|
5778
|
+
// a journal that cannot be read throws on open, and that must cost this
|
|
5779
|
+
// session its restore and nothing else.
|
|
5780
|
+
try {
|
|
5781
|
+
if (this.sessions.has(descriptor.id))
|
|
5782
|
+
continue;
|
|
5783
|
+
if (this.terminalStatusToReplay(descriptor))
|
|
5784
|
+
continue;
|
|
5785
|
+
if (descriptor.status === 'STARTING' || !descriptor.providerSessionId)
|
|
5786
|
+
continue;
|
|
5787
|
+
restoring.set(descriptor.id, this.registerRestoredSession(descriptor));
|
|
5788
|
+
}
|
|
5789
|
+
catch (error) {
|
|
5790
|
+
log.error('supervisor: could not register a session for restore', {
|
|
5791
|
+
sessionId: descriptor.id,
|
|
5792
|
+
error: String(error),
|
|
5793
|
+
});
|
|
5794
|
+
}
|
|
5795
|
+
}
|
|
5333
5796
|
for (const descriptor of descriptors) {
|
|
5334
5797
|
// One session must never cost the others their restore (ticket #225).
|
|
5335
5798
|
// Everything below runs per descriptor, and until this guard existed a
|
|
@@ -5338,9 +5801,10 @@ export class Supervisor {
|
|
|
5338
5801
|
// unrestored, on every reconnect, with nothing said anywhere. The blast
|
|
5339
5802
|
// radius of a broken session is now that session.
|
|
5340
5803
|
try {
|
|
5804
|
+
const restored = restoring.get(descriptor.id);
|
|
5341
5805
|
// Live local session: statuses are fire-and-forget on the wire, so a
|
|
5342
5806
|
// status reached while the WS was down is re-reported here (QA-96 F1).
|
|
5343
|
-
const tracked = this.sessions.get(descriptor.id);
|
|
5807
|
+
const tracked = restored ? undefined : this.sessions.get(descriptor.id);
|
|
5344
5808
|
if (tracked) {
|
|
5345
5809
|
// The session was resumed server-side while this runner was offline, and
|
|
5346
5810
|
// the local copy is that same work. Adopt the new epoch BEFORE reporting:
|
|
@@ -5377,79 +5841,33 @@ export class Supervisor {
|
|
|
5377
5841
|
// Session that went terminal while we were offline: the journal keeps
|
|
5378
5842
|
// the last reported status — replay it instead of resurrecting the
|
|
5379
5843
|
// session as resumable (QA-96 F1).
|
|
5380
|
-
|
|
5381
|
-
|
|
5382
|
-
|
|
5383
|
-
|
|
5384
|
-
|
|
5385
|
-
|
|
5386
|
-
|
|
5387
|
-
|
|
5388
|
-
|
|
5389
|
-
|
|
5390
|
-
|
|
5391
|
-
|
|
5392
|
-
|
|
5393
|
-
(last.epoch ?? 0) >= descriptor.epoch) {
|
|
5394
|
-
this.ws.send({
|
|
5395
|
-
type: 'session_status',
|
|
5396
|
-
sessionId: descriptor.id,
|
|
5397
|
-
status: last.status,
|
|
5398
|
-
...(last.extra ?? {}),
|
|
5399
|
-
// Guarded above to be >= the descriptor's epoch, so the API keeps it.
|
|
5400
|
-
...(last.epoch === undefined ? {} : { epoch: last.epoch }),
|
|
5401
|
-
});
|
|
5402
|
-
if (journal.unacked().length === 0) {
|
|
5403
|
-
this.journals.closeAndDelete(descriptor.id);
|
|
5404
|
-
}
|
|
5405
|
-
continue;
|
|
5844
|
+
const last = restored ? null : this.terminalStatusToReplay(descriptor);
|
|
5845
|
+
if (last) {
|
|
5846
|
+
this.ws.send({
|
|
5847
|
+
type: 'session_status',
|
|
5848
|
+
sessionId: descriptor.id,
|
|
5849
|
+
status: last.status,
|
|
5850
|
+
...(last.extra ?? {}),
|
|
5851
|
+
// Guarded in `terminalStatusToReplay` to be >= the descriptor's
|
|
5852
|
+
// epoch, so the API keeps it.
|
|
5853
|
+
...(last.epoch === undefined ? {} : { epoch: last.epoch }),
|
|
5854
|
+
});
|
|
5855
|
+
if (this.journals.open(descriptor.id).unacked().length === 0) {
|
|
5856
|
+
this.journals.closeAndDelete(descriptor.id);
|
|
5406
5857
|
}
|
|
5858
|
+
continue;
|
|
5407
5859
|
}
|
|
5408
5860
|
if (descriptor.status === 'STARTING') {
|
|
5409
|
-
await this.startSession(descriptor);
|
|
5861
|
+
await this.startSession(descriptor, 'runner_restarted');
|
|
5410
5862
|
}
|
|
5411
|
-
else if (
|
|
5863
|
+
else if (restored) {
|
|
5412
5864
|
// Runner restarted mid-session. The provider session is resumable —
|
|
5413
|
-
// park it until the user sends the next instruction. The map entry
|
|
5414
|
-
// registered
|
|
5415
|
-
// buffered instead of
|
|
5416
|
-
|
|
5417
|
-
|
|
5418
|
-
|
|
5419
|
-
session: null,
|
|
5420
|
-
lastReported: descriptor.status === 'REVIEW' ? 'REVIEW' : 'WAITING_INPUT',
|
|
5421
|
-
costUsd: descriptor.costUsd,
|
|
5422
|
-
costBaseUsd: descriptor.costUsd,
|
|
5423
|
-
levels: freshLevels(),
|
|
5424
|
-
stopRequested: false,
|
|
5425
|
-
parkRequested: false,
|
|
5426
|
-
// Registered before the worktree await, so the gate has to be up
|
|
5427
|
-
// before it too (#401).
|
|
5428
|
-
starting: true,
|
|
5429
|
-
pendingMessages: [],
|
|
5430
|
-
// Seed from the API, not 0: a runner restart used to hand the session
|
|
5431
|
-
// a full fresh budget silently.
|
|
5432
|
-
activeMs: descriptor.activeMsBase,
|
|
5433
|
-
extraBudgetMinutes: descriptor.extraBudgetMinutes,
|
|
5434
|
-
epoch: descriptor.epoch,
|
|
5435
|
-
openQuestions: new Set(),
|
|
5436
|
-
openPermissions: new Set(),
|
|
5437
|
-
liveToolUses: new Set(),
|
|
5438
|
-
pauseEpoch: 0,
|
|
5439
|
-
processSeq: 0,
|
|
5440
|
-
answeredAsks: new Set(),
|
|
5441
|
-
deliveredMessageIds: new Set(),
|
|
5442
|
-
backgroundTasks: 0,
|
|
5443
|
-
...pausedUntilOf(descriptor),
|
|
5444
|
-
mode: descriptor.mode,
|
|
5445
|
-
...(descriptor.model ? { model: descriptor.model } : {}),
|
|
5446
|
-
...(descriptor.effort ? { effort: descriptor.effort } : {}),
|
|
5447
|
-
lastPrompt: '',
|
|
5448
|
-
};
|
|
5449
|
-
running.journal.ensureSeqAbove(descriptor.lastSeq);
|
|
5450
|
-
// Anything the API handed us before the daemon stopped (session 9).
|
|
5451
|
-
running.pendingMessages.push(...running.journal.pending());
|
|
5452
|
-
this.sessions.set(descriptor.id, running);
|
|
5865
|
+
// park it until the user sends the next instruction. The map entry
|
|
5866
|
+
// was registered before the first await of this pass (see
|
|
5867
|
+
// `restoring` above) so a racing message is buffered instead of
|
|
5868
|
+
// dropped (QA-96 F3), and the cards of the dead process are already
|
|
5869
|
+
// closed (#392).
|
|
5870
|
+
const running = restored;
|
|
5453
5871
|
try {
|
|
5454
5872
|
try {
|
|
5455
5873
|
// A session being restored after a runner restart already has its
|
|
@@ -5502,12 +5920,42 @@ export class Supervisor {
|
|
|
5502
5920
|
// from it. Only the instruction at the end changes — telling someone to
|
|
5503
5921
|
// send a message while the agent is already working again would be a lie.
|
|
5504
5922
|
this.sendEvent(running, 'system_note', {
|
|
5923
|
+
// The code is for the dashboard (#348): a compaction the dead
|
|
5924
|
+
// process was in the middle of ends here, and this line is the
|
|
5925
|
+
// only thing in the feed that says so.
|
|
5926
|
+
code: 'runner_reconnected',
|
|
5505
5927
|
text: willContinue
|
|
5506
5928
|
? 'Runner reconnected. The session was resumed — continuing the interrupted turn.'
|
|
5507
5929
|
: resumeId
|
|
5508
5930
|
? 'Runner reconnected. The session was resumed — send a message to continue.'
|
|
5509
5931
|
: 'Runner reconnected, but the agent never got as far as naming its conversation, so it starts this one over. Your files, your branch and everything above are untouched.',
|
|
5510
5932
|
});
|
|
5933
|
+
/**
|
|
5934
|
+
* The subagents that died with the process (#393).
|
|
5935
|
+
*
|
|
5936
|
+
* «Four of twelve. Waiting.» followed by «Runner reconnected» reads
|
|
5937
|
+
* as «the other eight are still coming». They are not: a restart
|
|
5938
|
+
* kills the agent process and every subagent inside it, and
|
|
5939
|
+
* `--resume` brings back the conversation, never the work. One short
|
|
5940
|
+
* line with the number, and only when there is a number to give —
|
|
5941
|
+
* the API sends `backgroundTasks` only while it still believes the
|
|
5942
|
+
* count (its own trust window), and sends nothing when everyone had
|
|
5943
|
+
* reported before the process died.
|
|
5944
|
+
*
|
|
5945
|
+
* BEFORE `reportStatus` below, which carries this session's fresh
|
|
5946
|
+
* zero: once that frame lands, the number is gone everywhere. And
|
|
5947
|
+
* once per restart rather than per reconnect by construction — a
|
|
5948
|
+
* session this process already tracks never reaches this branch.
|
|
5949
|
+
* «Stopped», never «finished»: to a person waiting for a report the
|
|
5950
|
+
* two are opposites.
|
|
5951
|
+
*/
|
|
5952
|
+
if (descriptor.backgroundTasks && descriptor.backgroundTasks > 0) {
|
|
5953
|
+
const n = descriptor.backgroundTasks;
|
|
5954
|
+
this.sendEvent(running, 'system_note', {
|
|
5955
|
+
code: 'background_tasks_lost',
|
|
5956
|
+
text: `The runner restart stopped ${n} background agent${n === 1 ? '' : 's'}.`,
|
|
5957
|
+
});
|
|
5958
|
+
}
|
|
5511
5959
|
if (willContinue) {
|
|
5512
5960
|
// Resumed through the PROVIDER session, so the agent keeps its whole
|
|
5513
5961
|
// conversation; the prompt is only the nudge a human would otherwise
|
|
@@ -5582,7 +6030,7 @@ export class Supervisor {
|
|
|
5582
6030
|
// A session that never had a turn (a free session still waiting for
|
|
5583
6031
|
// its first message) has nothing to resume — just bring the agent back
|
|
5584
6032
|
// up and keep waiting, instead of failing the session.
|
|
5585
|
-
await this.startSession({ ...descriptor, status: 'STARTING', prompt: '' });
|
|
6033
|
+
await this.startSession({ ...descriptor, status: 'STARTING', prompt: '' }, 'runner_restarted');
|
|
5586
6034
|
}
|
|
5587
6035
|
else {
|
|
5588
6036
|
this.reportStatus(descriptor.id, 'FAILED', {
|
|
@@ -6079,12 +6527,14 @@ export class Supervisor {
|
|
|
6079
6527
|
error: 'The agent could not compact right now — wait for the current turn to finish',
|
|
6080
6528
|
});
|
|
6081
6529
|
}
|
|
6082
|
-
// Ticket #185.
|
|
6083
|
-
// as an ordinary user message — and until
|
|
6084
|
-
// session read «Ваш ход» for the entire squeeze
|
|
6085
|
-
//
|
|
6086
|
-
//
|
|
6087
|
-
//
|
|
6530
|
+
// Ticket #185. On Claude a compaction is a whole agent turn —
|
|
6531
|
+
// `/compact` goes in as an ordinary user message — and until 0.36
|
|
6532
|
+
// nobody said so: the session read «Ваш ход» for the entire squeeze.
|
|
6533
|
+
// The end of it is the turn's own ending there; on Codex, which runs
|
|
6534
|
+
// it outside any turn, the end is the adapter's `compaction` event
|
|
6535
|
+
// (#348, `forwardEvent`), and both agents now leave their lines in
|
|
6536
|
+
// the feed — the dashboard reads its indicator from those, not from
|
|
6537
|
+
// a variable of the tab that pressed the button.
|
|
6088
6538
|
//
|
|
6089
6539
|
// It is also not only cosmetic here: a session reading WAITING_INPUT
|
|
6090
6540
|
// with no open question is parkable, and parking it would kill the
|
|
@@ -6094,6 +6544,16 @@ export class Supervisor {
|
|
|
6094
6544
|
this.syncBudgetClock(running);
|
|
6095
6545
|
this.reportStatus(running.descriptor.id, 'RUNNING', {});
|
|
6096
6546
|
}
|
|
6547
|
+
// The start line comes from HERE, not from the agent (#348): the
|
|
6548
|
+
// agent's own «compacting» arrives when it gets round to it — on
|
|
6549
|
+
// Codex the `contextCompaction` item may only show up at the end —
|
|
6550
|
+
// and the moment a person can see the indicator is the moment the
|
|
6551
|
+
// request was taken, on every tab. The agent's later start is then
|
|
6552
|
+
// the same compaction (de-duplicated above). `user`, because that is
|
|
6553
|
+
// who asked: only this kind is offered a «Cancel».
|
|
6554
|
+
if (!running.compaction)
|
|
6555
|
+
this.openCompaction(running, 'user');
|
|
6556
|
+
this.armCompactionWatchdog(running);
|
|
6097
6557
|
return void reply({ ok: true, result: { started: true } });
|
|
6098
6558
|
}
|
|
6099
6559
|
case 'git_status': {
|
|
@@ -6516,6 +6976,50 @@ export class Supervisor {
|
|
|
6516
6976
|
this.opts.onRestartRequested?.(outcome);
|
|
6517
6977
|
return;
|
|
6518
6978
|
}
|
|
6979
|
+
/**
|
|
6980
|
+
* «Restart the runner» from the server card (#396, plan R13).
|
|
6981
|
+
*
|
|
6982
|
+
* The whole errand on this side: answer, tell every live session why it
|
|
6983
|
+
* is about to go quiet, and exit for the service to start us again. The
|
|
6984
|
+
* API cannot do the middle part — the feeds belong to sessions only this
|
|
6985
|
+
* process knows about — and it does not try to.
|
|
6986
|
+
*
|
|
6987
|
+
* Refused, in words, when nothing would bring the daemon back: a runner
|
|
6988
|
+
* started by hand that exits here is a machine that has lost its runner
|
|
6989
|
+
* until somebody with SSH notices. Same condition and the same shape of
|
|
6990
|
+
* sentence as `self_update`'s (`self-update.ts`), and the same reason.
|
|
6991
|
+
*/
|
|
6992
|
+
case 'runner_restart': {
|
|
6993
|
+
if (!isSupervisedProcess()) {
|
|
6994
|
+
const detail = 'The runner is not running as a service on this machine, so nothing would start it ' +
|
|
6995
|
+
'again. Install it with `devbridge-runner install-service`, or restart it by hand.';
|
|
6996
|
+
return void reply({ ok: false, result: { ok: false, detail }, error: detail });
|
|
6997
|
+
}
|
|
6998
|
+
// Second line of defence behind the API's per-server lock, exactly as
|
|
6999
|
+
// `self_update` has: exiting in the middle of an `npm install -g`
|
|
7000
|
+
// leaves a half-written package for the service to start.
|
|
7001
|
+
const busy = this.installBusyReason();
|
|
7002
|
+
if (busy)
|
|
7003
|
+
return void reply({ ok: false, result: { ok: false, detail: busy }, error: busy });
|
|
7004
|
+
const note = str(frame.args?.['note']);
|
|
7005
|
+
// BEFORE `shutdown()`, which clears the session map — and before the
|
|
7006
|
+
// exit, because everything on this path is journaled synchronously
|
|
7007
|
+
// for the reason spelled out on `shutdown` itself. A person whose
|
|
7008
|
+
// session goes quiet did not press the button; the line is how they
|
|
7009
|
+
// find out it was pressed at all.
|
|
7010
|
+
if (note) {
|
|
7011
|
+
for (const running of this.sessions.values()) {
|
|
7012
|
+
this.sendEvent(running, 'system_note', { code: 'runner_restart', text: note });
|
|
7013
|
+
}
|
|
7014
|
+
}
|
|
7015
|
+
// Set BEFORE the reply: the API releases its per-server lock the
|
|
7016
|
+
// moment the reply lands, and the next errand must meet a machine
|
|
7017
|
+
// that already knows it is leaving.
|
|
7018
|
+
this.restarting = true;
|
|
7019
|
+
reply({ ok: true, result: { ok: true } });
|
|
7020
|
+
this.opts.onRestartCommanded?.(note);
|
|
7021
|
+
return;
|
|
7022
|
+
}
|
|
6519
7023
|
case 'agent_install': {
|
|
6520
7024
|
// The veto is enforced here as well as withheld from `hello`: an API
|
|
6521
7025
|
// that has not noticed still must not install anything on a machine
|
|
@@ -7098,6 +7602,9 @@ export class Supervisor {
|
|
|
7098
7602
|
// redelivered on the next connection (QA-106 M1).
|
|
7099
7603
|
try {
|
|
7100
7604
|
this.withdrawOpenQuestions(running, 'runner_restarted');
|
|
7605
|
+
// …and what only the journal still knows about (#392): a card from a
|
|
7606
|
+
// previous life this process inherited the file from but never held.
|
|
7607
|
+
this.withdrawJournaledQuestions(running, 'runner_restarted');
|
|
7101
7608
|
}
|
|
7102
7609
|
catch (error) {
|
|
7103
7610
|
log.warn('supervisor: could not withdraw open questions on shutdown', {
|