@crouton-kit/crouter 0.3.62 → 0.3.64
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/builtin-memory/00-runtime-base.md +3 -0
- package/dist/builtin-memory/internal/INDEX.md +1 -0
- package/dist/builtin-memory/internal/agent-shaping.md +77 -0
- package/dist/clients/attach/attach-cmd.js +137 -137
- package/dist/core/__tests__/full/broker-crash-teardown.test.js +17 -3
- package/dist/core/__tests__/helpers/harness.js +26 -8
- package/dist/core/canvas/pid.d.ts +14 -0
- package/dist/core/canvas/pid.js +26 -0
- package/dist/core/runtime/revive.js +8 -5
- package/dist/core/substrate/render.js +2 -1
- package/dist/daemon/crtrd.js +17 -5
- package/package.json +1 -1
|
@@ -72,14 +72,28 @@ test('CRASH → grace-revive RESUME (one-writer), then clean teardown on close',
|
|
|
72
72
|
// --- Items 4 + 6: kill the broker out from under the daemon (a crash). Drive a
|
|
73
73
|
// FIXED clock so the grace window is exercised deterministically. ---
|
|
74
74
|
process.kill(oldPid, 'SIGKILL');
|
|
75
|
+
// ONE-WRITER: the old engine pid is dead BEFORE any revive can launch. Assert
|
|
76
|
+
// this via the waitFor alone — a raw isPidAlive RE-CHECK immediately after
|
|
77
|
+
// would re-probe the same numeric pid a moment later, and on a heavily
|
|
78
|
+
// forking CI runner (test-concurrency=4 x full-tier broker spawns) the OS can
|
|
79
|
+
// recycle a just-freed low pid to an unrelated fresh process inside that gap,
|
|
80
|
+
// reporting "alive" for a pid that never resurrected (proven by a real
|
|
81
|
+
// Actions run: crouter-development/prove-ci-fixes-in-actions). The waitFor's
|
|
82
|
+
// successful resolution already IS the proof of deadness at this point.
|
|
75
83
|
await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
|
|
76
|
-
// ONE-WRITER: the old engine pid is dead BEFORE any revive can launch.
|
|
77
|
-
assert.equal(isPidAlive(oldPid), false, 'crashed pid dead before the daemon revives');
|
|
78
84
|
const NOW = 5_000_000;
|
|
79
85
|
await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
|
|
80
86
|
assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
|
|
81
87
|
await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
|
|
82
|
-
|
|
88
|
+
// This is the file's SECOND real broker boot (crash-revive), on an
|
|
89
|
+
// oversubscribed CI runner sharing the host with the other full-tier files'
|
|
90
|
+
// own broker spawns. Locally this boot lands in ~16s total for the whole
|
|
91
|
+
// test; real Actions runs proved the harness's normal 30s awaitBoot default
|
|
92
|
+
// insufficient, and on a genuinely noisy runner even 60s (measured: one real
|
|
93
|
+
// run's boot took ~65–75s) — crouter-development/prove-ci-fixes-in-actions.
|
|
94
|
+
// Give it a wide, still-bounded ceiling rather than tuning down the shared
|
|
95
|
+
// harness default for every other (cheaper, single-boot) full-tier test.
|
|
96
|
+
const boot2 = await h.awaitBoot(id, { minCount: boots + 1, timeoutMs: 120_000 });
|
|
83
97
|
assert.equal(boot2.resuming, true, 'grace-revive RESUMES the saved .jsonl');
|
|
84
98
|
const newPid = h.node(id).pi_pid;
|
|
85
99
|
assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
|
|
@@ -415,14 +415,32 @@ export async function createHarness(opts = {}) {
|
|
|
415
415
|
const minCount = o.minCount ?? 1;
|
|
416
416
|
const bootsPath = join(nodeDir(nodeId), 'fake-pi.boots.jsonl');
|
|
417
417
|
const errPath = join(nodeDir(nodeId), 'fake-pi.error');
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
418
|
+
let lines;
|
|
419
|
+
try {
|
|
420
|
+
lines = await waitFor(() => {
|
|
421
|
+
const ls = readLines(bootsPath);
|
|
422
|
+
return ls.length >= minCount ? ls : null;
|
|
423
|
+
}, {
|
|
424
|
+
timeoutMs: o.timeoutMs ?? 30_000,
|
|
425
|
+
label: `fake-pi boot >= ${minCount} for ${nodeId}` +
|
|
426
|
+
(existsSync(errPath) ? ` (error file: ${readFileSync(errPath, 'utf8')})` : ''),
|
|
427
|
+
});
|
|
428
|
+
}
|
|
429
|
+
catch (err) {
|
|
430
|
+
// Failure-path-only diagnostic: an intermittent, Linux-CI-only hang
|
|
431
|
+
// here (recorded-pid-reuse fooling the daemon's revive liveness gate)
|
|
432
|
+
// was invisible for three CI iterations because the timeout error
|
|
433
|
+
// alone doesn't say WHY the boot never happened. Dump the node row's
|
|
434
|
+
// recorded liveness fields plus the current boot count so a future
|
|
435
|
+
// failing run on Actions is self-explaining from the log alone.
|
|
436
|
+
closeDb();
|
|
437
|
+
const n = getNode(nodeId);
|
|
438
|
+
process.stderr.write(`[awaitBoot] timeout for ${nodeId}: pi_pid=${n?.pi_pid ?? 'null'} ` +
|
|
439
|
+
`pi_pid_identity=${n?.pi_pid_identity ?? 'null'} status=${n?.status ?? 'null'} ` +
|
|
440
|
+
`intent=${n?.intent ?? 'null'} pi_session_id=${n?.pi_session_id ?? 'null'} ` +
|
|
441
|
+
`bootCount=${bootCount(nodeId)} minCount=${minCount}\n`);
|
|
442
|
+
throw err;
|
|
443
|
+
}
|
|
426
444
|
const boot = JSON.parse(lines[lines.length - 1]);
|
|
427
445
|
if (typeof boot.pid === 'number')
|
|
428
446
|
pidsToKill.add(boot.pid);
|
|
@@ -13,6 +13,20 @@
|
|
|
13
13
|
* out under a long-lived spawning process (see `isZombie`'s doc) never comes
|
|
14
14
|
* back on its own. */
|
|
15
15
|
export declare function isPidAlive(pid: number | null | undefined): boolean;
|
|
16
|
+
/** Like `isPidAlive`, but guards a RECORDED node pid against PID REUSE using the
|
|
17
|
+
* launch-time identity baseline (`pi_pid_identity`, captured by `recordPid`).
|
|
18
|
+
* On a heavily-forking host — esp. Linux, where low pids recycle fast — a
|
|
19
|
+
* broker's recorded pid can be reused by an unrelated process after the broker
|
|
20
|
+
* dies; a bare `isPidAlive` then reports the STRANGER as "our broker alive",
|
|
21
|
+
* wedging every daemon supervise/revive decision (the daemon never revives a
|
|
22
|
+
* row that looks alive; `reviveNode`'s double-launch guard never relaunches).
|
|
23
|
+
* Returns false the instant the pid's CURRENT identity is POSITIVELY known to
|
|
24
|
+
* differ from `expectedIdentity` (the recycle case). Fail-open everywhere else
|
|
25
|
+
* — no baseline (legacy row / capture failed at launch), `ps` probe failure, or
|
|
26
|
+
* a pid present-but-unreadable — exactly matching `captureTeardownSnapshot`'s
|
|
27
|
+
* discipline: a MISMATCH is the ONLY thing that reads dead, never an absence of
|
|
28
|
+
* evidence. */
|
|
29
|
+
export declare function isRecordedPidAlive(pid: number | null | undefined, expectedIdentity: string | null | undefined): boolean;
|
|
16
30
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
17
31
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
|
18
32
|
* an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -60,6 +60,32 @@ export function isPidAlive(pid) {
|
|
|
60
60
|
}
|
|
61
61
|
return !isZombie(pid);
|
|
62
62
|
}
|
|
63
|
+
/** Like `isPidAlive`, but guards a RECORDED node pid against PID REUSE using the
|
|
64
|
+
* launch-time identity baseline (`pi_pid_identity`, captured by `recordPid`).
|
|
65
|
+
* On a heavily-forking host — esp. Linux, where low pids recycle fast — a
|
|
66
|
+
* broker's recorded pid can be reused by an unrelated process after the broker
|
|
67
|
+
* dies; a bare `isPidAlive` then reports the STRANGER as "our broker alive",
|
|
68
|
+
* wedging every daemon supervise/revive decision (the daemon never revives a
|
|
69
|
+
* row that looks alive; `reviveNode`'s double-launch guard never relaunches).
|
|
70
|
+
* Returns false the instant the pid's CURRENT identity is POSITIVELY known to
|
|
71
|
+
* differ from `expectedIdentity` (the recycle case). Fail-open everywhere else
|
|
72
|
+
* — no baseline (legacy row / capture failed at launch), `ps` probe failure, or
|
|
73
|
+
* a pid present-but-unreadable — exactly matching `captureTeardownSnapshot`'s
|
|
74
|
+
* discipline: a MISMATCH is the ONLY thing that reads dead, never an absence of
|
|
75
|
+
* evidence. */
|
|
76
|
+
export function isRecordedPidAlive(pid, expectedIdentity) {
|
|
77
|
+
if (!isPidAlive(pid))
|
|
78
|
+
return false; // gone/zombie → dead (unchanged path)
|
|
79
|
+
if (expectedIdentity == null)
|
|
80
|
+
return true; // no baseline → trust isPidAlive (legacy behavior)
|
|
81
|
+
const current = capturePidIdentities([pid]);
|
|
82
|
+
if (current == null)
|
|
83
|
+
return true; // probe itself failed → fail-open
|
|
84
|
+
const actual = current.get(pid);
|
|
85
|
+
if (actual === undefined)
|
|
86
|
+
return true; // present but no identity row (race) → fail-open
|
|
87
|
+
return actual === expectedIdentity; // different identity → REUSED → dead
|
|
88
|
+
}
|
|
63
89
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
64
90
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
|
65
91
|
* an in-flight predicate evaluation) and daemon/predicate-eval.ts (per-eval
|
|
@@ -29,7 +29,7 @@ import { fanDoctrineWake } from './close.js';
|
|
|
29
29
|
import { buildPiArgv } from './launch.js';
|
|
30
30
|
import { buildReviveKickoff, drainBearings } from './kickoff.js';
|
|
31
31
|
import { headlessBrokerHost } from './host.js';
|
|
32
|
-
import {
|
|
32
|
+
import { isRecordedPidAlive } from '../canvas/pid.js';
|
|
33
33
|
import { reconcileBootLiveness } from '../canvas/boot.js';
|
|
34
34
|
import { clearFault } from './fault.js';
|
|
35
35
|
import { clearInjectedDocs } from '../substrate/injected-store.js';
|
|
@@ -89,11 +89,14 @@ export function reviveNode(nodeId, opts) {
|
|
|
89
89
|
// actually matters, so it's safe to call on every revive regardless of
|
|
90
90
|
// entry point (daemon tick, or a direct/manual `node lifecycle revive`).
|
|
91
91
|
reconcileBootLiveness();
|
|
92
|
-
// Double-revive guard: the broker's isAlive
|
|
93
|
-
// whose broker pid is still running
|
|
94
|
-
//
|
|
92
|
+
// Double-revive guard: the broker's isAlive is identity-aware liveness on
|
|
93
|
+
// pi_pid, so a node whose broker pid is still running (and CONFIRMED to
|
|
94
|
+
// still be that same broker, not a reused pid) was already revived by
|
|
95
|
+
// another path — re-launching would put a SECOND broker on the same
|
|
96
|
+
// session file. No-op. A bare isPidAlive would misread a reused pid (heavy
|
|
97
|
+
// forking, esp. Linux) as "still alive" and skip the relaunch forever.
|
|
95
98
|
const live = getNode(nodeId) ?? meta;
|
|
96
|
-
if (
|
|
99
|
+
if (isRecordedPidAlive(live.pi_pid, live.pi_pid_identity)) {
|
|
97
100
|
return {
|
|
98
101
|
window: null,
|
|
99
102
|
session: live.tmux_session ?? null,
|
|
@@ -430,7 +430,8 @@ const MEMORY_USAGE_GUIDANCE = 'When your task matches a knowledge doc or prefere
|
|
|
430
430
|
"will make without it, so doing the relevant work without consulting it defeats the doc's " +
|
|
431
431
|
'whole purpose. ' +
|
|
432
432
|
'Before saving any memory, check for an existing doc that already covers it — update that ' +
|
|
433
|
-
'doc rather than creating a duplicate
|
|
433
|
+
'doc rather than creating a duplicate, rewriting it as if for the first time instead of ' +
|
|
434
|
+
'appending the new truth beneath the old; delete memories that turn out to be wrong. ' +
|
|
434
435
|
"Don't save what the repo already records (code structure, past fixes, git history, " +
|
|
435
436
|
'CLAUDE.md) or what only matters to this conversation; if asked to remember one of those, ' +
|
|
436
437
|
'ask what was non-obvious about it and save that instead. Docs auto-surfaced in ' +
|
package/dist/daemon/crtrd.js
CHANGED
|
@@ -64,7 +64,7 @@ import { FAULT_QUIET_MS, readFault, recordFault } from '../core/runtime/fault.js
|
|
|
64
64
|
import { fanDoctrineWake } from '../core/runtime/close.js';
|
|
65
65
|
import { logger } from '../core/log.js';
|
|
66
66
|
export { FAULT_QUIET_MS };
|
|
67
|
-
import { isPidAlive } from '../core/canvas/pid.js';
|
|
67
|
+
import { isPidAlive, isRecordedPidAlive } from '../core/canvas/pid.js';
|
|
68
68
|
import { reconcileBootLiveness } from '../core/canvas/boot.js';
|
|
69
69
|
import { listLivePanes, tearDownNode } from '../core/runtime/placement.js';
|
|
70
70
|
import { reviveNode } from '../core/runtime/revive.js';
|
|
@@ -704,7 +704,15 @@ async function handleNodeLiveness(row, now, revivedThisTick) {
|
|
|
704
704
|
// enforce §H: handleYieldStall no-ops unless this node yielded (intent=refresh),
|
|
705
705
|
// its turn is over, and the engine never exited — past the grace it kills the
|
|
706
706
|
// engine so the dead-pid refresh branch below revives it fresh.
|
|
707
|
-
|
|
707
|
+
//
|
|
708
|
+
// Liveness here is IDENTITY-aware, not a bare isPidAlive: on a heavily-
|
|
709
|
+
// forking host (esp. Linux, where low pids recycle fast) a just-dead
|
|
710
|
+
// broker's pid can be reused by an unrelated process before this tick
|
|
711
|
+
// runs, and a bare isPidAlive would report the STRANGER as "our broker
|
|
712
|
+
// alive" — clearing the grace clock forever and wedging revive (the
|
|
713
|
+
// recorded-pid-reuse hang). isRecordedPidAlive guards against that with
|
|
714
|
+
// the launch-time identity baseline (`row.pi_pid_identity`).
|
|
715
|
+
if (pid != null && isRecordedPidAlive(pid, row.pi_pid_identity)) {
|
|
708
716
|
unhealthySince.delete(id);
|
|
709
717
|
clearStrandedRelaunchState(id); // a live engine means the stranding resolved
|
|
710
718
|
handleYieldStall(row, pid, now);
|
|
@@ -1149,8 +1157,10 @@ export async function superviseTick(now = Date.now()) {
|
|
|
1149
1157
|
// The in-process inbox-watcher only owns delivery while the engine is LIVE.
|
|
1150
1158
|
// A released node's broker is DEAD (it freed itself while dormant), so it
|
|
1151
1159
|
// has no watcher and the daemon must wake it. Gate the skip on engine
|
|
1152
|
-
// liveness alone — viewer panes are irrelevant here.
|
|
1153
|
-
|
|
1160
|
+
// liveness alone — viewer panes are irrelevant here. Identity-aware: a
|
|
1161
|
+
// bare isPidAlive would misread a reused pid as "still alive" and skip
|
|
1162
|
+
// the revive forever, stranding the node on its unseen inbox entry.
|
|
1163
|
+
if (r.pi_pid != null && isRecordedPidAlive(r.pi_pid, r.pi_pid_identity))
|
|
1154
1164
|
continue;
|
|
1155
1165
|
const entries = readInboxSince(row.node_id, readCursor(row.node_id));
|
|
1156
1166
|
if (entries.length > 0) {
|
|
@@ -1214,7 +1224,9 @@ export async function superviseTick(now = Date.now()) {
|
|
|
1214
1224
|
}
|
|
1215
1225
|
if (revivedThisTick.has(target.node_id))
|
|
1216
1226
|
continue;
|
|
1217
|
-
|
|
1227
|
+
// Identity-aware: a bare isPidAlive would misread a reused pid as
|
|
1228
|
+
// "still alive" and skip the wake, stranding the scheduled trigger.
|
|
1229
|
+
if (target.pi_pid != null && isRecordedPidAlive(target.pi_pid, target.pi_pid_identity))
|
|
1218
1230
|
continue;
|
|
1219
1231
|
process.stderr.write(`[crtrd] wake ${target.node_id} (bare alarm)\n`);
|
|
1220
1232
|
reviveNode(target.node_id, { resume: false, wakeReason: wakeOriginFrom(t) });
|