@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/session-cage.js
CHANGED
|
@@ -367,6 +367,45 @@ export function sessionScopeUnit(id, attempt = 1) {
|
|
|
367
367
|
const base = `${SESSION_SCOPE_PREFIX}${sanitizeCageId(id)}`;
|
|
368
368
|
return attempt <= 1 ? `${base}.scope` : `${base}-r${attempt}.scope`;
|
|
369
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* How many names one start may walk through before giving up and letting
|
|
372
|
+
* systemd refuse it (#431). Three is two more than has ever been needed.
|
|
373
|
+
*/
|
|
374
|
+
const CAGE_NAME_MAX_TRIES = 3;
|
|
375
|
+
/**
|
|
376
|
+
* Is there a cgroup by this unit's name — that is, processes still in it?
|
|
377
|
+
*
|
|
378
|
+
* `dirOf` is the test seam: the answer depends on a cgroup tree, and a suite
|
|
379
|
+
* has none. Everything else about this function is one `stat`.
|
|
380
|
+
*/
|
|
381
|
+
export function cageCgroupExists(unit, dirOf = scopeCgroupDir) {
|
|
382
|
+
const dir = dirOf(unit);
|
|
383
|
+
if (dir === null)
|
|
384
|
+
return false;
|
|
385
|
+
try {
|
|
386
|
+
return fs.existsSync(dir);
|
|
387
|
+
}
|
|
388
|
+
catch {
|
|
389
|
+
return false;
|
|
390
|
+
}
|
|
391
|
+
}
|
|
392
|
+
/**
|
|
393
|
+
* Put out the cage that is holding a name this session needs, and say so if it
|
|
394
|
+
* would not go.
|
|
395
|
+
*
|
|
396
|
+
* Loud on failure, because the fallback is the dangerous one: the start moves
|
|
397
|
+
* to `-r2` either way, and if the old tree is STILL running under the old name
|
|
398
|
+
* the agent that comes back will start the same command beside it — two runs of
|
|
399
|
+
* one build in one folder (independent review of #431). Nothing here can undo
|
|
400
|
+
* that; the machine's log is what lets a person see it happened.
|
|
401
|
+
*/
|
|
402
|
+
function defaultStopCage(unit) {
|
|
403
|
+
void stopSessionScope(unit).then((stopped) => {
|
|
404
|
+
if (!stopped) {
|
|
405
|
+
log.warn('session cage: the cage holding this name would not stop', { unit });
|
|
406
|
+
}
|
|
407
|
+
});
|
|
408
|
+
}
|
|
370
409
|
// ─── capability detection ────────────────────────────────────────────
|
|
371
410
|
/** cgroup v2 unified, from `statfs` — the number behind `stat -fc %T`. */
|
|
372
411
|
const CGROUP2_SUPER_MAGIC = 0x63677270;
|
|
@@ -874,7 +913,13 @@ export function cageSpawn(input) {
|
|
|
874
913
|
* who may hand it. One function could not do both — a test process legitimately
|
|
875
914
|
* needs the first answer and must never get the second.
|
|
876
915
|
*/
|
|
877
|
-
export function buildCagedSpawn(input
|
|
916
|
+
export function buildCagedSpawn(input,
|
|
917
|
+
/**
|
|
918
|
+
* The two things this function does to the machine besides building a command
|
|
919
|
+
* line, as seams (#431): whether a cage of that name still exists, and what to
|
|
920
|
+
* do about it. A suite has neither a cgroup tree nor a systemd to answer.
|
|
921
|
+
*/
|
|
922
|
+
io = {}) {
|
|
878
923
|
const facts = sessionCage();
|
|
879
924
|
if (facts.mode !== 'scope' ||
|
|
880
925
|
facts.memoryMaxBytes === null ||
|
|
@@ -896,9 +941,64 @@ export function buildCagedSpawn(input) {
|
|
|
896
941
|
const maxBytes = live?.maxBytes ?? facts.memoryMaxBytes;
|
|
897
942
|
const swapMaxBytes = live?.swapBytes ?? facts.swapMaxBytes;
|
|
898
943
|
const guaranteedBytes = live?.guaranteedBytes ?? null;
|
|
899
|
-
|
|
944
|
+
let attempt = (attempts.get(input.id) ?? 0) + 1;
|
|
945
|
+
let unit = sessionScopeUnit(input.id, attempt);
|
|
946
|
+
/**
|
|
947
|
+
* The last line of defence: a name that is STILL taken (#431).
|
|
948
|
+
*
|
|
949
|
+
* The counter above is memory, and memory is exactly what a restarted daemon
|
|
950
|
+
* does not have: after `systemctl restart` it starts at one again and asks
|
|
951
|
+
* for the plain `devbridge-session-<id>.scope` — the name the previous life
|
|
952
|
+
* of this session may still be holding with a build nobody killed. systemd
|
|
953
|
+
* answers «was already loaded or has a fragment file» and exits 1, and the
|
|
954
|
+
* session dies before it starts (the whole of #431).
|
|
955
|
+
*
|
|
956
|
+
* Asked of the cgroup tree rather than of systemd on purpose: this runs on
|
|
957
|
+
* the start of every session, and the bus has been measured at 2.7 s under
|
|
958
|
+
* load. A directory that exists means processes are in it, which is the case
|
|
959
|
+
* that matters; a unit left `failed` and EMPTY has no directory, and the
|
|
960
|
+
* sweep at daemon start clears those by name.
|
|
961
|
+
*
|
|
962
|
+
* And the tree is not left to run under a different name. It cannot hand its
|
|
963
|
+
* output to anybody — the pipes died with the process that started it — while
|
|
964
|
+
* the agent coming back will start the same command again: two `docker
|
|
965
|
+
* compose` runs of one project in one folder is a worse outcome than the
|
|
966
|
+
* failure this guard exists to prevent.
|
|
967
|
+
*/
|
|
968
|
+
const exists = io.cageExists ?? cageCgroupExists;
|
|
969
|
+
const liveness = io.cageLiveness ?? scopeOwnerLiveness;
|
|
970
|
+
const stop = io.stopCage ?? defaultStopCage;
|
|
971
|
+
for (let tries = 0; tries < CAGE_NAME_MAX_TRIES && exists(unit); tries += 1) {
|
|
972
|
+
/**
|
|
973
|
+
* Asked of the kernel, not of this process's memory (independent review of
|
|
974
|
+
* #431).
|
|
975
|
+
*
|
|
976
|
+
* The first version compared the name against `liveUnits` and stopped
|
|
977
|
+
* anything that was not in it. That register holds ONE unit per session, so
|
|
978
|
+
* in the shape it was meant to protect — two processes of one session,
|
|
979
|
+
* #401 — the live cage is the one MISSING from it, and the guard would have
|
|
980
|
+
* put out an agent somebody was using. `scopeOwnerLiveness` answers the
|
|
981
|
+
* question that actually matters, on evidence: `orphaned` and `empty` are
|
|
982
|
+
* the previous life of this session, and nothing else may be touched.
|
|
983
|
+
*/
|
|
984
|
+
const state = liveness(unit);
|
|
985
|
+
if (state === 'orphaned' || state === 'empty') {
|
|
986
|
+
log.warn('session cage: the name of this cage is still taken, stopping what is left in it', {
|
|
987
|
+
unit,
|
|
988
|
+
liveness: state,
|
|
989
|
+
});
|
|
990
|
+
stop(unit);
|
|
991
|
+
}
|
|
992
|
+
else {
|
|
993
|
+
log.warn('session cage: the name of this cage is taken by something alive, leaving it', {
|
|
994
|
+
unit,
|
|
995
|
+
liveness: state,
|
|
996
|
+
});
|
|
997
|
+
}
|
|
998
|
+
attempt += 1;
|
|
999
|
+
unit = sessionScopeUnit(input.id, attempt);
|
|
1000
|
+
}
|
|
900
1001
|
attempts.set(input.id, attempt);
|
|
901
|
-
const unit = sessionScopeUnit(input.id, attempt);
|
|
902
1002
|
liveUnits.set(input.id, unit);
|
|
903
1003
|
// A verdict nobody read belongs to the process that just ended, not to the
|
|
904
1004
|
// one starting now: without this, a kill that was never surfaced would be
|
|
@@ -1281,6 +1381,31 @@ export function stoppedProcesses(count, where = 'it') {
|
|
|
1281
1381
|
? `a process in ${where} was stopped`
|
|
1282
1382
|
: `${count} processes in ${where} were stopped`;
|
|
1283
1383
|
}
|
|
1384
|
+
/**
|
|
1385
|
+
* What the feed says when the runner takes a session's cage down with itself
|
|
1386
|
+
* (#431).
|
|
1387
|
+
*
|
|
1388
|
+
* Next to {@link stoppedProcesses} and for the same reason: two places that
|
|
1389
|
+
* count the same thing must not word it differently. This one counts COMMANDS,
|
|
1390
|
+
* because that is what a person can act on — «7 processes» is the same build
|
|
1391
|
+
* said in a way nobody can use.
|
|
1392
|
+
*
|
|
1393
|
+
* The middle sentence is the part that matters. A command that outlives the
|
|
1394
|
+
* process which started it cannot hand its output back to anybody: the pipe
|
|
1395
|
+
* died with the agent, and the agent that comes back is a different process
|
|
1396
|
+
* with a different tool call. Stopping it is not a loss, it is the refusal to
|
|
1397
|
+
* keep a zombie — but the person has to be told, or they will wait for a build
|
|
1398
|
+
* that nobody is going to report.
|
|
1399
|
+
*/
|
|
1400
|
+
export function restartStoppedCommands(count) {
|
|
1401
|
+
return count === 1
|
|
1402
|
+
? 'The runner was restarted, so the command this session had running was stopped. ' +
|
|
1403
|
+
'Nothing can hand its output back now — start it again if you still need it. ' +
|
|
1404
|
+
'The session itself is fine: its conversation was not touched.'
|
|
1405
|
+
: `The runner was restarted, so the ${count} commands this session had running were stopped. ` +
|
|
1406
|
+
'Nothing can hand their output back now — start them again if you still need them. ' +
|
|
1407
|
+
'The session itself is fine: its conversation was not touched.';
|
|
1408
|
+
}
|
|
1284
1409
|
const deaths = new Map();
|
|
1285
1410
|
/**
|
|
1286
1411
|
* Snapshot the cgroup's verdict before systemd can take it away.
|
|
@@ -1373,11 +1498,28 @@ function showValue(stdout, property) {
|
|
|
1373
1498
|
* startable in the meantime.
|
|
1374
1499
|
*/
|
|
1375
1500
|
export async function releaseSessionScope(unit, id, systemctl = realSystemctl) {
|
|
1376
|
-
|
|
1501
|
+
/**
|
|
1502
|
+
* Is the scope being released the one this session is CURRENTLY in?
|
|
1503
|
+
*
|
|
1504
|
+
* It usually is, and then everything below behaves as it always did. It is
|
|
1505
|
+
* not when the agent was relaunched while the previous process was still on
|
|
1506
|
+
* its way out — the one-shot relaunches empty the slot when the event stream
|
|
1507
|
+
* ends, and the CLI's `exit` lands a second or two later. By then the
|
|
1508
|
+
* register names the LIVE cage, and wiping it by session id threw away the
|
|
1509
|
+
* only record of it: the memory watch lost the cgroup, the sweep lost a
|
|
1510
|
+
* spare, and since #431 `shutdown()` reads exactly this to decide whose cage
|
|
1511
|
+
* to put out — «this session has no cage» would have left the live one
|
|
1512
|
+
* running, which is the defect this ticket is about. Found by the
|
|
1513
|
+
* independent review of #431.
|
|
1514
|
+
*/
|
|
1515
|
+
const current = unit !== null && id !== undefined && liveUnits.get(id) === unit;
|
|
1516
|
+
const running = releaseSessionScopeInner(unit, id, systemctl, current);
|
|
1377
1517
|
if (id !== undefined) {
|
|
1378
1518
|
// The process this named is gone with the scope; a pid outliving it would be
|
|
1379
|
-
// a pid that means something else by the time anybody reads it.
|
|
1380
|
-
|
|
1519
|
+
// a pid that means something else by the time anybody reads it. Only when
|
|
1520
|
+
// this IS that process — see `current` above.
|
|
1521
|
+
if (current)
|
|
1522
|
+
agentPids.delete(id);
|
|
1381
1523
|
releasing.set(id, running);
|
|
1382
1524
|
void running.finally(() => {
|
|
1383
1525
|
if (releasing.get(id) === running)
|
|
@@ -1416,7 +1558,9 @@ export async function memoryDeathSentence(id, capMs = 3_000) {
|
|
|
1416
1558
|
}
|
|
1417
1559
|
return explainMemoryDeath(id);
|
|
1418
1560
|
}
|
|
1419
|
-
async function releaseSessionScopeInner(unit, id, systemctl
|
|
1561
|
+
async function releaseSessionScopeInner(unit, id, systemctl,
|
|
1562
|
+
/** Is this the cage the session is in right now — see {@link releaseSessionScope}. */
|
|
1563
|
+
current) {
|
|
1420
1564
|
if (!unit)
|
|
1421
1565
|
return null;
|
|
1422
1566
|
// Synchronously and FIRST: the cgroup's own counters are the one record of an
|
|
@@ -1425,7 +1569,7 @@ async function releaseSessionScopeInner(unit, id, systemctl) {
|
|
|
1425
1569
|
// empty. This is what the adapter's error text is built from (#387).
|
|
1426
1570
|
if (id !== undefined)
|
|
1427
1571
|
rememberDeath(id, unit);
|
|
1428
|
-
if (id !== undefined)
|
|
1572
|
+
if (id !== undefined && current)
|
|
1429
1573
|
liveUnits.delete(id);
|
|
1430
1574
|
let result = null;
|
|
1431
1575
|
let tasksLeft = null;
|
|
@@ -1504,10 +1648,45 @@ async function releaseSessionScopeInner(unit, id, systemctl) {
|
|
|
1504
1648
|
// which systemd answers with «was already loaded or has a fragment file» and
|
|
1505
1649
|
// spawns nothing — the exact failure the counter exists to prevent
|
|
1506
1650
|
// (QA-2026-09-07 MINOR-11).
|
|
1507
|
-
if (id !== undefined && forgotten)
|
|
1651
|
+
if (id !== undefined && forgotten && current)
|
|
1508
1652
|
attempts.delete(id);
|
|
1509
1653
|
return result;
|
|
1510
1654
|
}
|
|
1655
|
+
/**
|
|
1656
|
+
* Stop a cage and let systemd forget its name — the other half of killing the
|
|
1657
|
+
* agent (#431, грабля §524).
|
|
1658
|
+
*
|
|
1659
|
+
* {@link releaseSessionScope} is the path for a process that ENDED: it reads
|
|
1660
|
+
* the verdict first and only stops what is left. This one is for the two
|
|
1661
|
+
* moments where there is nothing to read and no time to read it — the daemon
|
|
1662
|
+
* going down, and a start that found its own name still taken. It asks for the
|
|
1663
|
+
* stop and nothing else.
|
|
1664
|
+
*
|
|
1665
|
+
* Worth knowing on the shutdown path: once `stop` has been accepted, the job
|
|
1666
|
+
* belongs to systemd. A caller that gives up waiting still gets the cage
|
|
1667
|
+
* stopped — it simply does not get to see it happen.
|
|
1668
|
+
*/
|
|
1669
|
+
export async function stopSessionScope(unit, systemctl = realSystemctl) {
|
|
1670
|
+
if (!unit)
|
|
1671
|
+
return false;
|
|
1672
|
+
let stopped = false;
|
|
1673
|
+
try {
|
|
1674
|
+
await systemctl(['stop', unit]);
|
|
1675
|
+
stopped = true;
|
|
1676
|
+
}
|
|
1677
|
+
catch (error) {
|
|
1678
|
+
// A scope that ended by itself answers «not loaded», and a process with no
|
|
1679
|
+
// right to act on cages answers with a refusal. Neither is worth a session.
|
|
1680
|
+
log.debug('session cage: could not stop the scope', { unit, error: String(error) });
|
|
1681
|
+
}
|
|
1682
|
+
try {
|
|
1683
|
+
await systemctl(['reset-failed', unit]);
|
|
1684
|
+
}
|
|
1685
|
+
catch {
|
|
1686
|
+
// Nothing to forget: the usual answer for a scope that stopped cleanly.
|
|
1687
|
+
}
|
|
1688
|
+
return stopped;
|
|
1689
|
+
}
|
|
1511
1690
|
/** Unit names of every `devbridge-session-*.scope` systemd still knows about. */
|
|
1512
1691
|
export async function listSessionScopeUnits(systemctl = realSystemctl) {
|
|
1513
1692
|
let stdout;
|
|
@@ -1568,6 +1747,17 @@ export async function listSessionScopes(systemctl = realSystemctl) {
|
|
|
1568
1747
|
}
|
|
1569
1748
|
return out;
|
|
1570
1749
|
}
|
|
1750
|
+
/**
|
|
1751
|
+
* Did this read fail because the process is gone, or because we could not read?
|
|
1752
|
+
*
|
|
1753
|
+
* `ENOENT` is /proc's answer for a pid that ended; `ESRCH` is the same answer
|
|
1754
|
+
* from the other syscalls. Everything else — permissions, descriptors, a broken
|
|
1755
|
+
* read — is ignorance, and ignorance is never evidence of litter.
|
|
1756
|
+
*/
|
|
1757
|
+
function processIsGone(error) {
|
|
1758
|
+
const code = error?.code;
|
|
1759
|
+
return code === 'ENOENT' || code === 'ESRCH';
|
|
1760
|
+
}
|
|
1571
1761
|
export function scopeOwnerLiveness(unit, io = {}) {
|
|
1572
1762
|
const readFile = io.readFile ?? ((p) => fs.readFileSync(p, 'utf8'));
|
|
1573
1763
|
const cgroupDir = io.cgroupDir ?? scopeCgroupDir;
|
|
@@ -1597,6 +1787,19 @@ export function scopeOwnerLiveness(unit, io = {}) {
|
|
|
1597
1787
|
}
|
|
1598
1788
|
if (pids.length === 0)
|
|
1599
1789
|
return 'empty';
|
|
1790
|
+
/**
|
|
1791
|
+
* Who is INSIDE, as a set — the difference between «a live parent» and «a
|
|
1792
|
+
* live parent outside the cage» (#431).
|
|
1793
|
+
*
|
|
1794
|
+
* An agent's command is never one process: `bash -lc 'docker compose …'` is a
|
|
1795
|
+
* tree, and every process below its root has a living parent — its own
|
|
1796
|
+
* neighbour in this cgroup. Without this set the question below answered
|
|
1797
|
+
* «somebody is supervising them» for every abandoned build on the machine,
|
|
1798
|
+
* so the sweep left it alone for ever, the cage kept its name, and the next
|
|
1799
|
+
* start of that session died as «(code 1)» (measured 16.09.2026). The single
|
|
1800
|
+
* orphan of §479 answered correctly only because it had no children.
|
|
1801
|
+
*/
|
|
1802
|
+
const inside = new Set(pids);
|
|
1600
1803
|
let sawUnknown = false;
|
|
1601
1804
|
for (const pid of pids) {
|
|
1602
1805
|
let ppid;
|
|
@@ -1606,8 +1809,23 @@ export function scopeOwnerLiveness(unit, io = {}) {
|
|
|
1606
1809
|
const value = line === undefined ? Number.NaN : Number(line.slice('PPid:'.length).trim());
|
|
1607
1810
|
ppid = Number.isSafeInteger(value) ? value : null;
|
|
1608
1811
|
}
|
|
1609
|
-
catch {
|
|
1610
|
-
|
|
1812
|
+
catch (error) {
|
|
1813
|
+
/**
|
|
1814
|
+
* Two different answers wearing one coat, and the difference decides
|
|
1815
|
+
* whether a live session may be stopped (независимая проверка #431).
|
|
1816
|
+
*
|
|
1817
|
+
* `ENOENT`/`ESRCH` is the kernel saying the process ended between the
|
|
1818
|
+
* listing and the read — it says nothing either way, and the scan goes
|
|
1819
|
+
* on. Anything else (`EACCES` under `hidepid`, `EMFILE` on a busy
|
|
1820
|
+
* daemon, `EIO`) is a /proc we could not read, and «could not read» has
|
|
1821
|
+
* exactly one safe reading in this function: `unknown`, which is never
|
|
1822
|
+
* swept. Until the tree rule above, a live session survived this by
|
|
1823
|
+
* accident — its neighbours' parents were alive; now the agent's own
|
|
1824
|
+
* line is the only voice for «somebody is working here», and losing it
|
|
1825
|
+
* must not read as «litter».
|
|
1826
|
+
*/
|
|
1827
|
+
if (!processIsGone(error))
|
|
1828
|
+
sawUnknown = true;
|
|
1611
1829
|
continue;
|
|
1612
1830
|
}
|
|
1613
1831
|
if (ppid === null) {
|
|
@@ -1618,6 +1836,10 @@ export function scopeOwnerLiveness(unit, io = {}) {
|
|
|
1618
1836
|
// answer for a process that is going away.
|
|
1619
1837
|
if (ppid <= 1)
|
|
1620
1838
|
continue;
|
|
1839
|
+
// A parent in the SAME cage is a neighbour, not an owner: it is part of the
|
|
1840
|
+
// same abandoned tree, and its own parent is asked about in its own turn.
|
|
1841
|
+
if (inside.has(ppid))
|
|
1842
|
+
continue;
|
|
1621
1843
|
/**
|
|
1622
1844
|
* …and reparented to the USER MANAGER counts the same (measured 10.09.2026).
|
|
1623
1845
|
*
|
|
@@ -1632,8 +1854,12 @@ export function scopeOwnerLiveness(unit, io = {}) {
|
|
|
1632
1854
|
try {
|
|
1633
1855
|
parentComm = readFile(path.join('/proc', String(ppid), 'comm')).trim();
|
|
1634
1856
|
}
|
|
1635
|
-
catch {
|
|
1857
|
+
catch (error) {
|
|
1636
1858
|
// The parent went between the two reads: it is not supervising anything.
|
|
1859
|
+
// Anything other than «it is gone» is a /proc we could not read — see the
|
|
1860
|
+
// catch above; the same rule, for the same reason.
|
|
1861
|
+
if (!processIsGone(error))
|
|
1862
|
+
sawUnknown = true;
|
|
1637
1863
|
continue;
|
|
1638
1864
|
}
|
|
1639
1865
|
if (parentComm === 'systemd' || parentComm === 'init')
|
package/dist/session-stall.d.ts
CHANGED
|
@@ -305,6 +305,31 @@ export declare function readScopeProcesses(unit: string, readFile?: (p: string)
|
|
|
305
305
|
* over that a `sh`, and killing the leaf leaves the parent to start another.
|
|
306
306
|
*/
|
|
307
307
|
export declare function pickKillCandidate(processes: ScopeProcess[], agentPid?: number | null): KillCandidate | null;
|
|
308
|
+
/**
|
|
309
|
+
* How many COMMANDS this session has running, counted the way a person counts
|
|
310
|
+
* them (#431).
|
|
311
|
+
*
|
|
312
|
+
* Used on the way out: the runner is about to take the cage down with it, and
|
|
313
|
+
* the line it writes into the feed has to say how much work that costs. The
|
|
314
|
+
* number must therefore mean what the sentence says – «commands», not
|
|
315
|
+
* «processes»: `bash -lc 'docker compose up'` is three or four processes and
|
|
316
|
+
* exactly one command, and a person who is told «7 processes were stopped»
|
|
317
|
+
* learns nothing they can act on.
|
|
318
|
+
*
|
|
319
|
+
* The exclusions are {@link pickKillCandidate}'s, for the same reasons and with
|
|
320
|
+
* the same reading of `agentPid`:
|
|
321
|
+
*
|
|
322
|
+
* - **the agent is not a command.** It is the root of the tree, and stopping
|
|
323
|
+
* it is what the restart is.
|
|
324
|
+
* - **MCP servers and everything under them are not commands.** They are the
|
|
325
|
+
* agent's own plumbing; the session did not ask for them and nothing of the
|
|
326
|
+
* person's work is lost with them.
|
|
327
|
+
*
|
|
328
|
+
* `git` is NOT excluded here, though it is excluded there: that rule exists so
|
|
329
|
+
* the memory brake does not leave `.git/index.lock` behind, and this function
|
|
330
|
+
* stops nothing – it counts what is about to go either way.
|
|
331
|
+
*/
|
|
332
|
+
export declare function countRunningCommands(processes: ScopeProcess[], agentPid?: number | null): number;
|
|
308
333
|
/**
|
|
309
334
|
* Is this pid still the process it was when we decided to stop it?
|
|
310
335
|
*
|
package/dist/session-stall.js
CHANGED
|
@@ -668,6 +668,90 @@ export function pickKillCandidate(processes, agentPid = null) {
|
|
|
668
668
|
}
|
|
669
669
|
return best;
|
|
670
670
|
}
|
|
671
|
+
/**
|
|
672
|
+
* How many COMMANDS this session has running, counted the way a person counts
|
|
673
|
+
* them (#431).
|
|
674
|
+
*
|
|
675
|
+
* Used on the way out: the runner is about to take the cage down with it, and
|
|
676
|
+
* the line it writes into the feed has to say how much work that costs. The
|
|
677
|
+
* number must therefore mean what the sentence says – «commands», not
|
|
678
|
+
* «processes»: `bash -lc 'docker compose up'` is three or four processes and
|
|
679
|
+
* exactly one command, and a person who is told «7 processes were stopped»
|
|
680
|
+
* learns nothing they can act on.
|
|
681
|
+
*
|
|
682
|
+
* The exclusions are {@link pickKillCandidate}'s, for the same reasons and with
|
|
683
|
+
* the same reading of `agentPid`:
|
|
684
|
+
*
|
|
685
|
+
* - **the agent is not a command.** It is the root of the tree, and stopping
|
|
686
|
+
* it is what the restart is.
|
|
687
|
+
* - **MCP servers and everything under them are not commands.** They are the
|
|
688
|
+
* agent's own plumbing; the session did not ask for them and nothing of the
|
|
689
|
+
* person's work is lost with them.
|
|
690
|
+
*
|
|
691
|
+
* `git` is NOT excluded here, though it is excluded there: that rule exists so
|
|
692
|
+
* the memory brake does not leave `.git/index.lock` behind, and this function
|
|
693
|
+
* stops nothing – it counts what is about to go either way.
|
|
694
|
+
*/
|
|
695
|
+
export function countRunningCommands(processes, agentPid = null) {
|
|
696
|
+
if (processes.length === 0)
|
|
697
|
+
return 0;
|
|
698
|
+
const byPid = new Map(processes.map((p) => [p.pid, p]));
|
|
699
|
+
const children = new Map();
|
|
700
|
+
for (const p of processes) {
|
|
701
|
+
if (!byPid.has(p.ppid))
|
|
702
|
+
continue;
|
|
703
|
+
const list = children.get(p.ppid);
|
|
704
|
+
if (list)
|
|
705
|
+
list.push(p.pid);
|
|
706
|
+
else
|
|
707
|
+
children.set(p.ppid, [p.pid]);
|
|
708
|
+
}
|
|
709
|
+
const roots = processes.filter((p) => !byPid.has(p.ppid)).map((p) => p.pid);
|
|
710
|
+
/**
|
|
711
|
+
* The agent, and ONLY the agent, when its pid is known (independent review of
|
|
712
|
+
* #431).
|
|
713
|
+
*
|
|
714
|
+
* `pickKillCandidate` protects every parentless process, and there that is
|
|
715
|
+
* right: it chooses something to KILL, and a command wrongly spared costs a
|
|
716
|
+
* retry while the agent wrongly killed costs the session. Counting is the
|
|
717
|
+
* other way round. A cage has more than one parentless process whenever the
|
|
718
|
+
* agent left something behind it — `setsid`, `nohup pnpm dev &`, a double
|
|
719
|
+
* fork: the child stays in the cgroup and is reparented to the user manager,
|
|
720
|
+
* OUTSIDE the cage. Measured on this machine 16.09.2026: such a command was
|
|
721
|
+
* counted as zero, so the person was told nothing at all while it was being
|
|
722
|
+
* stopped — the exact silence this line exists to break (#427).
|
|
723
|
+
*/
|
|
724
|
+
const excluded = new Set(agentPid === null ? roots : [agentPid]);
|
|
725
|
+
const spread = (pid) => {
|
|
726
|
+
for (const child of children.get(pid) ?? []) {
|
|
727
|
+
if (excluded.has(child))
|
|
728
|
+
continue;
|
|
729
|
+
excluded.add(child);
|
|
730
|
+
spread(child);
|
|
731
|
+
}
|
|
732
|
+
};
|
|
733
|
+
for (const p of processes) {
|
|
734
|
+
if (!p.mcp)
|
|
735
|
+
continue;
|
|
736
|
+
excluded.add(p.pid);
|
|
737
|
+
// …but never from the agent: its own command line names the MCP config, so
|
|
738
|
+
// spreading from it would hide every command it ever started.
|
|
739
|
+
const isAgent = agentPid === null ? roots.includes(p.pid) : p.pid === agentPid;
|
|
740
|
+
if (!isAgent)
|
|
741
|
+
spread(p.pid);
|
|
742
|
+
}
|
|
743
|
+
let commands = 0;
|
|
744
|
+
for (const p of processes) {
|
|
745
|
+
if (excluded.has(p.pid))
|
|
746
|
+
continue;
|
|
747
|
+
// Only the top of each surviving subtree: a process whose parent is also a
|
|
748
|
+
// command is part of that command, not another one.
|
|
749
|
+
if (byPid.has(p.ppid) && !excluded.has(p.ppid))
|
|
750
|
+
continue;
|
|
751
|
+
commands += 1;
|
|
752
|
+
}
|
|
753
|
+
return commands;
|
|
754
|
+
}
|
|
671
755
|
/**
|
|
672
756
|
* Is this pid still the process it was when we decided to stop it?
|
|
673
757
|
*
|
package/dist/supervisor.d.ts
CHANGED
|
@@ -186,6 +186,19 @@ export interface SupervisorOptions {
|
|
|
186
186
|
* it never calls it twice at once. Production sweeps for real.
|
|
187
187
|
*/
|
|
188
188
|
sweepOrphanSessionScopes?: (liveIds: Iterable<string>) => Promise<string[]>;
|
|
189
|
+
/**
|
|
190
|
+
* #431: taking a session's cage down together with its agent. A test has no
|
|
191
|
+
* systemd to answer, and the daemon must not need one to be testable here.
|
|
192
|
+
*/
|
|
193
|
+
stopSessionScope?: (unit: string | null) => Promise<boolean>;
|
|
194
|
+
/** How long those stops are given. The real one is 3 s — no suite waits it out. */
|
|
195
|
+
cageStopBudgetMs?: number;
|
|
196
|
+
/**
|
|
197
|
+
* Which pid in a cage is the agent. Production reads the register the spawn
|
|
198
|
+
* filled; without the seam every test counted commands through the fallback
|
|
199
|
+
* branch, i.e. never the one the daemon uses (independent review of #431).
|
|
200
|
+
*/
|
|
201
|
+
sessionAgentPid?: (id: string) => number | null;
|
|
189
202
|
/**
|
|
190
203
|
* #398 S2: the stall mechanism's four doors to the machine.
|
|
191
204
|
*
|
|
@@ -765,6 +778,16 @@ export declare class Supervisor {
|
|
|
765
778
|
*/
|
|
766
779
|
private publishBackgroundHeartbeats;
|
|
767
780
|
private publishHostLoad;
|
|
781
|
+
/**
|
|
782
|
+
* Set by {@link shutdown}, and read wherever this supervisor could still be
|
|
783
|
+
* ASKED to do something after it has let go of everything (#431).
|
|
784
|
+
*
|
|
785
|
+
* It exists because the daemon now outlives `shutdown()` by up to three
|
|
786
|
+
* seconds — the time its cages are given to stop. Until that change every
|
|
787
|
+
* caller exited in the same tick and «after shutdown» was not a state
|
|
788
|
+
* anything could be in.
|
|
789
|
+
*/
|
|
790
|
+
private shuttingDown;
|
|
768
791
|
private onFrame;
|
|
769
792
|
private startSession;
|
|
770
793
|
/**
|
|
@@ -1076,6 +1099,26 @@ export declare class Supervisor {
|
|
|
1076
1099
|
*/
|
|
1077
1100
|
private armCompactionWatchdog;
|
|
1078
1101
|
private clearCompactionWatchdog;
|
|
1102
|
+
/**
|
|
1103
|
+
* A card that interrupted a RESTING session has been answered: go back to
|
|
1104
|
+
* rest instead of reporting a turn (#382).
|
|
1105
|
+
*
|
|
1106
|
+
* Which card it was does not matter — a Codex helper's approval, a Claude
|
|
1107
|
+
* background subagent's, an ask either of them parked while the session was
|
|
1108
|
+
* already the person's. What matters is that no turn of this session was
|
|
1109
|
+
* running when the card went out, so there is no turn to go back to and
|
|
1110
|
+
* nothing that would end one: on Codex the helper's own ending is explicitly
|
|
1111
|
+
* not the session's, so «Working» stood until somebody typed.
|
|
1112
|
+
*
|
|
1113
|
+
* Only when the burst is over — both card sets empty — because answering one
|
|
1114
|
+
* of three still leaves the session parked on the other two. The frame
|
|
1115
|
+
* carries the background count like every other status report, so the badge
|
|
1116
|
+
* and the Inbox see «resting, with helpers» rather than «your turn».
|
|
1117
|
+
*
|
|
1118
|
+
* Returns true when it handled the resolution, so the callers' «the human
|
|
1119
|
+
* answered, bill again» branches stay out of it.
|
|
1120
|
+
*/
|
|
1121
|
+
private restAfterCard;
|
|
1079
1122
|
/**
|
|
1080
1123
|
* Record how many subagents are alive, and say so when it matters (#236).
|
|
1081
1124
|
*
|
|
@@ -1581,8 +1624,49 @@ export declare class Supervisor {
|
|
|
1581
1624
|
*/
|
|
1582
1625
|
private sendEvent;
|
|
1583
1626
|
private reportStatus;
|
|
1584
|
-
/**
|
|
1585
|
-
|
|
1627
|
+
/**
|
|
1628
|
+
* How long the whole fleet of cages is given to stop before this process goes
|
|
1629
|
+
* anyway (#431).
|
|
1630
|
+
*
|
|
1631
|
+
* The bus has been measured at 2.7 s under load and the stops run in
|
|
1632
|
+
* parallel, so three seconds is room for all of them on a bad day. Going over
|
|
1633
|
+
* it costs nothing: `stop` has been ACCEPTED by then, and systemd finishes
|
|
1634
|
+
* the job whether or not anybody is still here to watch. systemd's own
|
|
1635
|
+
* patience with this service is `TimeoutStopSec`, far above this.
|
|
1636
|
+
*/
|
|
1637
|
+
private static readonly CAGE_STOP_BUDGET_MS;
|
|
1638
|
+
/**
|
|
1639
|
+
* Graceful daemon shutdown: kill agents, take their cages with them, keep
|
|
1640
|
+
* sessions resumable server-side.
|
|
1641
|
+
*
|
|
1642
|
+
* **Everything that has to survive this process happens BEFORE the first
|
|
1643
|
+
* `await`** — the withdrawn cards, the feed line, and the word to each agent
|
|
1644
|
+
* that it is over (a signal for Codex; for Claude, stdin closed and the SDK's
|
|
1645
|
+
* own `close()`, whose SIGTERM is a timer this process will not live to see —
|
|
1646
|
+
* on a caged machine what actually ends it is the stop below). A caller that
|
|
1647
|
+
* does not wait (the suite does not) gets exactly the shutdown it always got;
|
|
1648
|
+
* what it misses is only the stop of the cgroups. Written this way rather
|
|
1649
|
+
* than as two methods because «kill the agent» and «put its cage out» are one
|
|
1650
|
+
* action, and the shape of the code is what stops them drifting apart again
|
|
1651
|
+
* (грабля §479).
|
|
1652
|
+
*
|
|
1653
|
+
* What the caller owes in return: close the socket between the two halves.
|
|
1654
|
+
* The events above need it open, and the seconds below must not be a window
|
|
1655
|
+
* in which a `session_start` arrives at a supervisor that has already let go
|
|
1656
|
+
* of everything (`index.ts`).
|
|
1657
|
+
*/
|
|
1658
|
+
shutdown(): Promise<void>;
|
|
1659
|
+
/**
|
|
1660
|
+
* Put out every cage this daemon was holding, in parallel and on a clock.
|
|
1661
|
+
*
|
|
1662
|
+
* Until 0.65.0 nothing did this: `releaseSessionScope` hangs off the agent
|
|
1663
|
+
* process's `exit`, and on a restart the daemon is gone before that event
|
|
1664
|
+
* fires. What stayed behind was a whole tree — a build, its docker client,
|
|
1665
|
+
* their children — holding the name of a scope systemd will then refuse to
|
|
1666
|
+
* reuse, so the FIRST start of that session after the restart died as
|
|
1667
|
+
* «exited before the session was ready (code 1)» (#431).
|
|
1668
|
+
*/
|
|
1669
|
+
private stopCages;
|
|
1586
1670
|
}
|
|
1587
1671
|
/**
|
|
1588
1672
|
* The first message the agent gets.
|