@bridge4dev/runner 0.64.1 → 0.65.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/agent-tasks.d.ts +113 -0
- package/dist/adapters/agent-tasks.js +260 -0
- package/dist/adapters/claude.js +45 -236
- package/dist/adapters/codex-subagents.d.ts +169 -0
- package/dist/adapters/codex-subagents.js +569 -0
- package/dist/adapters/codex.d.ts +4 -0
- package/dist/adapters/codex.js +219 -20
- package/dist/daemon-exit.d.ts +40 -0
- package/dist/daemon-exit.js +29 -0
- package/dist/index.js +15 -17
- package/dist/session-cage.d.ts +49 -0
- package/dist/session-cage.js +238 -12
- package/dist/session-stall.d.ts +25 -0
- package/dist/session-stall.js +84 -0
- package/dist/supervisor.d.ts +86 -2
- package/dist/supervisor.js +215 -5
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/ws-client.js +14 -0
- package/package.json +1 -1
package/dist/adapters/codex.js
CHANGED
|
@@ -6,6 +6,8 @@ import { RUNNER_VERSION } from '../version.js';
|
|
|
6
6
|
import { repairCodexAuth } from './codex-home.js';
|
|
7
7
|
import { AppServerClient, asRecord, num, RpcError, RpcTimeoutError, str, } from './codex-protocol.js';
|
|
8
8
|
import { truncate } from './claude.js';
|
|
9
|
+
import { AgentTaskTray } from './agent-tasks.js';
|
|
10
|
+
import { CodexSubagents, threadProbeOver } from './codex-subagents.js';
|
|
9
11
|
import { availableModes, cardDescription, DIRECT_BRANCH_RULE, folderRuleFor, MODE_REFUSED_TEXT, MODE_WITHDRAWN_TEXT, policyContextFor, DEVBRIDGE_MCP_SERVER_NAME, } from './types.js';
|
|
10
12
|
import { clampPercent, rateWindowKeyFromMinutes } from './rate-limits.js';
|
|
11
13
|
import { answerSummary, invalidationMessage, mirrorOptions, newAskId, MAX_OPTIONS, MAX_QUESTIONS, OPTION_TEXT_LIMIT, QUESTION_TEXT_LIMIT, } from './questions.js';
|
|
@@ -182,6 +184,24 @@ const APPROVAL_METHODS = new Set([
|
|
|
182
184
|
'item/permissions/requestApproval',
|
|
183
185
|
'mcpServer/elicitation/request',
|
|
184
186
|
]);
|
|
187
|
+
/**
|
|
188
|
+
* Notifications that concern the whole session whichever thread they name
|
|
189
|
+
* (#382). Everything else from a thread other than the session's own belongs to
|
|
190
|
+
* a helper – see `CodexSession.onHelperNotification`.
|
|
191
|
+
*
|
|
192
|
+
* - `serverRequest/resolved`: a helper's approval is shown as an ordinary card,
|
|
193
|
+
* and the card has to close when it is answered elsewhere;
|
|
194
|
+
* - MCP start-up and the warnings: the helpers share the session's servers and
|
|
195
|
+
* configuration, and each distinct sentence is shown once anyway.
|
|
196
|
+
*/
|
|
197
|
+
const SESSION_WIDE = new Set([
|
|
198
|
+
'serverRequest/resolved',
|
|
199
|
+
'mcpServer/startupStatus/updated',
|
|
200
|
+
'configWarning',
|
|
201
|
+
'warning',
|
|
202
|
+
'guardianWarning',
|
|
203
|
+
'deprecationNotice',
|
|
204
|
+
]);
|
|
185
205
|
class CodexSession {
|
|
186
206
|
spec;
|
|
187
207
|
home;
|
|
@@ -236,6 +256,15 @@ class CodexSession {
|
|
|
236
256
|
stopped = false;
|
|
237
257
|
ready = false;
|
|
238
258
|
capabilitiesInFlight = false;
|
|
259
|
+
/**
|
|
260
|
+
* Helpers at work beside the conversation (#382) – the same tray, and so the
|
|
261
|
+
* same «is anybody still working» rule, as the Claude adapter's.
|
|
262
|
+
*/
|
|
263
|
+
tray = new AgentTaskTray({
|
|
264
|
+
emit: (event) => this.emit(event),
|
|
265
|
+
isStopped: () => this.stopped,
|
|
266
|
+
});
|
|
267
|
+
subagents;
|
|
239
268
|
events = this.output;
|
|
240
269
|
constructor(spec, home, deps) {
|
|
241
270
|
this.spec = spec;
|
|
@@ -272,6 +301,16 @@ class CodexSession {
|
|
|
272
301
|
sessionId: spec.sessionId,
|
|
273
302
|
...wiring,
|
|
274
303
|
});
|
|
304
|
+
this.subagents = new CodexSubagents({
|
|
305
|
+
tray: this.tray,
|
|
306
|
+
ownThreadId: () => this.threadId,
|
|
307
|
+
probe: threadProbeOver((method, params, timeoutMs) => this.client.request(method, params, timeoutMs)),
|
|
308
|
+
isStopped: () => this.stopped,
|
|
309
|
+
...(deps.subagentReconcileMs === undefined ? {} : { reconcileMs: deps.subagentReconcileMs }),
|
|
310
|
+
...(deps.subagentSpawnGraceMs === undefined
|
|
311
|
+
? {}
|
|
312
|
+
: { spawnGraceMs: deps.subagentSpawnGraceMs }),
|
|
313
|
+
});
|
|
275
314
|
if (this.modeRefusedAtLaunch) {
|
|
276
315
|
this.notice('warn', MODE_REFUSED_TEXT);
|
|
277
316
|
this.emit({ type: 'settings', mode: this.mode });
|
|
@@ -307,6 +346,17 @@ class CodexSession {
|
|
|
307
346
|
throw new Error(`codex is using an unexpected CODEX_HOME (${reportedHome ?? 'not reported'}) — refusing to start the session`);
|
|
308
347
|
}
|
|
309
348
|
this.client.notify('initialized', {});
|
|
349
|
+
// The helper set is per PROCESS (#113, #382): a session relaunched after a
|
|
350
|
+
// runner restart must not go on showing the helpers of its previous life,
|
|
351
|
+
// and nothing else would say so until the next one starts or ends.
|
|
352
|
+
//
|
|
353
|
+
// Here and not in the constructor, and that is not tidiness: the
|
|
354
|
+
// supervisor reads the FIRST event of a session as «the process came up»
|
|
355
|
+
// and calls off the watch for a CLI that never boots (#225). A frame
|
|
356
|
+
// emitted before a single byte reached the app-server would answer that
|
|
357
|
+
// watch for every Codex session, on behalf of a process that has said
|
|
358
|
+
// nothing. This is the first moment the process really has.
|
|
359
|
+
this.tray.announceEmpty();
|
|
310
360
|
if (this.home.auth === 'missing') {
|
|
311
361
|
// One repair attempt before telling the user their login is broken: the
|
|
312
362
|
// credential link can be removed under a running daemon, and putting it
|
|
@@ -956,6 +1006,9 @@ class CodexSession {
|
|
|
956
1006
|
if (this.stopped)
|
|
957
1007
|
return;
|
|
958
1008
|
this.stopped = true;
|
|
1009
|
+
// The helpers go with the process; nothing more is published about them.
|
|
1010
|
+
this.subagents.close();
|
|
1011
|
+
this.tray.close();
|
|
959
1012
|
// Release anything the agent is blocked on so the child can exit cleanly —
|
|
960
1013
|
// and say in the feed that the runner did it, not the user. A pending card
|
|
961
1014
|
// used to gutter out looking like a human decision.
|
|
@@ -1262,6 +1315,17 @@ class CodexSession {
|
|
|
1262
1315
|
}
|
|
1263
1316
|
// ─── Notifications ─────────────────────────────────────────────────
|
|
1264
1317
|
onNotification(method, params) {
|
|
1318
|
+
// #382. One app-server runs the session's thread AND every helper it spawns,
|
|
1319
|
+
// and the helpers' notifications arrive on this same connection tagged with
|
|
1320
|
+
// their own thread id (measured on 0.154.0). Read as ours, a helper's
|
|
1321
|
+
// `turn/completed` ended THIS session's turn while it was still working, its
|
|
1322
|
+
// `turn/started` became the turn a Stop or a steer is aimed at, and its
|
|
1323
|
+
// prose went into the feed as this session's answer.
|
|
1324
|
+
const thread = str(params['threadId']);
|
|
1325
|
+
if (thread && this.threadId && thread !== this.threadId && !SESSION_WIDE.has(method)) {
|
|
1326
|
+
this.onHelperNotification(thread, method, params);
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1265
1329
|
switch (method) {
|
|
1266
1330
|
case 'item/started':
|
|
1267
1331
|
case 'item/updated':
|
|
@@ -1417,21 +1481,100 @@ class CodexSession {
|
|
|
1417
1481
|
return;
|
|
1418
1482
|
}
|
|
1419
1483
|
}
|
|
1484
|
+
/**
|
|
1485
|
+
* A notification from another thread in this app-server – a helper's, or one
|
|
1486
|
+
* of Codex's own (#382).
|
|
1487
|
+
*
|
|
1488
|
+
* What it may change is the helper count and, while a turn of this session is
|
|
1489
|
+
* running, that turn's «work was done» flags – and nothing else: not the turn
|
|
1490
|
+
* itself, not the feed, not the context meter or the settings on the header,
|
|
1491
|
+
* and not the session's health. A helper's failure is the helper's; the one
|
|
1492
|
+
* exception is the plan limit, which belongs to the account and refuses this
|
|
1493
|
+
* session next. Its tool calls stay
|
|
1494
|
+
* out of the feed too, and that is not tidiness: the supervisor reads any
|
|
1495
|
+
* tool event from a resting session as «the agent is working» (#185), and no
|
|
1496
|
+
* turn of THIS session would ever come to put it back – a helper finishing
|
|
1497
|
+
* does not start one.
|
|
1498
|
+
*
|
|
1499
|
+
* Its items are still remembered: a helper's command that needs a person
|
|
1500
|
+
* arrives as an ordinary approval request, and the card is only readable
|
|
1501
|
+
* because the item behind it is known.
|
|
1502
|
+
*/
|
|
1503
|
+
onHelperNotification(thread, method, params) {
|
|
1504
|
+
switch (method) {
|
|
1505
|
+
case 'item/started':
|
|
1506
|
+
case 'item/updated':
|
|
1507
|
+
case 'item/completed': {
|
|
1508
|
+
const item = asRecord(params['item']);
|
|
1509
|
+
this.rememberItem(item);
|
|
1510
|
+
// What a helper does while this session's turn is running is that turn's
|
|
1511
|
+
// work (#252, #257) – the Claude adapter counts a subagent's tool calls
|
|
1512
|
+
// for the same reason: a helper's `git push` is still a push, and a
|
|
1513
|
+
// failed turn that pushed must not be sent again as if nothing happened.
|
|
1514
|
+
if (method === 'item/started' &&
|
|
1515
|
+
item['type'] === 'commandExecution' &&
|
|
1516
|
+
this.activeTurnId !== null &&
|
|
1517
|
+
this.subagents.isHelper(thread)) {
|
|
1518
|
+
this.noteWork(str(item['command']) ?? '');
|
|
1519
|
+
}
|
|
1520
|
+
this.subagents.onItem(thread, item);
|
|
1521
|
+
return;
|
|
1522
|
+
}
|
|
1523
|
+
case 'turn/started':
|
|
1524
|
+
this.subagents.onHelperTurn(thread, 'started');
|
|
1525
|
+
return;
|
|
1526
|
+
case 'turn/completed': {
|
|
1527
|
+
const turn = asRecord(params['turn']);
|
|
1528
|
+
// A helper refused by the plan limit is this ACCOUNT being refused: the
|
|
1529
|
+
// session's own next turn will be refused too, so the API is told and
|
|
1530
|
+
// the feed says why (#382). Only the announcement — no turn of this
|
|
1531
|
+
// session ended, so there is no ending to mark `limitBlocked`.
|
|
1532
|
+
if (str(turn['status']) === 'failed') {
|
|
1533
|
+
this.announceRateLimitRefusal(asRecord(turn['error']));
|
|
1534
|
+
}
|
|
1535
|
+
this.subagents.onHelperTurn(thread, 'completed', str(turn['status']));
|
|
1536
|
+
return;
|
|
1537
|
+
}
|
|
1538
|
+
case 'thread/closed':
|
|
1539
|
+
this.subagents.onThreadClosed(thread);
|
|
1540
|
+
return;
|
|
1541
|
+
case 'error':
|
|
1542
|
+
if (params['willRetry'] !== true && this.subagents.isHelper(thread)) {
|
|
1543
|
+
const detail = asRecord(params['error']);
|
|
1544
|
+
// The one failure of a helper that is not the helper's own business:
|
|
1545
|
+
// the plan limit is the account's, and the session is next (#382).
|
|
1546
|
+
if (!this.announceRateLimitRefusal(detail)) {
|
|
1547
|
+
log.warn('codex: a helper agent reported an error', {
|
|
1548
|
+
sessionId: this.spec.sessionId,
|
|
1549
|
+
message: maskString(str(detail['message']) ?? '').slice(0, 300),
|
|
1550
|
+
});
|
|
1551
|
+
}
|
|
1552
|
+
}
|
|
1553
|
+
return;
|
|
1554
|
+
default:
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
}
|
|
1558
|
+
/** Keep an item's last state – approval params alone are too thin. */
|
|
1559
|
+
rememberItem(item) {
|
|
1560
|
+
const id = str(item['id']);
|
|
1561
|
+
if (!id)
|
|
1562
|
+
return;
|
|
1563
|
+
this.items.set(id, item);
|
|
1564
|
+
// Bound the cache: a long session would otherwise hold every item.
|
|
1565
|
+
if (this.items.size > 400) {
|
|
1566
|
+
const oldest = this.items.keys().next().value;
|
|
1567
|
+
if (oldest !== undefined)
|
|
1568
|
+
this.items.delete(oldest);
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1420
1571
|
onItem(method, params) {
|
|
1421
1572
|
const item = asRecord(params['item']);
|
|
1422
1573
|
const type = str(item['type']);
|
|
1423
1574
|
const id = str(item['id']);
|
|
1424
1575
|
if (!type)
|
|
1425
1576
|
return;
|
|
1426
|
-
|
|
1427
|
-
this.items.set(id, item);
|
|
1428
|
-
// Bound the cache: a long session would otherwise hold every item.
|
|
1429
|
-
if (this.items.size > 400) {
|
|
1430
|
-
const oldest = this.items.keys().next().value;
|
|
1431
|
-
if (oldest !== undefined)
|
|
1432
|
-
this.items.delete(oldest);
|
|
1433
|
-
}
|
|
1434
|
-
}
|
|
1577
|
+
this.rememberItem(item);
|
|
1435
1578
|
const done = method === 'item/completed';
|
|
1436
1579
|
switch (type) {
|
|
1437
1580
|
case 'agentMessage': {
|
|
@@ -1556,6 +1699,17 @@ class CodexSession {
|
|
|
1556
1699
|
}
|
|
1557
1700
|
return;
|
|
1558
1701
|
}
|
|
1702
|
+
case 'collabAgentToolCall':
|
|
1703
|
+
case 'subAgentActivity': {
|
|
1704
|
+
// #382: helpers starting, being handed work and finishing. Counted, and
|
|
1705
|
+
// deliberately NOT a feed row: a helper's `completed` arrives after this
|
|
1706
|
+
// session's turn is over, and a tool event then would put a resting
|
|
1707
|
+
// session back to «working» with no turn to end it (see
|
|
1708
|
+
// `onHelperNotification`).
|
|
1709
|
+
if (this.threadId)
|
|
1710
|
+
this.subagents.onItem(this.threadId, item);
|
|
1711
|
+
return;
|
|
1712
|
+
}
|
|
1559
1713
|
case 'webSearch':
|
|
1560
1714
|
case 'dynamicToolCall': {
|
|
1561
1715
|
if (!done) {
|
|
@@ -1741,6 +1895,29 @@ class CodexSession {
|
|
|
1741
1895
|
* one turn.
|
|
1742
1896
|
*/
|
|
1743
1897
|
static LIMIT_SETTLE_MS = 180_000;
|
|
1898
|
+
/**
|
|
1899
|
+
* The plan is spent: tell the API so it can arm its clock, and tell the
|
|
1900
|
+
* person in the feed. Says nothing about whose turn it was.
|
|
1901
|
+
*
|
|
1902
|
+
* Split out of `noteRateLimitRefusal` for the refusals that belong to no turn
|
|
1903
|
+
* of this session at all — a helper's (#382). The block is account-wide: the
|
|
1904
|
+
* same account, the same window, and the next turn of the session will be
|
|
1905
|
+
* refused too. Silence here was a false «your turn» over a plan that had run
|
|
1906
|
+
* out, with nothing in the feed to explain it and no pause armed.
|
|
1907
|
+
*/
|
|
1908
|
+
announceRateLimitRefusal(error) {
|
|
1909
|
+
const blocked = this.rateLimitRefusal(error);
|
|
1910
|
+
if (!blocked)
|
|
1911
|
+
return false;
|
|
1912
|
+
this.emitRateLimits(blocked);
|
|
1913
|
+
this.emit({
|
|
1914
|
+
type: 'notice',
|
|
1915
|
+
level: 'warn',
|
|
1916
|
+
text: 'Codex refused the turn: the plan limit is spent' +
|
|
1917
|
+
(blocked.resetsAt ? `, and it lifts at ${describeResetTime(blocked.resetsAt)}.` : '.'),
|
|
1918
|
+
});
|
|
1919
|
+
return true;
|
|
1920
|
+
}
|
|
1744
1921
|
/**
|
|
1745
1922
|
* The plan is spent — say so, and keep the session alive to be woken.
|
|
1746
1923
|
*
|
|
@@ -1758,16 +1935,8 @@ class CodexSession {
|
|
|
1758
1935
|
* feed of the session this was found on.
|
|
1759
1936
|
*/
|
|
1760
1937
|
noteRateLimitRefusal(error) {
|
|
1761
|
-
|
|
1762
|
-
if (!blocked)
|
|
1938
|
+
if (!this.announceRateLimitRefusal(error))
|
|
1763
1939
|
return false;
|
|
1764
|
-
this.emitRateLimits(blocked);
|
|
1765
|
-
this.emit({
|
|
1766
|
-
type: 'notice',
|
|
1767
|
-
level: 'warn',
|
|
1768
|
-
text: 'Codex refused the turn: the plan limit is spent' +
|
|
1769
|
-
(blocked.resetsAt ? `, and it lifts at ${describeResetTime(blocked.resetsAt)}.` : '.'),
|
|
1770
|
-
});
|
|
1771
1940
|
/**
|
|
1772
1941
|
* The flag is set ONLY while a turn is in flight, and that is not caution.
|
|
1773
1942
|
*
|
|
@@ -1800,6 +1969,7 @@ class CodexSession {
|
|
|
1800
1969
|
// is still working — a steer would be aimed at a turn that has ended,
|
|
1801
1970
|
// and the next real turn would look like a turn already in flight.
|
|
1802
1971
|
this.activeTurnId = null;
|
|
1972
|
+
this.subagents.endTurn();
|
|
1803
1973
|
this.emit({
|
|
1804
1974
|
type: 'turn_end',
|
|
1805
1975
|
ok: false,
|
|
@@ -1864,6 +2034,10 @@ class CodexSession {
|
|
|
1864
2034
|
return;
|
|
1865
2035
|
}
|
|
1866
2036
|
this.activeTurnId = null;
|
|
2037
|
+
// #382: what is still running, said BEFORE the ending – the supervisor
|
|
2038
|
+
// decides «is this the person's turn» on the count it holds when `turn_end`
|
|
2039
|
+
// arrives.
|
|
2040
|
+
this.subagents.endTurn();
|
|
1867
2041
|
// A held plan means the turn ended by proposing, not by finishing the work.
|
|
1868
2042
|
if (this.heldPlan)
|
|
1869
2043
|
return;
|
|
@@ -1945,12 +2119,35 @@ class CodexSession {
|
|
|
1945
2119
|
// codex is chatty on stderr (bubblewrap notices, MCP transport spam). Log
|
|
1946
2120
|
// locally, never turn it into session events.
|
|
1947
2121
|
log.warn('codex: stderr', { text: maskString(trimmed).slice(0, 500) });
|
|
1948
|
-
|
|
2122
|
+
/**
|
|
2123
|
+
* …with one exception, kept for the death below (#431). A process that
|
|
2124
|
+
* never reached `initialize` said exactly one thing on its way out, and
|
|
2125
|
+
* that sentence is the difference between «code 1» and a cause: on 16.09 it
|
|
2126
|
+
* was systemd's «Unit … was already loaded», and the person got the code.
|
|
2127
|
+
*
|
|
2128
|
+
* Only until the session is ready: after that this is never read again, and
|
|
2129
|
+
* codex is chatty enough on stderr for «never read» to be worth not
|
|
2130
|
+
* computing (independent review of #431).
|
|
2131
|
+
*/
|
|
2132
|
+
if (this.ready)
|
|
2133
|
+
return;
|
|
2134
|
+
const last = trimmed
|
|
2135
|
+
.split('\n')
|
|
2136
|
+
.filter((line) => line.trim())
|
|
2137
|
+
.pop();
|
|
2138
|
+
if (last !== undefined)
|
|
2139
|
+
this.lastStderrLine = last.trim();
|
|
2140
|
+
}
|
|
2141
|
+
/** The last thing this process said on stderr — see {@link onStderr}. */
|
|
2142
|
+
lastStderrLine = '';
|
|
1949
2143
|
onExit(info) {
|
|
1950
2144
|
if (!this.stopped && !this.ready) {
|
|
2145
|
+
// The cause, when the process left one. Masked and cut like every other
|
|
2146
|
+
// line that comes out of a child process here.
|
|
2147
|
+
const said = this.lastStderrLine ? `: ${maskString(this.lastStderrLine).slice(0, 200)}` : '';
|
|
1951
2148
|
this.emit({
|
|
1952
2149
|
type: 'error',
|
|
1953
|
-
message: `codex app-server exited before the session was ready (code ${info.code ?? 'null'})`,
|
|
2150
|
+
message: `codex app-server exited before the session was ready (code ${info.code ?? 'null'})${said}`,
|
|
1954
2151
|
// #373: the process is what ended, not a turn. `stopped` is already
|
|
1955
2152
|
// excluded above, so this is always an exit nobody here asked for.
|
|
1956
2153
|
processGone: true,
|
|
@@ -2310,6 +2507,8 @@ class CodexSession {
|
|
|
2310
2507
|
finish() {
|
|
2311
2508
|
this.stopped = true;
|
|
2312
2509
|
this.clearLimitSettle();
|
|
2510
|
+
this.subagents.close();
|
|
2511
|
+
this.tray.close();
|
|
2313
2512
|
this.output.end();
|
|
2314
2513
|
}
|
|
2315
2514
|
}
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The one way out of the daemon process (#431).
|
|
3
|
+
*
|
|
4
|
+
* Its own module, and not four lines inside `cmdDaemon`, for one reason: the
|
|
5
|
+
* defect this ticket is about WAS those four lines. `supervisor.shutdown();
|
|
6
|
+
* ws.stop(); process.exit(0);` in a single tick killed one process per session
|
|
7
|
+
* and left everything that process had started running inside a cgroup systemd
|
|
8
|
+
* would then refuse to reuse by name. Nothing in the suite could see that,
|
|
9
|
+
* because no test in this package imports `index.ts` — found by the independent
|
|
10
|
+
* review of #431, which also pointed out that the next edit to that file would
|
|
11
|
+
* be just as invisible.
|
|
12
|
+
*
|
|
13
|
+
* So the order is code with a test on it rather than a comment:
|
|
14
|
+
*
|
|
15
|
+
* 1. **`shutdown()`** — its synchronous half runs before it returns: the cards
|
|
16
|
+
* a person was still being asked are withdrawn, the line saying what this
|
|
17
|
+
* restart costs goes into the journal, the agents are killed.
|
|
18
|
+
* 2. **the socket is closed** — after the events above needed it open, and
|
|
19
|
+
* before the wait below, which must not be a window in which an `event_ack`
|
|
20
|
+
* or a `session_start` reaches a supervisor that has let go of everything.
|
|
21
|
+
* 3. **the promise is awaited** — the cages, and nothing else. What does not
|
|
22
|
+
* finish inside the supervisor's own budget continues on systemd's side:
|
|
23
|
+
* the stop has been accepted by then.
|
|
24
|
+
* 4. **`exit`**, always — including when the wait ends in a rejection. A
|
|
25
|
+
* daemon that would not die because a bus call failed is worse than one
|
|
26
|
+
* that leaves a scope behind.
|
|
27
|
+
*/
|
|
28
|
+
export interface DaemonExitDeps {
|
|
29
|
+
/** The supervisor's shutdown. The synchronous half must run before it returns. */
|
|
30
|
+
shutdown: () => Promise<void>;
|
|
31
|
+
/** Close the socket, both directions. */
|
|
32
|
+
stopWs: () => void;
|
|
33
|
+
/** Write the status file, so `doctor` sees a daemon that is going down. */
|
|
34
|
+
noteStatus?: () => void;
|
|
35
|
+
/** End the process. Injected so the order above can be proved. */
|
|
36
|
+
exit: (code: number) => void;
|
|
37
|
+
}
|
|
38
|
+
/** Build the exit door. The returned function is safe to call more than once. */
|
|
39
|
+
export declare function daemonExit(deps: DaemonExitDeps): (code: number) => void;
|
|
40
|
+
//# sourceMappingURL=daemon-exit.d.ts.map
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { log } from './log.js';
|
|
2
|
+
/** Build the exit door. The returned function is safe to call more than once. */
|
|
3
|
+
export function daemonExit(deps) {
|
|
4
|
+
let leaving = false;
|
|
5
|
+
return (code) => {
|
|
6
|
+
/**
|
|
7
|
+
* A second signal does not shorten the first departure.
|
|
8
|
+
*
|
|
9
|
+
* Two `SIGTERM`s, or «Update runner» racing a restart command, used to run
|
|
10
|
+
* the whole thing twice: a second `verify.shutdown()` signalling a group
|
|
11
|
+
* that is already dying, and an `exit` from the second call cutting the
|
|
12
|
+
* first call's wait for its cages — the very wait this door exists for.
|
|
13
|
+
*/
|
|
14
|
+
if (leaving) {
|
|
15
|
+
log.warn('daemon: already leaving, this second request changes nothing', { code });
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
leaving = true;
|
|
19
|
+
const cages = deps.shutdown();
|
|
20
|
+
deps.stopWs();
|
|
21
|
+
deps.noteStatus?.();
|
|
22
|
+
void cages
|
|
23
|
+
.catch((error) => {
|
|
24
|
+
log.warn('daemon: the cages did not all stop cleanly', { error: String(error) });
|
|
25
|
+
})
|
|
26
|
+
.finally(() => deps.exit(code));
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
//# sourceMappingURL=daemon-exit.js.map
|
package/dist/index.js
CHANGED
|
@@ -25,6 +25,7 @@ import { buildUnit, cpuQuotaPercent, devbridgeSliceOverridePath, limitsOverrideI
|
|
|
25
25
|
import { defaultCageProbe, initSessionCage, readSliceLimits, listSessionScopes, sessionCage, sweepOrphanSessionScopes, SESSION_TASKS_MAX, } from './session-cage.js';
|
|
26
26
|
import { SEARCH_GUARD_ENABLED, claudeSettingsPath, installSearchGuard, removeSearchGuard, searchGuardCommand, searchGuardHome, searchGuardHookPath, searchGuardStatus, } from './claude-settings.js';
|
|
27
27
|
import { readOomKills, recordCrash, takeLastExit } from './crash-note.js';
|
|
28
|
+
import { daemonExit } from './daemon-exit.js';
|
|
28
29
|
import { agentAuthStatuses } from './auth-relay.js';
|
|
29
30
|
import { addSafeDirectory, agentConfigContour, dockerCheck, ensureAgentPath, firstUnreachableAncestor, hasSafeDirectory, inspectPath, knownWorkspacePaths, lingerEnabled, nodeCheck, otherHomeWithAgents, runnerIdentity, safeDirectoryCommand, systemctlHint, systemdUserBusReachable, } from './environment.js';
|
|
30
31
|
import { mcpConfigDir } from './paths.js';
|
|
@@ -1019,11 +1020,7 @@ async function cmdDaemon() {
|
|
|
1019
1020
|
from: outcome.fromVersion,
|
|
1020
1021
|
to: outcome.toVersion ?? 'unknown',
|
|
1021
1022
|
});
|
|
1022
|
-
const timer = setTimeout(() =>
|
|
1023
|
-
supervisor.shutdown();
|
|
1024
|
-
ws.stop();
|
|
1025
|
-
process.exit(0);
|
|
1026
|
-
}, RESTART_DELAY_MS);
|
|
1023
|
+
const timer = setTimeout(() => leave(0), RESTART_DELAY_MS);
|
|
1027
1024
|
timer.unref();
|
|
1028
1025
|
},
|
|
1029
1026
|
// #396: the same exit, asked for directly rather than as the tail of an
|
|
@@ -1035,11 +1032,7 @@ async function cmdDaemon() {
|
|
|
1035
1032
|
sessions: supervisor.activeSessionIds.length,
|
|
1036
1033
|
told: Boolean(note),
|
|
1037
1034
|
});
|
|
1038
|
-
const timer = setTimeout(() =>
|
|
1039
|
-
supervisor.shutdown();
|
|
1040
|
-
ws.stop();
|
|
1041
|
-
process.exit(0);
|
|
1042
|
-
}, RESTART_DELAY_MS);
|
|
1035
|
+
const timer = setTimeout(() => leave(0), RESTART_DELAY_MS);
|
|
1043
1036
|
timer.unref();
|
|
1044
1037
|
},
|
|
1045
1038
|
});
|
|
@@ -1052,13 +1045,21 @@ async function cmdDaemon() {
|
|
|
1052
1045
|
activeSessionIds: supervisor.activeSessionIds,
|
|
1053
1046
|
updatedAt: new Date().toISOString(),
|
|
1054
1047
|
});
|
|
1048
|
+
/**
|
|
1049
|
+
* The one way out of this process — the order lives in `daemon-exit.ts`,
|
|
1050
|
+
* where a test can hold it (#431).
|
|
1051
|
+
*/
|
|
1052
|
+
const leave = daemonExit({
|
|
1053
|
+
shutdown: () => supervisor.shutdown(),
|
|
1054
|
+
stopWs: () => ws.stop(),
|
|
1055
|
+
noteStatus: updateStatus,
|
|
1056
|
+
exit: (code) => process.exit(code),
|
|
1057
|
+
});
|
|
1055
1058
|
ws.on('open', updateStatus);
|
|
1056
1059
|
ws.on('close', updateStatus);
|
|
1057
1060
|
ws.on('revoked', (reason) => {
|
|
1058
1061
|
log.error(`daemon: access revoked (${reason}) — exiting`);
|
|
1059
|
-
|
|
1060
|
-
updateStatus();
|
|
1061
|
-
process.exit(3);
|
|
1062
|
+
leave(3);
|
|
1062
1063
|
});
|
|
1063
1064
|
const statusTimer = setInterval(updateStatus, 30_000);
|
|
1064
1065
|
statusTimer.unref();
|
|
@@ -1072,10 +1073,7 @@ async function cmdDaemon() {
|
|
|
1072
1073
|
limitsTimer.unref();
|
|
1073
1074
|
const shutdown = (signal) => {
|
|
1074
1075
|
log.info(`daemon: ${signal} received, shutting down`);
|
|
1075
|
-
|
|
1076
|
-
ws.stop();
|
|
1077
|
-
updateStatus();
|
|
1078
|
-
process.exit(0);
|
|
1076
|
+
leave(0);
|
|
1079
1077
|
};
|
|
1080
1078
|
process.on('SIGINT', () => shutdown('SIGINT'));
|
|
1081
1079
|
process.on('SIGTERM', () => shutdown('SIGTERM'));
|
package/dist/session-cage.d.ts
CHANGED
|
@@ -300,6 +300,13 @@ export declare function sanitizeCageId(id: string): string;
|
|
|
300
300
|
* normal case and the marker appears only while the old scope is still there.
|
|
301
301
|
*/
|
|
302
302
|
export declare function sessionScopeUnit(id: string, attempt?: number): string;
|
|
303
|
+
/**
|
|
304
|
+
* Is there a cgroup by this unit's name — that is, processes still in it?
|
|
305
|
+
*
|
|
306
|
+
* `dirOf` is the test seam: the answer depends on a cgroup tree, and a suite
|
|
307
|
+
* has none. Everything else about this function is one `stat`.
|
|
308
|
+
*/
|
|
309
|
+
export declare function cageCgroupExists(unit: string, dirOf?: (unit: string) => string | null): boolean;
|
|
303
310
|
/**
|
|
304
311
|
* The systemd release that added `--expand-environment=`.
|
|
305
312
|
*
|
|
@@ -433,6 +440,16 @@ export declare function buildCagedSpawn(input: {
|
|
|
433
440
|
id: string;
|
|
434
441
|
command: string;
|
|
435
442
|
args: string[];
|
|
443
|
+
},
|
|
444
|
+
/**
|
|
445
|
+
* The two things this function does to the machine besides building a command
|
|
446
|
+
* line, as seams (#431): whether a cage of that name still exists, and what to
|
|
447
|
+
* do about it. A suite has neither a cgroup tree nor a systemd to answer.
|
|
448
|
+
*/
|
|
449
|
+
io?: {
|
|
450
|
+
cageExists?: (unit: string) => boolean;
|
|
451
|
+
cageLiveness?: (unit: string) => ScopeLiveness;
|
|
452
|
+
stopCage?: (unit: string) => void;
|
|
436
453
|
}): CagedSpawn;
|
|
437
454
|
/**
|
|
438
455
|
* Did this process die in the window before `systemd-run` handed over?
|
|
@@ -628,6 +645,23 @@ export declare function markScopeOomKillsSeen(id: string, oomKills: number): voi
|
|
|
628
645
|
* thing and must not word it differently.
|
|
629
646
|
*/
|
|
630
647
|
export declare function stoppedProcesses(count: number, where?: 'it' | 'this one'): string;
|
|
648
|
+
/**
|
|
649
|
+
* What the feed says when the runner takes a session's cage down with itself
|
|
650
|
+
* (#431).
|
|
651
|
+
*
|
|
652
|
+
* Next to {@link stoppedProcesses} and for the same reason: two places that
|
|
653
|
+
* count the same thing must not word it differently. This one counts COMMANDS,
|
|
654
|
+
* because that is what a person can act on — «7 processes» is the same build
|
|
655
|
+
* said in a way nobody can use.
|
|
656
|
+
*
|
|
657
|
+
* The middle sentence is the part that matters. A command that outlives the
|
|
658
|
+
* process which started it cannot hand its output back to anybody: the pipe
|
|
659
|
+
* died with the agent, and the agent that comes back is a different process
|
|
660
|
+
* with a different tool call. Stopping it is not a loss, it is the refusal to
|
|
661
|
+
* keep a zombie — but the person has to be told, or they will wait for a build
|
|
662
|
+
* that nobody is going to report.
|
|
663
|
+
*/
|
|
664
|
+
export declare function restartStoppedCommands(count: number): string;
|
|
631
665
|
/**
|
|
632
666
|
* Snapshot the cgroup's verdict before systemd can take it away.
|
|
633
667
|
*
|
|
@@ -669,6 +703,21 @@ export declare function releaseSessionScope(unit: string | null, id?: string, sy
|
|
|
669
703
|
* worth nothing, and a hung `systemctl` must not hold a failing session open.
|
|
670
704
|
*/
|
|
671
705
|
export declare function memoryDeathSentence(id: string, capMs?: number): Promise<string | null>;
|
|
706
|
+
/**
|
|
707
|
+
* Stop a cage and let systemd forget its name — the other half of killing the
|
|
708
|
+
* agent (#431, грабля §524).
|
|
709
|
+
*
|
|
710
|
+
* {@link releaseSessionScope} is the path for a process that ENDED: it reads
|
|
711
|
+
* the verdict first and only stops what is left. This one is for the two
|
|
712
|
+
* moments where there is nothing to read and no time to read it — the daemon
|
|
713
|
+
* going down, and a start that found its own name still taken. It asks for the
|
|
714
|
+
* stop and nothing else.
|
|
715
|
+
*
|
|
716
|
+
* Worth knowing on the shutdown path: once `stop` has been accepted, the job
|
|
717
|
+
* belongs to systemd. A caller that gives up waiting still gets the cage
|
|
718
|
+
* stopped — it simply does not get to see it happen.
|
|
719
|
+
*/
|
|
720
|
+
export declare function stopSessionScope(unit: string | null, systemctl?: Systemctl): Promise<boolean>;
|
|
672
721
|
/** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
|
|
673
722
|
export declare function listSessionScopeUnits(systemctl?: Systemctl): Promise<string[]>;
|
|
674
723
|
export interface SessionScopeInfo {
|