@crouton-kit/crouter 0.3.63 → 0.3.65
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 -1
- package/dist/core/__tests__/helpers/harness.js +26 -8
- package/dist/core/canvas/pid.d.ts +14 -0
- package/dist/core/canvas/pid.js +71 -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
|
@@ -44,7 +44,21 @@ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
|
44
44
|
import { join } from 'node:path';
|
|
45
45
|
import { createHarness } from '../helpers/harness.js';
|
|
46
46
|
import { subscribe } from '../../canvas/canvas.js';
|
|
47
|
-
import { isPidAlive } from '../../canvas/pid.js';
|
|
47
|
+
import { isPidAlive, capturePidIdentities } from '../../canvas/pid.js';
|
|
48
|
+
// Diagnostic-only, gated behind CRTR_DEBUG_PID_LIVENESS (same env var as the
|
|
49
|
+
// isRecordedPidAlive trace in pid.ts): logs whether the SIGKILLed pid reads
|
|
50
|
+
// alive and what identity ps reports for it, right at the two moments that
|
|
51
|
+
// matter for the intermittent Linux-only crash-revive wedge — directly after
|
|
52
|
+
// confirming the kill and again just before the grace-elapsed tick — so a real
|
|
53
|
+
// run shows whether the pid was reused during the grace window. No effect on
|
|
54
|
+
// test behavior when the env var is unset.
|
|
55
|
+
function debugLogPidState(label, pid) {
|
|
56
|
+
if (!process.env.CRTR_DEBUG_PID_LIVENESS)
|
|
57
|
+
return;
|
|
58
|
+
const identities = capturePidIdentities([pid]);
|
|
59
|
+
const identity = identities === null ? 'probe-null' : identities.has(pid) ? JSON.stringify(identities.get(pid)) : 'no-entry';
|
|
60
|
+
process.stderr.write(`[broker-crash-teardown] ${label}: pid=${pid} isPidAlive=${isPidAlive(pid)} capturePidIdentities=${identity}\n`);
|
|
61
|
+
}
|
|
48
62
|
// crtrd.ts module const (not exported): the fresh-pi-boot grace window the daemon
|
|
49
63
|
// waits before grace-reviving / boot-failing a pi observed dead. Reference:
|
|
50
64
|
// crtrd.ts `REVIVE_GRACE_MS = 20_000`.
|
|
@@ -81,9 +95,11 @@ test('CRASH → grace-revive RESUME (one-writer), then clean teardown on close',
|
|
|
81
95
|
// Actions run: crouter-development/prove-ci-fixes-in-actions). The waitFor's
|
|
82
96
|
// successful resolution already IS the proof of deadness at this point.
|
|
83
97
|
await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
|
|
98
|
+
debugLogPidState('after waitFor(!isPidAlive(oldPid))', oldPid);
|
|
84
99
|
const NOW = 5_000_000;
|
|
85
100
|
await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
|
|
86
101
|
assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
|
|
102
|
+
debugLogPidState('before grace-elapsed tick', oldPid);
|
|
87
103
|
await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
|
|
88
104
|
// This is the file's SECOND real broker boot (crash-revive), on an
|
|
89
105
|
// oversubscribed CI runner sharing the host with the other full-tier files'
|
|
@@ -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,77 @@ 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
|
+
const debug = Boolean(process.env.CRTR_DEBUG_PID_LIVENESS);
|
|
78
|
+
const alive = isPidAlive(pid);
|
|
79
|
+
if (!alive) {
|
|
80
|
+
if (debug)
|
|
81
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'isPidAlive=false → dead', false);
|
|
82
|
+
return false; // gone/zombie → dead (unchanged path)
|
|
83
|
+
}
|
|
84
|
+
if (expectedIdentity == null) {
|
|
85
|
+
if (debug)
|
|
86
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-probe', 'no baseline → trust isPidAlive', true);
|
|
87
|
+
return true; // no baseline → trust isPidAlive (legacy behavior)
|
|
88
|
+
}
|
|
89
|
+
const current = capturePidIdentities([pid]);
|
|
90
|
+
if (current == null) {
|
|
91
|
+
if (debug)
|
|
92
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'probe-null', 'ps probe failed → fail-open alive', true);
|
|
93
|
+
return true; // probe itself failed → fail-open
|
|
94
|
+
}
|
|
95
|
+
const actual = current.get(pid);
|
|
96
|
+
if (actual === undefined) {
|
|
97
|
+
if (debug)
|
|
98
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'no-entry', 'no identity row for pid (race) → fail-open alive', true);
|
|
99
|
+
return true; // present but no identity row (race) → fail-open
|
|
100
|
+
}
|
|
101
|
+
const result = actual === expectedIdentity; // different identity → REUSED → dead
|
|
102
|
+
if (debug)
|
|
103
|
+
logPidLivenessDecision(pid, alive, expectedIdentity, 'value', result ? 'identity matches expected → alive' : 'identity differs from expected → REUSED → dead', result, actual);
|
|
104
|
+
return result;
|
|
105
|
+
}
|
|
106
|
+
/** Diagnostic-only decision trace for `isRecordedPidAlive`, gated entirely
|
|
107
|
+
* behind `CRTR_DEBUG_PID_LIVENESS` — callers must check the env var
|
|
108
|
+
* themselves before calling this so the (tiny) formatting cost is paid ONLY
|
|
109
|
+
* when the env var is set; this function does no env check of its own.
|
|
110
|
+
* Written to trace the intermittent Linux-only CI crash-revive wedge
|
|
111
|
+
* (`broker-crash-teardown.test.ts`'s 'CRASH → grace-revive RESUME'): the
|
|
112
|
+
* goal is for a single stderr line per call to make BOTH a passing run ("pid
|
|
113
|
+
* read dead → revive") and a wedged run ("read alive via <branch>") fully
|
|
114
|
+
* self-explaining, without changing any return value. `capturePidIdentities`
|
|
115
|
+
* outcome is reported as one of three DISTINCT cases — `probe-null` (the
|
|
116
|
+
* `ps` probe itself failed), `no-entry` (probe succeeded but the map has no
|
|
117
|
+
* row for this pid), or `value` (probe succeeded and found an identity,
|
|
118
|
+
* printed verbatim) — plus `no-probe` for the two branches that return
|
|
119
|
+
* before `capturePidIdentities` is ever called. Kept as permanent
|
|
120
|
+
* observability for this primitive, not a throwaway debug print: pid-reuse
|
|
121
|
+
* liveness is subtle enough that the next person chasing a wedge here should
|
|
122
|
+
* find this already in place. */
|
|
123
|
+
function logPidLivenessDecision(pid, isPidAliveResult, expectedIdentity, probeOutcome, branch, returned, actualIdentity) {
|
|
124
|
+
const parts = [
|
|
125
|
+
`pid=${String(pid)}`,
|
|
126
|
+
`isPidAlive=${isPidAliveResult}`,
|
|
127
|
+
`expectedIdentity=${expectedIdentity === undefined ? 'undefined' : expectedIdentity === null ? 'null' : JSON.stringify(expectedIdentity)}`,
|
|
128
|
+
`capturePidIdentities=${probeOutcome}${probeOutcome === 'value' ? `(${JSON.stringify(actualIdentity)})` : ''}`,
|
|
129
|
+
`branch=${JSON.stringify(branch)}`,
|
|
130
|
+
`returned=${returned}`,
|
|
131
|
+
];
|
|
132
|
+
process.stderr.write(`[isRecordedPidAlive] ${parts.join(' ')}\n`);
|
|
133
|
+
}
|
|
63
134
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
64
135
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
|
65
136
|
* 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) });
|