@crouton-kit/crouter 0.3.41 → 0.3.43
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/bin/crouter +12 -1
- package/bin/crtr +12 -1
- package/bin/crtrd +12 -1
- package/dist/builtin-memory/wedged-child-on-runaway-bash.md +34 -0
- package/dist/clients/attach/attach-cmd.js +371 -371
- package/dist/clients/attach/view-socket.d.ts +8 -1
- package/dist/clients/attach/view-socket.js +15 -1
- package/dist/commands/memory/shared.d.ts +7 -3
- package/dist/commands/memory/shared.js +35 -5
- package/dist/commands/memory/write.js +5 -3
- package/dist/core/__tests__/pid-zombie.test.d.ts +1 -0
- package/dist/core/__tests__/pid-zombie.test.js +42 -0
- package/dist/core/canvas/pid.d.ts +14 -3
- package/dist/core/canvas/pid.js +41 -4
- package/dist/core/profiles/select.d.ts +4 -2
- package/dist/core/profiles/select.js +30 -4
- package/dist/core/runtime/spawn.js +18 -3
- package/package.json +1 -1
|
@@ -16,7 +16,8 @@ export type ReadOpRequest = Omit<ListModelsFrame, 'id'> | Omit<ListSessionsFrame
|
|
|
16
16
|
* broker on the same path. It gives up only when the node is genuinely gone:
|
|
17
17
|
* a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
|
|
18
18
|
* terminal — an idle-release node revives on its next inbox wake, so keep
|
|
19
|
-
* trying (the supervisor
|
|
19
|
+
* trying (the supervisor waits at a relaxed cadence for as long as the row
|
|
20
|
+
* stays live). */
|
|
20
21
|
export declare function reconnectShouldGiveUp(row: NodeRow | null): boolean;
|
|
21
22
|
export declare class BrokerUnavailableError extends Error {
|
|
22
23
|
readonly nodeId: string;
|
|
@@ -45,6 +46,12 @@ export declare class ViewSocketClient extends EventEmitter {
|
|
|
45
46
|
* Resolved by the matching `data` frame / rejected by the matching `error`
|
|
46
47
|
* frame in {@link onData}, the request's timeout, or socket teardown. */
|
|
47
48
|
private pending;
|
|
49
|
+
/** One redial fault line per reconnect EPISODE, not per attempt: the
|
|
50
|
+
* supervisor redials for as long as the node can come back (a dormant node
|
|
51
|
+
* may be gone for hours), so a per-attempt record would grow fault.log
|
|
52
|
+
* without bound. Set on the first failed redial, reset on any successful
|
|
53
|
+
* connect. */
|
|
54
|
+
private redialFaultRecorded;
|
|
48
55
|
constructor(nodeId: string);
|
|
49
56
|
private recordSocketFault;
|
|
50
57
|
/** Issue a correlated read-op and resolve with the broker's `data` reply (or
|
|
@@ -38,7 +38,8 @@ const REQUEST_TIMEOUT_MS = 10_000;
|
|
|
38
38
|
* broker on the same path. It gives up only when the node is genuinely gone:
|
|
39
39
|
* a terminal status (done/dead/canceled) or a reaped row (null). `idle` is NOT
|
|
40
40
|
* terminal — an idle-release node revives on its next inbox wake, so keep
|
|
41
|
-
* trying (the supervisor
|
|
41
|
+
* trying (the supervisor waits at a relaxed cadence for as long as the row
|
|
42
|
+
* stays live). */
|
|
42
43
|
export function reconnectShouldGiveUp(row) {
|
|
43
44
|
if (row === null)
|
|
44
45
|
return true;
|
|
@@ -61,11 +62,22 @@ export class ViewSocketClient extends EventEmitter {
|
|
|
61
62
|
* Resolved by the matching `data` frame / rejected by the matching `error`
|
|
62
63
|
* frame in {@link onData}, the request's timeout, or socket teardown. */
|
|
63
64
|
pending = new Map();
|
|
65
|
+
/** One redial fault line per reconnect EPISODE, not per attempt: the
|
|
66
|
+
* supervisor redials for as long as the node can come back (a dormant node
|
|
67
|
+
* may be gone for hours), so a per-attempt record would grow fault.log
|
|
68
|
+
* without bound. Set on the first failed redial, reset on any successful
|
|
69
|
+
* connect. */
|
|
70
|
+
redialFaultRecorded = false;
|
|
64
71
|
constructor(nodeId) {
|
|
65
72
|
super();
|
|
66
73
|
this.nodeId = nodeId;
|
|
67
74
|
}
|
|
68
75
|
recordSocketFault(op, err) {
|
|
76
|
+
if (op === 'redial') {
|
|
77
|
+
if (this.redialFaultRecorded)
|
|
78
|
+
return;
|
|
79
|
+
this.redialFaultRecorded = true;
|
|
80
|
+
}
|
|
69
81
|
const classified = classify('viewer↔broker', err);
|
|
70
82
|
recordFault(this.nodeId, {
|
|
71
83
|
link: 'viewer↔broker',
|
|
@@ -122,6 +134,7 @@ export class ViewSocketClient extends EventEmitter {
|
|
|
122
134
|
const socket = createConnection(this.socketPath);
|
|
123
135
|
this.socket = socket;
|
|
124
136
|
socket.on('connect', () => {
|
|
137
|
+
this.redialFaultRecorded = false;
|
|
125
138
|
clearFault(this.nodeId, { link: 'viewer↔broker' });
|
|
126
139
|
this.emit('connect');
|
|
127
140
|
});
|
|
@@ -148,6 +161,7 @@ export class ViewSocketClient extends EventEmitter {
|
|
|
148
161
|
if (settled)
|
|
149
162
|
return;
|
|
150
163
|
settled = true;
|
|
164
|
+
this.redialFaultRecorded = false;
|
|
151
165
|
clearFault(this.nodeId, { link: 'viewer↔broker' });
|
|
152
166
|
this.emit('connect');
|
|
153
167
|
resolve();
|
|
@@ -12,9 +12,13 @@ export declare function scopeRank(scope: MemoryScope): number;
|
|
|
12
12
|
* always resolves. `--scope profile` is NEVER a default — it requires a
|
|
13
13
|
* selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
|
|
14
14
|
* or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
|
|
15
|
-
* `loadProfileManifest` (never a raw path join).
|
|
16
|
-
*
|
|
17
|
-
|
|
15
|
+
* `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
|
|
16
|
+
* `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
|
|
17
|
+
* `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
|
|
18
|
+
* — the way a profiled agent writes into any project in its purview, and the
|
|
19
|
+
* only way to target a dir shadowed by an ancestor store. Returns the
|
|
20
|
+
* absolute `<root>/memory` dir to write under. */
|
|
21
|
+
export declare function resolveWriteTarget(scopeArg: string | undefined, profileArg?: string, dirArg?: string): {
|
|
18
22
|
scope: MemoryScope;
|
|
19
23
|
memoryDir: string;
|
|
20
24
|
};
|
|
@@ -3,10 +3,12 @@
|
|
|
3
3
|
// scope modules and build their documented output objects on top of the small
|
|
4
4
|
// helpers here. Nothing in this file forks on kind or re-implements the
|
|
5
5
|
// schema/gate/resolver — it only composes them.
|
|
6
|
-
import {
|
|
6
|
+
import { existsSync, realpathSync, statSync } from 'node:fs';
|
|
7
|
+
import { join, resolve as resolvePath } from 'node:path';
|
|
7
8
|
import { stringify as yamlStringify, parse as yamlParse } from 'yaml';
|
|
8
9
|
import { usage } from '../../core/errors.js';
|
|
9
|
-
import {
|
|
10
|
+
import { CRTR_DIR_NAME } from '../../types.js';
|
|
11
|
+
import { scopeMemoryDir, projectScopeRoot, ensureProjectScopeRoot, resetScopeCache, } from '../../core/scope.js';
|
|
10
12
|
import { loadProfileManifest, profileMemoryDir } from '../../core/profiles/manifest.js';
|
|
11
13
|
// The two memory kinds — knowledge (consult: procedural playbooks + factual
|
|
12
14
|
// references merged) vs preference (behave: standing directives). Used as the
|
|
@@ -29,9 +31,37 @@ export function scopeRank(scope) {
|
|
|
29
31
|
* always resolves. `--scope profile` is NEVER a default — it requires a
|
|
30
32
|
* selected profile, from `profileArg` (an explicit `--profile <id-or-name>`)
|
|
31
33
|
* or else the process's `CRTR_PROFILE_ID`, resolved through the centralized
|
|
32
|
-
* `loadProfileManifest` (never a raw path join).
|
|
33
|
-
*
|
|
34
|
-
|
|
34
|
+
* `loadProfileManifest` (never a raw path join). `dirArg` (an explicit
|
|
35
|
+
* `--dir <path>`) pins the EXACT project directory, cwd-free: the target is
|
|
36
|
+
* `<dir>/.crouter/memory/`, scaffolded if absent, never the cwd-ancestor walk
|
|
37
|
+
* — the way a profiled agent writes into any project in its purview, and the
|
|
38
|
+
* only way to target a dir shadowed by an ancestor store. Returns the
|
|
39
|
+
* absolute `<root>/memory` dir to write under. */
|
|
40
|
+
export function resolveWriteTarget(scopeArg, profileArg, dirArg) {
|
|
41
|
+
if (dirArg !== undefined && dirArg !== '') {
|
|
42
|
+
if (scopeArg !== undefined && scopeArg !== 'project') {
|
|
43
|
+
throw usage(`--dir targets a project store and cannot combine with --scope ${scopeArg}`);
|
|
44
|
+
}
|
|
45
|
+
const abs = resolvePath(dirArg);
|
|
46
|
+
if (!existsSync(abs) || !statSync(abs).isDirectory()) {
|
|
47
|
+
throw usage(`--dir does not exist or is not a directory: ${dirArg}`, {
|
|
48
|
+
received: dirArg,
|
|
49
|
+
next: 'Pass an existing project directory.',
|
|
50
|
+
});
|
|
51
|
+
}
|
|
52
|
+
let real = abs;
|
|
53
|
+
try {
|
|
54
|
+
real = realpathSync(abs);
|
|
55
|
+
}
|
|
56
|
+
catch {
|
|
57
|
+
/* fall back to the resolved path */
|
|
58
|
+
}
|
|
59
|
+
// The scaffold (mkdir of `.crouter/memory/`) happens at write time via the
|
|
60
|
+
// writer's ensureDir; drop the scope cache so this process's later
|
|
61
|
+
// resolves see the new root.
|
|
62
|
+
resetScopeCache();
|
|
63
|
+
return { scope: 'project', memoryDir: join(real, CRTR_DIR_NAME, 'memory') };
|
|
64
|
+
}
|
|
35
65
|
let scope;
|
|
36
66
|
if (scopeArg === 'user' || scopeArg === 'project' || scopeArg === 'profile') {
|
|
37
67
|
scope = scopeArg;
|
|
@@ -13,7 +13,7 @@ export const writeLeaf = defineLeaf({
|
|
|
13
13
|
guide: 'The body is the easy part; the craft is routing — every frontmatter flag decides who sees this doc, when, and at what context cost. Each rung up is paid by every future agent at every boot or read, forever, so default each rung down.\n\n' +
|
|
14
14
|
'Pick the kind. knowledge is consulted for facts or procedures; preference directs behavior. The kind choice is about how the doc is used, not about how long it is.\n\n' +
|
|
15
15
|
'Set both visibility rungs explicitly on create. There is no kind default. `none`, `name`, `preview`, and `content` move from least to most loaded: `none` keeps the doc out of auto-load and on-read surfaces, `name` is the bare doc tag only, `preview` is the name + envelope + routing line (`when-and-why-to-read`), and `content` inlines the whole body when the body is short enough to justify it. Each axis is independent; usually one carries a real rung and the other is `none`. When a doc is short enough to state in a single sentence — a one-line preference, or a knowledge fact that fits in a sentence — skip `preview` and use `name` or `content` directly: the routing line would run longer than the doc itself, so a `preview` rung just adds words. Sentence-length `content` docs are not just acceptable, they are correct. Never pad a memory to be more verbose than the rule or fact it carries.\n\n' +
|
|
16
|
-
'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
|
|
16
|
+
'Choose the hook — boot vs file-read. Behavior and how-to procedure surface at boot. Knowledge about code belongs next to the code: put the file in that directory’s .crouter/memory/ (`--dir <project-dir>` targets it exactly, from anywhere) and it fires positionally when files there are read, costing nothing at boot. A knowledge doc about a person or process has no code directory to anchor to, so on-read triggering is meaningless — route it through boot instead and set the boot rung to `preview` when you want the routing line to surface.\n\n' +
|
|
17
17
|
'Write the routing line (--when-and-why-to-read) first, before storing anything: "When <circumstance>, this <kind> should be read because <payoff>." The test is whether a stranger mid-task can decide from that one line alone whether to spend the read. If you cannot name the concrete situation that triggers it, you do not yet understand the memory — ask the user one sharp question instead of improvising. Never paraphrase the advice in the routing line; keep it about when to read and why the read is worth it.\n\n' +
|
|
18
18
|
'Gate and read-when share the same predicate language: a field map is AND-ed across fields; dotted fields resolve nested values; field matchers may be scalar, array, or object. Scalar matchers do exact equality, with arrays matching any element. Array matchers do membership or intersection. Object matchers accept `eq`, `ne`, `in`, `nin`, `exists`, `contains`, `containsAll`, `containsAny`, `matches`, `imatches`, `gt`, `gte`, `lt`, and `lte`. Combinators are `all`, `any`, and `not`; sibling field matchers next to them are AND-ed in. An empty condition is inert. An unknown op never matches.\n\n' +
|
|
19
19
|
'Find before write. Group related docs with path names (area/topic). Do not store what is already recorded or what only matters to this conversation. Body is for current truth, not history. Provenance is automatic on create and preserved on update. Validate after authoring.',
|
|
@@ -27,7 +27,8 @@ export const writeLeaf = defineLeaf({
|
|
|
27
27
|
{ kind: 'flag', name: 'gate', type: 'string', required: false, constraint: 'Frontmatter gate — YAML/JSON object predicate over node config using the same field/matcher vocabulary described in the guide.' },
|
|
28
28
|
{ kind: 'flag', name: 'applies-to', type: 'string', required: false, constraint: 'Frontmatter applies-to — glob/path scope the document applies to.' },
|
|
29
29
|
{ kind: 'flag', name: 'read-when', type: 'string', required: false, constraint: 'Frontmatter read-when — YAML/JSON object predicate over a read file’s own frontmatter using the same field/matcher vocabulary described in the guide.' },
|
|
30
|
-
{ kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
|
|
30
|
+
{ kind: 'flag', name: 'scope', type: 'enum', choices: [...MEMORY_SCOPES], required: false, constraint: 'Target scope. Default: project when inside a project, else user. `project` resolves to the NEAREST ancestor `.crouter/` walking up from cwd — in a nested workspace that can be a parent’s store, not the dir you are standing in; pass --dir to pin the exact project directory. `profile` requires a selected profile (CRTR_PROFILE_ID) or an explicit --profile.' },
|
|
31
|
+
{ kind: 'flag', name: 'dir', type: 'string', required: false, constraint: 'Exact project directory to write under — targets `<dir>/.crouter/memory/` regardless of cwd or ancestor stores, scaffolding `.crouter/` there if absent. THE way to place a doc in a specific project’s store (e.g. another project in your profile’s purview) without cd’ing there, and the only way to target a dir shadowed by an ancestor store. Implies --scope project; rejects --scope user/profile.' },
|
|
31
32
|
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile id or name to write under, for --scope profile. Default: the process CRTR_PROFILE_ID (the node\u2019s selected profile). Resolved through the same profile lookup as `crtr profile show`; ignored for any other --scope.' },
|
|
32
33
|
{ kind: 'stdin', name: 'body', required: true, constraint: 'Document body (markdown, no frontmatter). Piped on stdin, or passed as the bare positional after <name>.' },
|
|
33
34
|
],
|
|
@@ -50,7 +51,8 @@ export const writeLeaf = defineLeaf({
|
|
|
50
51
|
const scopeArg = input['scope'];
|
|
51
52
|
const profileArg = input['profile'];
|
|
52
53
|
const body = input['body'] ?? '';
|
|
53
|
-
const
|
|
54
|
+
const dirArg = input['dir'];
|
|
55
|
+
const { scope, memoryDir } = resolveWriteTarget(scopeArg, profileArg, dirArg);
|
|
54
56
|
const path = memoryFilePath(memoryDir, name);
|
|
55
57
|
const created = !pathExists(path);
|
|
56
58
|
// CREATE requires the read-routing line that becomes the preview — without
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/pid-zombie.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Regression cover for the zombie-broker revive bug: `kill(pid, 0)` reports a
|
|
4
|
+
// ZOMBIE process (exited, awaiting reap by its parent) as perfectly alive —
|
|
5
|
+
// its pid is still occupied in the process table. A broker's spawning process
|
|
6
|
+
// that never gets to process its SIGCHLD (the real-world trigger: `bootRoot`'s
|
|
7
|
+
// foreground attach session used to block its OWN event loop for the whole
|
|
8
|
+
// session via a synchronous `spawnSync`, so it could never reap its detached
|
|
9
|
+
// broker child in real time — see the async-spawn fix in runtime/spawn.ts)
|
|
10
|
+
// leaves that broker a zombie indefinitely. The daemon's revive guard reads
|
|
11
|
+
// liveness through `isPidAlive(pi_pid)` alone (`revive.ts`), so misreading a
|
|
12
|
+
// zombie as alive wedges the relaunch forever. `isPidAlive` must classify a
|
|
13
|
+
// genuine zombie as dead.
|
|
14
|
+
//
|
|
15
|
+
// Fabricates a REAL zombie: spawn a child with no exit listener, then starve
|
|
16
|
+
// THIS process's own event loop with a synchronous busy-wait spanning the
|
|
17
|
+
// child's exit — mirroring exactly how a blocking spawnSync parks the parent's
|
|
18
|
+
// loop past a child's death, before any assertion ever runs.
|
|
19
|
+
import { test } from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import { spawn } from 'node:child_process';
|
|
22
|
+
import { isPidAlive } from '../canvas/pid.js';
|
|
23
|
+
test('isPidAlive reads a genuine zombie process as dead, not alive', () => {
|
|
24
|
+
const child = spawn('/bin/sh', ['-c', 'exit 0'], { detached: true, stdio: 'ignore' });
|
|
25
|
+
const pid = child.pid;
|
|
26
|
+
assert.ok(pid != null, 'child must have a pid');
|
|
27
|
+
child.unref(); // no exit listener attached — nothing reaps it while the loop is starved below
|
|
28
|
+
// Busy-wait SYNCHRONOUSLY (no `await`/timer — starving the event loop, not
|
|
29
|
+
// just delaying it) long enough for the child to exit and the kernel to
|
|
30
|
+
// mark it a zombie, before this process's SIGCHLD handling ever gets a turn.
|
|
31
|
+
const deadline = Date.now() + 400;
|
|
32
|
+
while (Date.now() < deadline) { /* starve the loop */ }
|
|
33
|
+
assert.equal(isPidAlive(pid), false, 'a zombie pid must read as dead, not alive');
|
|
34
|
+
// Best-effort cleanup: signaling an already-zombied pid is a harmless no-op
|
|
35
|
+
// (it's already dead) — the zombie self-clears once this test process exits
|
|
36
|
+
// and the orphan is reparented to init, same as every other fixture in this
|
|
37
|
+
// suite; nothing here relies on that timing for the assertion above.
|
|
38
|
+
try {
|
|
39
|
+
process.kill(pid, 'SIGKILL');
|
|
40
|
+
}
|
|
41
|
+
catch { /* already gone */ }
|
|
42
|
+
});
|
|
@@ -1,6 +1,17 @@
|
|
|
1
|
-
/** True if a process with `pid` is currently alive (signal-0 probe)
|
|
2
|
-
* 0)` throws ESRCH when the process is gone; EPERM means
|
|
3
|
-
* ours — still alive
|
|
1
|
+
/** True if a process with `pid` is currently alive (signal-0 probe) AND not a
|
|
2
|
+
* zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
|
|
3
|
+
* it exists but isn't ours — still alive (a foreign process is never OUR
|
|
4
|
+
* zombie, so the `isZombie` check is skipped for that branch — see below). A
|
|
5
|
+
* null/undefined pid (legacy / never-booted) reads dead.
|
|
6
|
+
*
|
|
7
|
+
* The zombie check matters because EVERY broker this module supervises is
|
|
8
|
+
* spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
|
|
9
|
+
* dead broker whose spawning process hasn't reaped it yet is a zombie, and
|
|
10
|
+
* `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
|
|
11
|
+
* (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
|
|
12
|
+
* already-live-looking row, so a refreshed/crashed node whose broker zombied
|
|
13
|
+
* out under a long-lived spawning process (see `isZombie`'s doc) never comes
|
|
14
|
+
* back on its own. */
|
|
4
15
|
export declare function isPidAlive(pid: number | null | undefined): boolean;
|
|
5
16
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
6
17
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
package/dist/core/canvas/pid.js
CHANGED
|
@@ -9,19 +9,56 @@
|
|
|
9
9
|
// placement.ts, and crtrd.ts into one (Phase 3 review reuse MINOR-1).
|
|
10
10
|
import { spawnSync } from 'node:child_process';
|
|
11
11
|
import { readFileSync } from 'node:fs';
|
|
12
|
-
/**
|
|
13
|
-
*
|
|
14
|
-
*
|
|
12
|
+
/** Is `pid` a ZOMBIE (exited, awaiting reap by its parent)? `kill(pid, 0)`
|
|
13
|
+
* reports a zombie as a perfectly normal alive process — its PID is still
|
|
14
|
+
* occupied in the process table — so callers relying on that signal-0 probe
|
|
15
|
+
* alone (see `isPidAlive`) misread a dead broker as alive forever whenever
|
|
16
|
+
* its spawning process never reaps it (crouton-labs zombie-broker bug: the
|
|
17
|
+
* front door's foreground attach session parks its OWN event loop for the
|
|
18
|
+
* whole session — see `bootRoot` in runtime/spawn.ts — so it can't process
|
|
19
|
+
* the SIGCHLD that would otherwise reap its detached broker child in real
|
|
20
|
+
* time). `ps -o stat=` reports a leading `Z` for a zombie on BOTH BSD ps
|
|
21
|
+
* (macOS) and procps-ng (Linux), so one portable probe covers both platforms
|
|
22
|
+
* without a Linux-only `/proc` special case. Best-effort: any probe failure
|
|
23
|
+
* (spawn error, no matching row, non-zero exit) reads as NOT a zombie —
|
|
24
|
+
* fail-open, matching this module's other guards; a probe failure must never
|
|
25
|
+
* be misread as proof of death (a live pid IS alive; only a positive `Z`
|
|
26
|
+
* read says otherwise). */
|
|
27
|
+
function isZombie(pid) {
|
|
28
|
+
try {
|
|
29
|
+
const r = spawnSync('ps', ['-o', 'stat=', '-p', String(pid)], { encoding: 'utf8', timeout: 2000 });
|
|
30
|
+
if (r.status !== 0 || typeof r.stdout !== 'string')
|
|
31
|
+
return false;
|
|
32
|
+
return r.stdout.trim().charAt(0) === 'Z';
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return false;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/** True if a process with `pid` is currently alive (signal-0 probe) AND not a
|
|
39
|
+
* zombie. `kill(pid, 0)` throws ESRCH when the process is gone; EPERM means
|
|
40
|
+
* it exists but isn't ours — still alive (a foreign process is never OUR
|
|
41
|
+
* zombie, so the `isZombie` check is skipped for that branch — see below). A
|
|
42
|
+
* null/undefined pid (legacy / never-booted) reads dead.
|
|
43
|
+
*
|
|
44
|
+
* The zombie check matters because EVERY broker this module supervises is
|
|
45
|
+
* spawned `detached: true` by crtr itself (`headlessBrokerHost.launch`) — a
|
|
46
|
+
* dead broker whose spawning process hasn't reaped it yet is a zombie, and
|
|
47
|
+
* `kill(pid, 0)` alone reports it as alive, wedging the daemon's revive guard
|
|
48
|
+
* (`isPidAlive(pi_pid)`, `revive.ts`) forever: the daemon never revives an
|
|
49
|
+
* already-live-looking row, so a refreshed/crashed node whose broker zombied
|
|
50
|
+
* out under a long-lived spawning process (see `isZombie`'s doc) never comes
|
|
51
|
+
* back on its own. */
|
|
15
52
|
export function isPidAlive(pid) {
|
|
16
53
|
if (pid == null)
|
|
17
54
|
return false;
|
|
18
55
|
try {
|
|
19
56
|
process.kill(pid, 0);
|
|
20
|
-
return true;
|
|
21
57
|
}
|
|
22
58
|
catch (e) {
|
|
23
59
|
return e.code === 'EPERM';
|
|
24
60
|
}
|
|
61
|
+
return !isZombie(pid);
|
|
25
62
|
}
|
|
26
63
|
/** SIGTERM a process GROUP by pid (the negative-pid convention) — best-effort,
|
|
27
64
|
* swallowing ESRCH (already gone). Shared by triggers.ts (cancel-time reap of
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
2
2
|
*
|
|
3
|
-
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest
|
|
4
|
-
*
|
|
3
|
+
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
|
|
4
|
+
* its manifest does not already cover `cwd` and the session is interactive,
|
|
5
|
+
* offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
|
|
6
|
+
* return the id.
|
|
5
7
|
* 2. Else, among profiles whose project list COVERS `cwd`, pick the
|
|
6
8
|
* most-recently-used (nulls oldest), bump its `last_used_at`, return it.
|
|
7
9
|
* 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
import { existsSync, realpathSync } from 'node:fs';
|
|
6
6
|
import { basename, resolve as resolvePath, sep } from 'node:path';
|
|
7
7
|
import { createInterface } from 'node:readline/promises';
|
|
8
|
-
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, } from './manifest.js';
|
|
8
|
+
import { listProfiles, loadProfileManifest, updateProfileLastUsed, createProfile, addProfileProject, } from './manifest.js';
|
|
9
9
|
/** Resolve cwd to an absolute, realpath'd form so it compares against the
|
|
10
10
|
* realpath'd project dirs `createProfile`/`addProfileProject` already store
|
|
11
11
|
* (manifest.ts resolves every project dir through `realpathSync`). Falls back
|
|
@@ -66,6 +66,25 @@ function recoveryMessage(cwd) {
|
|
|
66
66
|
* artifact or a queued interaction, not a two-choice gate on the same TTY pi
|
|
67
67
|
* is about to take over — a raw `readline` question is the actual minimal
|
|
68
68
|
* fit here. */
|
|
69
|
+
/** Explicit `--profile` names a profile whose manifest does NOT cover cwd.
|
|
70
|
+
* Interactive only: offer to widen the profile's purview to include this
|
|
71
|
+
* directory (default yes), so the next node started here is covered without a
|
|
72
|
+
* separate `crtr profile add-project`. Declining leaves the manifest untouched
|
|
73
|
+
* and runs the node under the profile anyway. Same raw-readline rationale as
|
|
74
|
+
* `promptCreateOrRoot` — this gates pi's boot on the same TTY. */
|
|
75
|
+
async function promptAddDirToProfile(entry, cwd) {
|
|
76
|
+
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
77
|
+
try {
|
|
78
|
+
process.stdout.write(`\nProfile "${entry.manifest.name}" does not cover this directory:\n ${cwd}\n\n`);
|
|
79
|
+
const choice = (await rl.question('Add it to the profile? [Y/n]: ')).trim().toLowerCase();
|
|
80
|
+
if (choice === 'n' || choice === 'no')
|
|
81
|
+
return;
|
|
82
|
+
addProfileProject(entry.profileId, cwd);
|
|
83
|
+
}
|
|
84
|
+
finally {
|
|
85
|
+
rl.close();
|
|
86
|
+
}
|
|
87
|
+
}
|
|
69
88
|
async function promptCreateOrRoot(cwd) {
|
|
70
89
|
const rl = createInterface({ input: process.stdin, output: process.stdout });
|
|
71
90
|
try {
|
|
@@ -86,8 +105,10 @@ async function promptCreateOrRoot(cwd) {
|
|
|
86
105
|
}
|
|
87
106
|
/** Select the profile a node about to boot at `cwd` should run under.
|
|
88
107
|
*
|
|
89
|
-
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest
|
|
90
|
-
*
|
|
108
|
+
* 1. `explicitProfile` present → resolve id/name via `loadProfileManifest`. If
|
|
109
|
+
* its manifest does not already cover `cwd` and the session is interactive,
|
|
110
|
+
* offer to add `cwd` to its purview (default yes). Bump `last_used_at`,
|
|
111
|
+
* return the id.
|
|
91
112
|
* 2. Else, among profiles whose project list COVERS `cwd`, pick the
|
|
92
113
|
* most-recently-used (nulls oldest), bump its `last_used_at`, return it.
|
|
93
114
|
* 3. Else — nothing covers cwd. Interactive session: prompt synchronously to
|
|
@@ -95,12 +116,17 @@ async function promptCreateOrRoot(cwd) {
|
|
|
95
116
|
* interactive (no TTY): default to root (null) and print the recovery
|
|
96
117
|
* instruction to STDERR — never stdout, which the caller may be piping. */
|
|
97
118
|
export async function selectProfileForCwd(cwd, explicitProfile) {
|
|
119
|
+
const resolvedCwd = resolveCwd(cwd);
|
|
98
120
|
if (explicitProfile !== undefined && explicitProfile !== null && explicitProfile !== '') {
|
|
99
121
|
const entry = loadProfileManifest(explicitProfile);
|
|
122
|
+
// Selecting a profile from a directory it does not yet cover: offer to
|
|
123
|
+
// widen its purview (interactive only, default yes) before pi boots.
|
|
124
|
+
if (!profileCoversCwd(entry, resolvedCwd) && isInteractive()) {
|
|
125
|
+
await promptAddDirToProfile(entry, resolvedCwd);
|
|
126
|
+
}
|
|
100
127
|
updateProfileLastUsed(entry.profileId);
|
|
101
128
|
return entry.profileId;
|
|
102
129
|
}
|
|
103
|
-
const resolvedCwd = resolveCwd(cwd);
|
|
104
130
|
const covering = listProfiles().filter((p) => profileCoversCwd(p, resolvedCwd));
|
|
105
131
|
if (covering.length > 0) {
|
|
106
132
|
const winner = pickMostRecent(covering);
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// terminal background worker by default; with `root`, an
|
|
10
10
|
// INDEPENDENT resident root (no subscription back to the spawner,
|
|
11
11
|
// provenance via spawned_by) brought forefront for direct driving.
|
|
12
|
-
import {
|
|
12
|
+
import { spawn } from 'node:child_process';
|
|
13
13
|
import { readdirSync, existsSync } from 'node:fs';
|
|
14
14
|
import { isAbsolute, resolve, join } from 'node:path';
|
|
15
15
|
import { homedir } from 'node:os';
|
|
@@ -151,8 +151,23 @@ export async function bootRoot(opts) {
|
|
|
151
151
|
throw new Error(`the front-door root broker ${meta.node_id} never bound its view socket — cannot attach.`);
|
|
152
152
|
}
|
|
153
153
|
const attachEnv = { ...process.env, ...viewerSplitEnv() };
|
|
154
|
-
|
|
155
|
-
|
|
154
|
+
// Async, NOT spawnSync: a synchronous spawn would park THIS process's own
|
|
155
|
+
// event loop for the whole attach session (which legitimately runs for
|
|
156
|
+
// hours) — starving it of the SIGCHLD delivery it needs to reap its OWN
|
|
157
|
+
// detached broker child (recorded above via `recordPid`). A refresh/crash
|
|
158
|
+
// mid-session would then leave that broker a zombie for as long as the user
|
|
159
|
+
// stays attached, which `isPidAlive` misread as "alive" and blocked the
|
|
160
|
+
// daemon's relaunch (the exact bug this fixes). An async spawn keeps this
|
|
161
|
+
// process's loop ticking throughout, so the broker's already-registered
|
|
162
|
+
// `exit` listener (`headlessBrokerHost.launch`) actually gets to run and
|
|
163
|
+
// reap it in real time; `stdio: 'inherit'` still hands the child the TTY
|
|
164
|
+
// directly, so this is a behavior-neutral swap for the foreground session.
|
|
165
|
+
const attachChild = spawn('crtr', ['surface', 'attach', 'to', meta.node_id], { stdio: 'inherit', env: attachEnv });
|
|
166
|
+
const code = await new Promise((resolveCode) => {
|
|
167
|
+
attachChild.once('exit', (exitCode) => resolveCode(exitCode ?? 0));
|
|
168
|
+
attachChild.once('error', () => resolveCode(1));
|
|
169
|
+
});
|
|
170
|
+
process.exit(code);
|
|
156
171
|
}
|
|
157
172
|
/** pi's sessions root, VENDORED from pi `config.getSessionsDir()` (= `<agentDir>/
|
|
158
173
|
* sessions`). pi's package `exports` map is `.`-only, so config.js can't be
|