@bridge4dev/runner 0.45.0 → 0.46.1
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 +2 -2
- package/dist/adapters/codex.d.ts +10 -0
- package/dist/adapters/codex.js +294 -10
- package/dist/adapters/types.d.ts +16 -0
- package/dist/adapters/types.js +16 -0
- package/dist/file-publish.d.ts +27 -0
- package/dist/file-publish.js +82 -0
- package/dist/fsview.d.ts +8 -0
- package/dist/fsview.js +8 -1
- package/dist/index.js +15 -0
- package/dist/protocol.d.ts +39 -0
- package/dist/protocol.js +1 -1
- package/dist/supervisor.d.ts +115 -0
- package/dist/supervisor.js +423 -51
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/package.json +1 -1
package/dist/supervisor.js
CHANGED
|
@@ -14,6 +14,7 @@ import { VerifyRunner, runOneOffCommand, } from './verify.js';
|
|
|
14
14
|
import { VerifyReportQueue } from './verify-queue.js';
|
|
15
15
|
import { applySession, gitBranches, gitCommit, gitDiff, gitLog, gitPush, gitRefs, gitShow, gitStatus, revertApply, gitStage, gitUnstage, gitDiscard, gitPull, gitMergeAbort, updateFromBase, workspaceState, } from './gitops.js';
|
|
16
16
|
import { fsView } from './fsview.js';
|
|
17
|
+
import { publishFile } from './file-publish.js';
|
|
17
18
|
import { agentAuthStatuses, AuthRelay, clearAgentAuthFailure, noteAgentAuthFailure, } from './auth-relay.js';
|
|
18
19
|
import { selfUpdate } from './self-update.js';
|
|
19
20
|
import { rememberWorkspacePath } from './environment.js';
|
|
@@ -77,6 +78,8 @@ export class Supervisor {
|
|
|
77
78
|
* agent mid-thought.
|
|
78
79
|
*/
|
|
79
80
|
static EMPTY_TURN_SETTLE_MS = 25_000;
|
|
81
|
+
/** The window actually used — the constant, or a test's own shorter one. */
|
|
82
|
+
emptyTurnSettleMs;
|
|
80
83
|
/** A finished session's journal is kept this long for a late reconnect. */
|
|
81
84
|
static JOURNAL_TTL_MS = 72 * 3_600_000;
|
|
82
85
|
/** Backstop: events the API will never accept must not pile up forever. */
|
|
@@ -114,6 +117,7 @@ export class Supervisor {
|
|
|
114
117
|
this.ws = ws;
|
|
115
118
|
this.opts = opts;
|
|
116
119
|
this.journals = opts.journals ?? new JournalStore();
|
|
120
|
+
this.emptyTurnSettleMs = opts.emptyTurnSettleMs ?? Supervisor.EMPTY_TURN_SETTLE_MS;
|
|
117
121
|
this.verify = new VerifyRunner({
|
|
118
122
|
enabled: opts.verifyEnabled !== false,
|
|
119
123
|
onReport: (report) => {
|
|
@@ -128,7 +132,26 @@ export class Supervisor {
|
|
|
128
132
|
ws.on('frame', (frame) => {
|
|
129
133
|
void this.onFrame(frame).catch((error) => log.error('supervisor: frame handler failed', { type: frame.type, error: String(error) }));
|
|
130
134
|
});
|
|
135
|
+
/**
|
|
136
|
+
* The seat report is on a heartbeat, not only on the events that change it.
|
|
137
|
+
*
|
|
138
|
+
* Deliberate, and the lesson of the bug this whole frame exists for: seat
|
|
139
|
+
* state lives in three mutable fields on each entry (`session`,
|
|
140
|
+
* `stopRequested`, `parkRequested`), so there is no single mutation to hook
|
|
141
|
+
* — and «every call site remembers to tell the API» is exactly the rule
|
|
142
|
+
* that had already been broken in three places. A tick that re-derives the
|
|
143
|
+
* truth cannot be forgotten by a future call site.
|
|
144
|
+
*
|
|
145
|
+
* Nearly free: `publishSlots` fingerprints what it last sent and returns
|
|
146
|
+
* without touching the socket when nothing moved, so a quiet runner sends
|
|
147
|
+
* one frame per change and none in between.
|
|
148
|
+
*/
|
|
149
|
+
this.slotsTimer = setInterval(() => this.publishSlots(), Supervisor.SLOTS_REPORT_INTERVAL_MS);
|
|
150
|
+
this.slotsTimer.unref?.();
|
|
131
151
|
}
|
|
152
|
+
/** How often the seat report re-derives the truth. See the constructor. */
|
|
153
|
+
static SLOTS_REPORT_INTERVAL_MS = 15_000;
|
|
154
|
+
slotsTimer;
|
|
132
155
|
/**
|
|
133
156
|
* Push every unacked verdict at the API.
|
|
134
157
|
*
|
|
@@ -150,11 +173,83 @@ export class Supervisor {
|
|
|
150
173
|
get activeSessionIds() {
|
|
151
174
|
return [...this.sessions.keys()];
|
|
152
175
|
}
|
|
176
|
+
/** The last set of seat-holders we told the API about, to send only changes. */
|
|
177
|
+
lastPublishedSlots = '';
|
|
178
|
+
/**
|
|
179
|
+
* Tell the API which sessions are actually holding a seat here (0.46.0).
|
|
180
|
+
*
|
|
181
|
+
* The seat count is a property of THIS process, and until now the API could
|
|
182
|
+
* only guess it from its own rows. The guess was wrong whenever a session
|
|
183
|
+
* ended on paper without releasing its entry, and wrong in the direction that
|
|
184
|
+
* costs a person their session: the dashboard offers a seat, the create
|
|
185
|
+
* passes, this runner refuses, the session dies in fifty milliseconds.
|
|
186
|
+
*
|
|
187
|
+
* Called from `reportStatus` — the choke point every seat change already
|
|
188
|
+
* passes through — and from the interval in the constructor, which is the net
|
|
189
|
+
* under any path that forgets. Both are safe to call as often as they like:
|
|
190
|
+
* this compares what it is about to say against what it last said and returns
|
|
191
|
+
* without touching the socket when nothing moved.
|
|
192
|
+
*
|
|
193
|
+
* Two lists, because two different questions are being asked and answering
|
|
194
|
+
* both with one number is wrong in both directions:
|
|
195
|
+
*
|
|
196
|
+
* - `sessionIds` — everything still tracked here, parked or not. This is the
|
|
197
|
+
* reconciliation list, and it must be generous: a session the API has
|
|
198
|
+
* buried needs laying to rest whatever state it is in on this side.
|
|
199
|
+
* - `blockingIds` — what a new session would really have to wait for.
|
|
200
|
+
* `ensureCapacity` parks an idle REVIEW or WAITING_INPUT session to make
|
|
201
|
+
* room, so those seats are available on demand.
|
|
202
|
+
*
|
|
203
|
+
* Reporting the second as the first would have the API stop live REVIEW
|
|
204
|
+
* sessions; reporting the first as the second would call a machine full while
|
|
205
|
+
* it had room. Both were caught by the independent QA review of this change.
|
|
206
|
+
*/
|
|
207
|
+
publishSlots() {
|
|
208
|
+
const tracked = [];
|
|
209
|
+
const blocking = [];
|
|
210
|
+
for (const entry of this.sessions.values()) {
|
|
211
|
+
const id = entry.descriptor.id;
|
|
212
|
+
tracked.push(id);
|
|
213
|
+
if (entry.stopRequested || entry.parkRequested || !entry.session)
|
|
214
|
+
continue;
|
|
215
|
+
// `ensureCapacity` would park this one to make room, so its seat is
|
|
216
|
+
// available on demand and does not refuse anybody. Counting it as taken
|
|
217
|
+
// reported a machine with three finished-turn sessions as full while it
|
|
218
|
+
// would happily have started a fourth (independent QA, 31.08.2026).
|
|
219
|
+
if (this.isParkable(entry))
|
|
220
|
+
continue;
|
|
221
|
+
blocking.push(id);
|
|
222
|
+
}
|
|
223
|
+
tracked.sort();
|
|
224
|
+
blocking.sort();
|
|
225
|
+
const limit = this.maxSessions;
|
|
226
|
+
const fingerprint = `${limit}:${tracked.join(',')}|${blocking.join(',')}`;
|
|
227
|
+
if (fingerprint === this.lastPublishedSlots)
|
|
228
|
+
return;
|
|
229
|
+
// Recorded only when it actually went out. A frame dropped because the
|
|
230
|
+
// socket was down must not be remembered as sent — otherwise the seat
|
|
231
|
+
// report would go quiet until the set changed again, which on an idle
|
|
232
|
+
// machine is exactly never.
|
|
233
|
+
if (this.ws.send({
|
|
234
|
+
type: 'session_slots',
|
|
235
|
+
sessionIds: tracked,
|
|
236
|
+
blockingIds: blocking,
|
|
237
|
+
maxSessions: limit,
|
|
238
|
+
})) {
|
|
239
|
+
this.lastPublishedSlots = fingerprint;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
153
242
|
async onFrame(frame) {
|
|
154
243
|
switch (frame.type) {
|
|
155
244
|
case 'hello_ack':
|
|
245
|
+
// A new connection knows nothing about the seats we reported to the
|
|
246
|
+
// last one — the API keeps that beside the socket, not in the database,
|
|
247
|
+
// because it is only true while the socket is. Forget what we told the
|
|
248
|
+
// old one so the first tick after this reconnect actually sends.
|
|
249
|
+
this.lastPublishedSlots = '';
|
|
156
250
|
this.setMaxSessions(frame.maxSessions);
|
|
157
251
|
await this.reconcile(frame.sessions);
|
|
252
|
+
this.publishSlots();
|
|
158
253
|
// A build that finished while the socket was down has its verdict
|
|
159
254
|
// sitting on disk. This is the moment it can be delivered.
|
|
160
255
|
this.flushVerifyReports();
|
|
@@ -376,7 +471,7 @@ export class Supervisor {
|
|
|
376
471
|
if (orphaned) {
|
|
377
472
|
this.orphanMessages.delete(descriptor.id);
|
|
378
473
|
for (const message of orphaned) {
|
|
379
|
-
this.acceptUserMessage(descriptor.id, message.text, message.attachments);
|
|
474
|
+
this.acceptUserMessage(descriptor.id, message.text, message.attachments, message.messageId);
|
|
380
475
|
}
|
|
381
476
|
}
|
|
382
477
|
try {
|
|
@@ -1063,6 +1158,20 @@ export class Supervisor {
|
|
|
1063
1158
|
// Idle process ended (parked or died between turns) — stay resumable.
|
|
1064
1159
|
running.session = null;
|
|
1065
1160
|
running.parkRequested = false;
|
|
1161
|
+
/**
|
|
1162
|
+
* The subagents died with it (QA-2026-08-16 M-5).
|
|
1163
|
+
*
|
|
1164
|
+
* Here rather than in `park()`, because parking only ASKS the process to
|
|
1165
|
+
* stop and this is where it actually went. The adapter stops publishing
|
|
1166
|
+
* the moment it is told to stop, so without this the last frame's count
|
|
1167
|
+
* stands for ever: the strip says «Working in background» over a parked
|
|
1168
|
+
* session and — far worse — the API goes on suppressing that session's
|
|
1169
|
+
* «your turn» notification for the rest of its life, because the number
|
|
1170
|
+
* it stored never reaches zero again.
|
|
1171
|
+
*
|
|
1172
|
+
* Before the `reportStatus` just below, so the zero rides out on it.
|
|
1173
|
+
*/
|
|
1174
|
+
running.backgroundTasks = 0;
|
|
1066
1175
|
running.costBaseUsd = running.costUsd; // next process starts from here
|
|
1067
1176
|
// Persist the clock: a parked session can sit for hours and the runner
|
|
1068
1177
|
// may be restarted before it ever runs again.
|
|
@@ -1094,6 +1203,11 @@ export class Supervisor {
|
|
|
1094
1203
|
isTerminal(running.lastReported)) {
|
|
1095
1204
|
this.journals.closeAndDelete(descriptor.id);
|
|
1096
1205
|
}
|
|
1206
|
+
// The map just lost an entry, and this is the one place where that happens
|
|
1207
|
+
// WITHOUT a status frame to carry the news: the terminal status was already
|
|
1208
|
+
// reported before the process stream ended. Without this the API goes on
|
|
1209
|
+
// believing the runner tracks a session that is gone, until the next tick.
|
|
1210
|
+
this.publishSlots();
|
|
1097
1211
|
// A resume arrived while this life was winding down — start it now that the
|
|
1098
1212
|
// map entry is gone. One place, after every removal path above.
|
|
1099
1213
|
if (running.pendingRestart && !this.sessions.has(descriptor.id)) {
|
|
@@ -1301,6 +1415,11 @@ export class Supervisor {
|
|
|
1301
1415
|
if (event.ok && event.produced !== true && this.holdEmptyTurn(running, descriptor, event)) {
|
|
1302
1416
|
return;
|
|
1303
1417
|
}
|
|
1418
|
+
// Not held — so any phantom still waiting from an earlier turn is stale and
|
|
1419
|
+
// must not outlive this ending. Cleared HERE and not before the decision
|
|
1420
|
+
// above, or `holdEmptyTurn`'s «two in a row is a quiet agent, not two
|
|
1421
|
+
// phantoms» rule could never see the first one (QA-2026-08-16 M-6).
|
|
1422
|
+
this.clearEmptyTurn(running);
|
|
1304
1423
|
this.settleTurnStatus(running, descriptor, event);
|
|
1305
1424
|
}
|
|
1306
1425
|
/**
|
|
@@ -1334,7 +1453,12 @@ export class Supervisor {
|
|
|
1334
1453
|
});
|
|
1335
1454
|
}
|
|
1336
1455
|
else {
|
|
1337
|
-
|
|
1456
|
+
// The turn failed for a reason nothing above could soften: not a plan
|
|
1457
|
+
// refusal, not a retryable provider fault. FAILED is terminal, so the
|
|
1458
|
+
// process and the seat go with it — see `finishSession`. Reporting the
|
|
1459
|
+
// status alone is what left this machine holding a seat for a session
|
|
1460
|
+
// the API had already buried (31.08.2026).
|
|
1461
|
+
this.finishSession(running, 'FAILED', {
|
|
1338
1462
|
costUsd: running.costUsd,
|
|
1339
1463
|
activeMs: Supervisor.spentMs(running),
|
|
1340
1464
|
errorMessage: event.errorMessage ?? 'Agent turn failed',
|
|
@@ -1364,7 +1488,7 @@ export class Supervisor {
|
|
|
1364
1488
|
return false;
|
|
1365
1489
|
log.info('supervisor: holding a turn that produced nothing', {
|
|
1366
1490
|
sessionId: descriptor.id,
|
|
1367
|
-
settleMs:
|
|
1491
|
+
settleMs: this.emptyTurnSettleMs,
|
|
1368
1492
|
});
|
|
1369
1493
|
const timer = setTimeout(() => {
|
|
1370
1494
|
running.emptyTurnTimer = undefined;
|
|
@@ -1374,7 +1498,7 @@ export class Supervisor {
|
|
|
1374
1498
|
return;
|
|
1375
1499
|
log.info('supervisor: the empty turn was real after all', { sessionId: descriptor.id });
|
|
1376
1500
|
this.settleTurnStatus(running, descriptor, event);
|
|
1377
|
-
},
|
|
1501
|
+
}, this.emptyTurnSettleMs);
|
|
1378
1502
|
timer.unref();
|
|
1379
1503
|
running.emptyTurnTimer = timer;
|
|
1380
1504
|
return true;
|
|
@@ -1392,6 +1516,35 @@ export class Supervisor {
|
|
|
1392
1516
|
clearTimeout(running.emptyTurnTimer);
|
|
1393
1517
|
running.emptyTurnTimer = undefined;
|
|
1394
1518
|
}
|
|
1519
|
+
/**
|
|
1520
|
+
* Record how many subagents are alive, and say so when it matters (#236).
|
|
1521
|
+
*
|
|
1522
|
+
* One place, because there are now two callers with opposite news — a frame
|
|
1523
|
+
* from the adapter, and the process going away — and «is it the human's turn»
|
|
1524
|
+
* must be answered the same way by both.
|
|
1525
|
+
*
|
|
1526
|
+
* The report on reaching zero is the whole point: the agent is already
|
|
1527
|
+
* sitting in a resting status, so nothing else will ever tell the API that
|
|
1528
|
+
* the session finally became the person's. `REVIEW` counts as well as
|
|
1529
|
+
* `WAITING_INPUT` — a TICKET session waiting on its own subagent is in the
|
|
1530
|
+
* same position, and leaving it out was how one of the two statuses kept its
|
|
1531
|
+
* notification suppressed for good (QA-2026-08-16 M-5).
|
|
1532
|
+
*/
|
|
1533
|
+
setBackgroundTasks(running, live) {
|
|
1534
|
+
if (live === running.backgroundTasks)
|
|
1535
|
+
return;
|
|
1536
|
+
const finished = running.backgroundTasks > 0 && live === 0;
|
|
1537
|
+
running.backgroundTasks = live;
|
|
1538
|
+
const resting = running.lastReported === 'WAITING_INPUT' || running.lastReported === 'REVIEW'
|
|
1539
|
+
? running.lastReported
|
|
1540
|
+
: null;
|
|
1541
|
+
if (finished && resting) {
|
|
1542
|
+
this.reportStatus(running.descriptor.id, resting, {
|
|
1543
|
+
costUsd: running.costUsd,
|
|
1544
|
+
activeMs: Supervisor.spentMs(running),
|
|
1545
|
+
});
|
|
1546
|
+
}
|
|
1547
|
+
}
|
|
1395
1548
|
isParkable(running) {
|
|
1396
1549
|
return ((running.lastReported === 'REVIEW' || running.lastReported === 'WAITING_INPUT') &&
|
|
1397
1550
|
running.openQuestions.size === 0 &&
|
|
@@ -1522,11 +1675,6 @@ export class Supervisor {
|
|
|
1522
1675
|
// when it finally succeeds or finally gives up.
|
|
1523
1676
|
if (!event.ok && this.armApiRetry(running, descriptor, event))
|
|
1524
1677
|
return;
|
|
1525
|
-
// A previous turn's phantom, if any, is over: this turn has ended for
|
|
1526
|
-
// real, and a timer still holding its predecessor would report a status
|
|
1527
|
-
// for a turn that is two turns old (#300 — the hold itself lives in
|
|
1528
|
-
// `completeTurn`, where the decision belongs).
|
|
1529
|
-
this.clearEmptyTurn(running);
|
|
1530
1678
|
this.completeTurn(running, descriptor, event);
|
|
1531
1679
|
return;
|
|
1532
1680
|
}
|
|
@@ -1574,7 +1722,43 @@ export class Supervisor {
|
|
|
1574
1722
|
// No `lastPrompt` guard: a free CHAT session boots with an empty prompt
|
|
1575
1723
|
// and is exactly the case that hits an auth failure at startup, so
|
|
1576
1724
|
// requiring one excluded the sessions that need the retry most.
|
|
1577
|
-
|
|
1725
|
+
// FIRST, and before any decision about retrying (#365). This is the only
|
|
1726
|
+
// authority on a login the credentials file cannot see through — a
|
|
1727
|
+
// provider-side revocation leaves the file looking perfectly healthy,
|
|
1728
|
+
// and `codex login status` never leaves the machine — so it is the one
|
|
1729
|
+
// thing that turns the server panel from «signed in» to «sign in
|
|
1730
|
+
// needed» (#121).
|
|
1731
|
+
//
|
|
1732
|
+
// It used to sit BELOW the retry, i.e. it wanted a SECOND refusal. On
|
|
1733
|
+
// Codex a second refusal never came: the first one was swallowed in
|
|
1734
|
+
// favour of a relaunch that could not happen, so the panel kept showing
|
|
1735
|
+
// a dead login as healthy — and the owner spent an hour signing into
|
|
1736
|
+
// the wrong machine while this line waited for its turn.
|
|
1737
|
+
//
|
|
1738
|
+
// `auth_expired` ONLY, and not every auth code. `auth_missing` means the
|
|
1739
|
+
// credential was locally absent — nothing was refused — and the panel's
|
|
1740
|
+
// own probe already answers «not signed in» for that machine without
|
|
1741
|
+
// any help from here. Marking it would only add a way to be wrong: the
|
|
1742
|
+
// mark outranks a healthy file for fifteen minutes, so a home that was
|
|
1743
|
+
// repaired a second later would keep a red panel over a working login.
|
|
1744
|
+
if (event.code === 'auth_expired') {
|
|
1745
|
+
const refused = relayAgent(String(descriptor.agent).toLowerCase());
|
|
1746
|
+
if (refused)
|
|
1747
|
+
noteAgentAuthFailure(refused);
|
|
1748
|
+
}
|
|
1749
|
+
// Only `auth_missing` is worth a relaunch, and the distinction is the
|
|
1750
|
+
// adapter's own (`probeAuth`), not a guess from prose.
|
|
1751
|
+
//
|
|
1752
|
+
// - `auth_missing` — there is no credential where we look. Usually the
|
|
1753
|
+
// link into the shared store went out from under a live daemon, and
|
|
1754
|
+
// the adapter re-asserts its home on start, so one relaunch really
|
|
1755
|
+
// does fix it.
|
|
1756
|
+
// - `auth_expired` — the provider refused a credential that IS there.
|
|
1757
|
+
// A repeat presents the same dead token and gets the same answer,
|
|
1758
|
+
// while the person waits for a retry that was never going to work.
|
|
1759
|
+
// `error-policy.ts` has said exactly this about Claude since 0.44.1
|
|
1760
|
+
// («A person has to sign in; a repeat cannot») — the two now agree.
|
|
1761
|
+
if (event.code === 'auth_missing' && !running.authRetryDone) {
|
|
1578
1762
|
running.authRetry = { prompt: running.lastPrompt };
|
|
1579
1763
|
this.sendEvent(running, 'notice', {
|
|
1580
1764
|
level: 'warn',
|
|
@@ -1582,22 +1766,18 @@ export class Supervisor {
|
|
|
1582
1766
|
});
|
|
1583
1767
|
return;
|
|
1584
1768
|
}
|
|
1585
|
-
// The retry is spent and the agent is still refused — this is the only
|
|
1586
|
-
// authority on a login the credentials file cannot see through (a
|
|
1587
|
-
// provider-side revocation leaves the file looking perfectly healthy).
|
|
1588
|
-
// The panel is told from here, not from a guess (#121).
|
|
1589
|
-
if (isAuthCode(event.code)) {
|
|
1590
|
-
const refused = relayAgent(String(descriptor.agent).toLowerCase());
|
|
1591
|
-
if (refused)
|
|
1592
|
-
noteAgentAuthFailure(refused);
|
|
1593
|
-
}
|
|
1594
1769
|
// Forward the code: the API stores the payload as-is, so the dashboard
|
|
1595
1770
|
// can offer "Sign in" instead of a dead error card.
|
|
1596
1771
|
this.sendEvent(running, 'error', {
|
|
1597
1772
|
message: event.message,
|
|
1598
1773
|
...(event.code ? { code: event.code } : {}),
|
|
1599
1774
|
});
|
|
1600
|
-
|
|
1775
|
+
// Through `finishSession`, not `reportStatus`: this branch is the one
|
|
1776
|
+
// the 31.08.2026 incident was traced to. It filed the session FAILED
|
|
1777
|
+
// and returned, and the agent process went on streaming for another
|
|
1778
|
+
// seventy-one seconds — holding a seat on a machine whose database said
|
|
1779
|
+
// it was free.
|
|
1780
|
+
this.finishSession(running, 'FAILED', {
|
|
1601
1781
|
costUsd: running.costUsd,
|
|
1602
1782
|
activeMs: Supervisor.spentMs(running),
|
|
1603
1783
|
errorMessage: event.message,
|
|
@@ -1757,21 +1937,7 @@ export class Supervisor {
|
|
|
1757
1937
|
* turn (`endTaskTurn` keeps the live set).
|
|
1758
1938
|
*/
|
|
1759
1939
|
const live = event.tasks.filter((task) => task.status === 'running').length;
|
|
1760
|
-
|
|
1761
|
-
const wasWaiting = running.backgroundTasks > 0 && live === 0;
|
|
1762
|
-
running.backgroundTasks = live;
|
|
1763
|
-
// The moment the last subagent finishes is the moment the session
|
|
1764
|
-
// really does become the human's — and the agent is sitting in
|
|
1765
|
-
// WAITING_INPUT, so nothing else will ever say so. `reportStatus`
|
|
1766
|
-
// carries the new number and the API turns it into the notification
|
|
1767
|
-
// it withheld earlier.
|
|
1768
|
-
if (wasWaiting && running.lastReported === 'WAITING_INPUT') {
|
|
1769
|
-
this.reportStatus(running.descriptor.id, 'WAITING_INPUT', {
|
|
1770
|
-
costUsd: running.costUsd,
|
|
1771
|
-
activeMs: Supervisor.spentMs(running),
|
|
1772
|
-
});
|
|
1773
|
-
}
|
|
1774
|
-
}
|
|
1940
|
+
this.setBackgroundTasks(running, live);
|
|
1775
1941
|
return;
|
|
1776
1942
|
}
|
|
1777
1943
|
case 'notice': {
|
|
@@ -1884,7 +2050,20 @@ export class Supervisor {
|
|
|
1884
2050
|
// instead of dropping it silently.
|
|
1885
2051
|
log.warn('supervisor: message for unknown session — requesting descriptor', { sessionId });
|
|
1886
2052
|
const queued = this.orphanMessages.get(sessionId) ?? [];
|
|
1887
|
-
|
|
2053
|
+
/**
|
|
2054
|
+
* The name travels with the words (QA-2026-08-16 M-2).
|
|
2055
|
+
*
|
|
2056
|
+
* Without it the replay below took the message a SECOND time under no
|
|
2057
|
+
* name, and the API's redelivery of the row it queued for the same
|
|
2058
|
+
* `unknown_session` then looked brand new to `deliveredMessageIds` — the
|
|
2059
|
+
* agent got one instruction twice, in the one scenario that reliably
|
|
2060
|
+
* produces both copies.
|
|
2061
|
+
*/
|
|
2062
|
+
queued.push({
|
|
2063
|
+
text,
|
|
2064
|
+
...(attachments?.length ? { attachments } : {}),
|
|
2065
|
+
...(messageId ? { messageId } : {}),
|
|
2066
|
+
});
|
|
1888
2067
|
this.orphanMessages.set(sessionId, queued.slice(-Supervisor.ORPHAN_MESSAGE_CAP));
|
|
1889
2068
|
this.ws.send({ type: 'session_unknown', sessionId });
|
|
1890
2069
|
return 'unknown_session';
|
|
@@ -2140,9 +2319,11 @@ export class Supervisor {
|
|
|
2140
2319
|
}
|
|
2141
2320
|
if (!running.session) {
|
|
2142
2321
|
// The process died while we waited. Relaunching is `launchAgent`'s job and
|
|
2143
|
-
// it needs a prompt; without one there is nothing honest to do here
|
|
2144
|
-
|
|
2145
|
-
|
|
2322
|
+
// it needs a prompt; without one there is nothing honest to do here — so
|
|
2323
|
+
// the session ends, entry and all (`finishSession`). It used to end only
|
|
2324
|
+
// on paper, and the row left behind then swallowed the next
|
|
2325
|
+
// `session_start` for the same id.
|
|
2326
|
+
this.finishSession(running, 'FAILED', {
|
|
2146
2327
|
errorMessage: 'The agent process ended while waiting to retry',
|
|
2147
2328
|
});
|
|
2148
2329
|
return;
|
|
@@ -2202,6 +2383,11 @@ export class Supervisor {
|
|
|
2202
2383
|
this.sendEvent(running, 'message_delivered', { targetSeqs: delivered });
|
|
2203
2384
|
}
|
|
2204
2385
|
};
|
|
2386
|
+
// M-4 (QA-2026-08-16): a new turn is starting, so a phantom held from the
|
|
2387
|
+
// previous one must not fire 25 seconds from now and report WAITING_INPUT
|
|
2388
|
+
// over an agent that is working — the very lie #300 is about, and this time
|
|
2389
|
+
// with an irreversible notification behind it.
|
|
2390
|
+
this.clearEmptyTurn(running);
|
|
2205
2391
|
if (running.session && !running.parkRequested) {
|
|
2206
2392
|
// #252: the words this turn is actually running. `lastPrompt` deliberately
|
|
2207
2393
|
// stays put — three older latches relaunch a PROCESS with it, and handing
|
|
@@ -2658,6 +2844,65 @@ export class Supervisor {
|
|
|
2658
2844
|
});
|
|
2659
2845
|
}
|
|
2660
2846
|
}
|
|
2847
|
+
/**
|
|
2848
|
+
* Let go of a session the API says no longer exists, so its files can go.
|
|
2849
|
+
*
|
|
2850
|
+
* `purge_session` and `clean` used to refuse outright while ANY entry for the
|
|
2851
|
+
* id was in the map. That reads as caution and behaves as a deadlock: the
|
|
2852
|
+
* frame arrives precisely because the API has already deleted the row, so
|
|
2853
|
+
* nothing will ever come along to release the entry, the worktree stays on
|
|
2854
|
+
* disk, and the retry runs until the purge ages out of its window. On
|
|
2855
|
+
* production one such tombstone was still failing two hours later, on a
|
|
2856
|
+
* machine that was refusing new sessions for exactly the seat it held.
|
|
2857
|
+
*
|
|
2858
|
+
* The API is the authority on whether a session exists. If it says gone, the
|
|
2859
|
+
* honest answer is to end it here too — the same teardown a reconnect
|
|
2860
|
+
* performs for a session missing from `hello_ack` — and only then report that
|
|
2861
|
+
* we could not finish, if the process really will not go.
|
|
2862
|
+
*
|
|
2863
|
+
* Returns true when nothing is holding the id any more.
|
|
2864
|
+
*/
|
|
2865
|
+
async releaseForCleanup(sessionId) {
|
|
2866
|
+
const running = this.sessions.get(sessionId);
|
|
2867
|
+
if (!running)
|
|
2868
|
+
return true;
|
|
2869
|
+
log.warn('supervisor: cleanup asked for a session this runner still held', { sessionId });
|
|
2870
|
+
if (!running.stopRequested) {
|
|
2871
|
+
running.stopRequested = true;
|
|
2872
|
+
this.clearBudgetTimers(running);
|
|
2873
|
+
this.clearApiRetry(running);
|
|
2874
|
+
this.clearEmptyTurn(running);
|
|
2875
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
2876
|
+
// No status report: the row is already gone on the other side, so a frame
|
|
2877
|
+
// about it would be answered with «Unknown session» and nothing else.
|
|
2878
|
+
running.session?.stop('session_stopped');
|
|
2879
|
+
if (!running.session)
|
|
2880
|
+
this.sessions.delete(sessionId);
|
|
2881
|
+
this.drainSessionsWaitingForCapacity();
|
|
2882
|
+
this.publishSlots();
|
|
2883
|
+
}
|
|
2884
|
+
// `pumpEvents` removes the entry when the process stream actually ends, and
|
|
2885
|
+
// that is asynchronous. Bounded wait rather than an unbounded one: the
|
|
2886
|
+
// caller is a command with its own timeout, and a purge that has to happen
|
|
2887
|
+
// on the next drain instead of this one costs a retry, not the worktree.
|
|
2888
|
+
for (let waited = 0; waited < Supervisor.CLEANUP_RELEASE_MS; waited += 100) {
|
|
2889
|
+
if (!this.sessions.has(sessionId))
|
|
2890
|
+
return true;
|
|
2891
|
+
await new Promise((resolve) => setTimeout(resolve, 100));
|
|
2892
|
+
}
|
|
2893
|
+
return !this.sessions.has(sessionId);
|
|
2894
|
+
}
|
|
2895
|
+
/**
|
|
2896
|
+
* How long cleanup waits for a stopped agent process to actually be gone.
|
|
2897
|
+
*
|
|
2898
|
+
* Deliberately a small slice of the API's ten-second command budget: removing
|
|
2899
|
+
* the worktree still has to happen after this, under the repository lock, and
|
|
2900
|
+
* a purge that times out on the wire is reported as a failure even when it
|
|
2901
|
+
* succeeded here. Three seconds is enough for an ordinary exit; anything
|
|
2902
|
+
* slower is better answered honestly, so the drain comes back and finds the
|
|
2903
|
+
* entry already gone.
|
|
2904
|
+
*/
|
|
2905
|
+
static CLEANUP_RELEASE_MS = 3_000;
|
|
2661
2906
|
stopSession(sessionId) {
|
|
2662
2907
|
const running = this.sessions.get(sessionId);
|
|
2663
2908
|
if (!running)
|
|
@@ -2676,6 +2921,59 @@ export class Supervisor {
|
|
|
2676
2921
|
this.journals.closeAndDelete(sessionId);
|
|
2677
2922
|
}
|
|
2678
2923
|
}
|
|
2924
|
+
this.publishSlots();
|
|
2925
|
+
}
|
|
2926
|
+
/**
|
|
2927
|
+
* The ONE way a session ends on this runner.
|
|
2928
|
+
*
|
|
2929
|
+
* A terminal status is two facts, not one: the API is told the session is
|
|
2930
|
+
* over, AND this machine stops holding a seat for it. They used to be
|
|
2931
|
+
* separate lines at eight call sites, and three of them wrote only the first
|
|
2932
|
+
* — `settleTurnStatus`'s failed-turn branch, `forwardEvent`'s `case 'error'`
|
|
2933
|
+
* and `runApiRetry`. Each left a `RunningSession` in the map with a live
|
|
2934
|
+
* `session` handle and `stopRequested === false`, which is precisely what
|
|
2935
|
+
* `liveSessionCount` counts. The seat was then held for a session the API had
|
|
2936
|
+
* already buried, until the next reconnect — weeks, on a healthy runner.
|
|
2937
|
+
*
|
|
2938
|
+
* On production (31.08.2026) that arithmetic refused a fourth session on a
|
|
2939
|
+
* machine whose database said one was running, and the dashboard had offered
|
|
2940
|
+
* the seat a moment earlier. `launchCrashed` had the rule right all along
|
|
2941
|
+
* («an entry left in the map would hold one of the runner's few slots»); it
|
|
2942
|
+
* just could not be the only place that knew it.
|
|
2943
|
+
*
|
|
2944
|
+
* Deliberately NOT folded into `reportStatus`: that method is also how a
|
|
2945
|
+
* session reaches REVIEW, WAITING_INPUT and RUNNING, and a teardown hidden
|
|
2946
|
+
* inside it would be invisible at exactly the call sites that must not tear
|
|
2947
|
+
* anything down. The name says what it does.
|
|
2948
|
+
*/
|
|
2949
|
+
finishSession(running, status, extra) {
|
|
2950
|
+
const sessionId = running.descriptor.id;
|
|
2951
|
+
// Before the report, so a frame that races the teardown cannot re-arm
|
|
2952
|
+
// anything the teardown is in the middle of taking away.
|
|
2953
|
+
running.stopRequested = true;
|
|
2954
|
+
this.clearBudgetTimers(running);
|
|
2955
|
+
this.clearApiRetry(running);
|
|
2956
|
+
this.clearEmptyTurn(running);
|
|
2957
|
+
// The cards die with the turn: a question nobody can answer any more is a
|
|
2958
|
+
// worse thing to leave on screen than no question at all.
|
|
2959
|
+
this.withdrawOpenQuestions(running, 'session_stopped');
|
|
2960
|
+
this.reportStatus(sessionId, status, extra);
|
|
2961
|
+
if (running.session) {
|
|
2962
|
+
// `pumpEvents` deletes the entry when the stream ends, and its
|
|
2963
|
+
// `stopRequested` branch skips the STOPPED frame because `lastReported`
|
|
2964
|
+
// is already terminal. Until then the seat is free anyway: `stopRequested`
|
|
2965
|
+
// takes the entry out of `liveSessionCount` on this very line.
|
|
2966
|
+
running.session.stop('session_stopped');
|
|
2967
|
+
}
|
|
2968
|
+
else {
|
|
2969
|
+
this.sessions.delete(sessionId);
|
|
2970
|
+
if (this.ws.connected && running.journal.unacked().length === 0) {
|
|
2971
|
+
this.journals.closeAndDelete(sessionId);
|
|
2972
|
+
}
|
|
2973
|
+
}
|
|
2974
|
+
// A freed seat is only useful to whoever is waiting for one.
|
|
2975
|
+
this.drainSessionsWaitingForCapacity();
|
|
2976
|
+
this.publishSlots();
|
|
2679
2977
|
}
|
|
2680
2978
|
// ─── Reconciliation (hello_ack) ────────────────────────────────────
|
|
2681
2979
|
async reconcile(descriptors) {
|
|
@@ -2927,11 +3225,30 @@ export class Supervisor {
|
|
|
2927
3225
|
});
|
|
2928
3226
|
}
|
|
2929
3227
|
}
|
|
2930
|
-
|
|
2931
|
-
|
|
2932
|
-
|
|
2933
|
-
|
|
2934
|
-
|
|
3228
|
+
/**
|
|
3229
|
+
* REVIEW stays REVIEW (WAITING_INPUT is not reachable from it);
|
|
3230
|
+
* mid-turn statuses are downgraded to "waiting for the user".
|
|
3231
|
+
*
|
|
3232
|
+
* REVIEW is now REPORTED rather than skipped — ticket #356, and the
|
|
3233
|
+
* news in that frame is not the status, which has not moved. It is
|
|
3234
|
+
* the ZERO riding on it.
|
|
3235
|
+
*
|
|
3236
|
+
* `running.backgroundTasks` is seeded to 0 above, because a runner
|
|
3237
|
+
* restart takes every subagent with it. But the number the API holds
|
|
3238
|
+
* is written by the runner ALONE, and it is only ever written by a
|
|
3239
|
+
* frame that carries the field — which `reportStatus` attaches only
|
|
3240
|
+
* for a tracked session. Skipping the report here left the API
|
|
3241
|
+
* believing whatever the dead process last said, for good: the badge
|
|
3242
|
+
* would keep saying «Agents», the Inbox would keep hiding the card,
|
|
3243
|
+
* and no later frame would ever correct either. `setBackgroundTasks`
|
|
3244
|
+
* cannot help — it compares against the in-memory 0 and returns
|
|
3245
|
+
* early, having nothing to announce.
|
|
3246
|
+
*
|
|
3247
|
+
* Same-status frames are legal (`canDevSessionTransition` answers
|
|
3248
|
+
* `true` for `from === to`), so this costs one no-op write and buys
|
|
3249
|
+
* back a session that would otherwise have been lost.
|
|
3250
|
+
*/
|
|
3251
|
+
this.reportStatus(descriptor.id, statusForReport(running), {});
|
|
2935
3252
|
this.flushPendingMessages(running);
|
|
2936
3253
|
}
|
|
2937
3254
|
else if (descriptor.status === 'WAITING_INPUT') {
|
|
@@ -3022,8 +3339,22 @@ export class Supervisor {
|
|
|
3022
3339
|
const sessionId = frame.sessionId;
|
|
3023
3340
|
if (!sessionId)
|
|
3024
3341
|
return void reply({ ok: false, error: 'sessionId is required' });
|
|
3025
|
-
|
|
3026
|
-
|
|
3342
|
+
/**
|
|
3343
|
+
* `clean` keeps the flat refusal. `purge_session` does not. The
|
|
3344
|
+
* difference is who is allowed to ask.
|
|
3345
|
+
*
|
|
3346
|
+
* `purge_session` only ever arrives after the API has deleted the
|
|
3347
|
+
* row, so «wait for it to wind down» waits for something that will
|
|
3348
|
+
* never happen — that is the deadlock `releaseForCleanup` exists to
|
|
3349
|
+
* break. `clean` is a user-callable command
|
|
3350
|
+
* (`POST /dev/sessions/:id/commands`, any dev member) and the API
|
|
3351
|
+
* checks no status before relaying it. This refusal is the ONLY guard
|
|
3352
|
+
* on that route: releasing here would stop a mid-turn agent and then
|
|
3353
|
+
* `git worktree remove --force` its worktree, destroying every
|
|
3354
|
+
* uncommitted change in it. Caught by the independent QA review of
|
|
3355
|
+
* this change, by two reviewers separately.
|
|
3356
|
+
*/
|
|
3357
|
+
if (this.sessions.get(sessionId))
|
|
3027
3358
|
return void reply({ ok: false, error: 'Session is still active — stop it first' });
|
|
3028
3359
|
// `worktree remove` also mutates the shared .git registration.
|
|
3029
3360
|
await this.withRepoLockFor(sessionWorktreePath(sessionId), () => removeSessionWorktree(sessionId));
|
|
@@ -3035,7 +3366,7 @@ export class Supervisor {
|
|
|
3035
3366
|
const sessionId = frame.sessionId;
|
|
3036
3367
|
if (!sessionId)
|
|
3037
3368
|
return void reply({ ok: false, error: 'sessionId is required' });
|
|
3038
|
-
if (this.
|
|
3369
|
+
if (!(await this.releaseForCleanup(sessionId))) {
|
|
3039
3370
|
return void reply({ ok: false, error: 'Session is still active — stop it first' });
|
|
3040
3371
|
}
|
|
3041
3372
|
const branch = str(frame.args?.['branch']);
|
|
@@ -3727,6 +4058,43 @@ export class Supervisor {
|
|
|
3727
4058
|
return void reply({ ok: false, error: 'root argument is required' });
|
|
3728
4059
|
return void reply({ ok: true, result: fsView(root, str(frame.args?.['path']) ?? '.') });
|
|
3729
4060
|
}
|
|
4061
|
+
/**
|
|
4062
|
+
* Выложить файл с этой машины (0.46.0).
|
|
4063
|
+
*
|
|
4064
|
+
* Адрес, куда уходят байты, раннер собирает САМ из своего `apiUrl` —
|
|
4065
|
+
* во фрейме его нет и быть не должно. Иначе одна подделанная команда
|
|
4066
|
+
* стала бы способом вытянуть файл с чужой машины на чужой хост.
|
|
4067
|
+
*/
|
|
4068
|
+
case 'fs_publish': {
|
|
4069
|
+
const root = str(frame.args?.['root']);
|
|
4070
|
+
const filePath = str(frame.args?.['path']);
|
|
4071
|
+
const slotId = str(frame.args?.['slotId']);
|
|
4072
|
+
if (!root || !filePath || !slotId) {
|
|
4073
|
+
return void reply({ ok: false, error: 'root, path and slotId are required' });
|
|
4074
|
+
}
|
|
4075
|
+
if (!this.opts.apiUrl || !this.opts.runnerToken) {
|
|
4076
|
+
return void reply({
|
|
4077
|
+
ok: false,
|
|
4078
|
+
error: 'This runner has no API credentials configured',
|
|
4079
|
+
});
|
|
4080
|
+
}
|
|
4081
|
+
const maxBytesRaw = frame.args?.['maxBytes'];
|
|
4082
|
+
const maxBytes = typeof maxBytesRaw === 'number' ? maxBytesRaw : 0;
|
|
4083
|
+
try {
|
|
4084
|
+
const result = await publishFile({
|
|
4085
|
+
root,
|
|
4086
|
+
relPath: filePath,
|
|
4087
|
+
slotId,
|
|
4088
|
+
maxBytes,
|
|
4089
|
+
apiUrl: this.opts.apiUrl,
|
|
4090
|
+
token: this.opts.runnerToken,
|
|
4091
|
+
});
|
|
4092
|
+
return void reply({ ok: true, result });
|
|
4093
|
+
}
|
|
4094
|
+
catch (error) {
|
|
4095
|
+
return void reply({ ok: false, error: String(error.message ?? error) });
|
|
4096
|
+
}
|
|
4097
|
+
}
|
|
3730
4098
|
case 'self_update': {
|
|
3731
4099
|
// Everything that can refuse this lives in self-update.ts; here we
|
|
3732
4100
|
// only make sure the answer is on the wire BEFORE the process goes
|
|
@@ -4143,9 +4511,17 @@ export class Supervisor {
|
|
|
4143
4511
|
// would mean it goes on claiming background work forever.
|
|
4144
4512
|
...(running ? { backgroundTasks: running.backgroundTasks } : {}),
|
|
4145
4513
|
});
|
|
4514
|
+
// Every seat change is accompanied by a status report — a session starting,
|
|
4515
|
+
// parking, ending. Hooking the seat report here rather than at each of those
|
|
4516
|
+
// is the whole point: «remember to also tell the API» is the rule that had
|
|
4517
|
+
// already been broken in three places, and it is what this frame exists to
|
|
4518
|
+
// stop mattering. `publishSlots` sends only when the answer actually
|
|
4519
|
+
// changed, and the interval in the constructor is the net under both.
|
|
4520
|
+
this.publishSlots();
|
|
4146
4521
|
}
|
|
4147
4522
|
/** Graceful daemon shutdown: kill agents, keep sessions resumable server-side. */
|
|
4148
4523
|
shutdown() {
|
|
4524
|
+
clearInterval(this.slotsTimer);
|
|
4149
4525
|
this.authRelay.cancel();
|
|
4150
4526
|
for (const running of this.sessions.values()) {
|
|
4151
4527
|
this.clearBudgetTimers(running);
|
|
@@ -4330,10 +4706,6 @@ const AGENT_LABELS = { CLAUDE: 'Claude Code', CODEX: 'Codex' };
|
|
|
4330
4706
|
function reportsCost(agent) {
|
|
4331
4707
|
return agent === 'CLAUDE';
|
|
4332
4708
|
}
|
|
4333
|
-
/** Adapter error codes that mean "the sign-in did not work". */
|
|
4334
|
-
function isAuthCode(code) {
|
|
4335
|
-
return code === 'auth_expired' || code === 'auth_missing';
|
|
4336
|
-
}
|
|
4337
4709
|
/**
|
|
4338
4710
|
* Does this descriptor point at work that already exists on a branch?
|
|
4339
4711
|
* If so, silently creating a fresh branch off HEAD would hide the agent's
|