@crouton-kit/crouter 0.3.16 → 0.3.18
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-personas/design/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/developer/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/explore/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/general/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/code-smells/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/plan/reviewers/security/{base.md → PERSONA.md} +1 -1
- package/dist/builtin-personas/review/{base.md → PERSONA.md} +4 -0
- package/dist/builtin-personas/spec/{base.md → PERSONA.md} +5 -1
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +24 -14
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +4 -4
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/daemon.js +1 -1
- package/dist/commands/human/prompts.js +3 -9
- package/dist/commands/human/shared.d.ts +26 -1
- package/dist/commands/human/shared.js +48 -10
- package/dist/commands/node.js +66 -4
- package/dist/commands/skill/author.js +2 -2
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/human-surface-target.test.d.ts +1 -0
- package/dist/core/__tests__/human-surface-target.test.js +98 -0
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/persona-subkind.test.js +18 -15
- package/dist/core/__tests__/placement-focus.test.js +53 -15
- package/dist/core/__tests__/relaunch.test.js +12 -12
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/paths.d.ts +4 -1
- package/dist/core/canvas/paths.js +10 -4
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.js +2 -2
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/help.d.ts +6 -0
- package/dist/core/help.js +7 -0
- package/dist/core/personas/index.d.ts +4 -3
- package/dist/core/personas/index.js +3 -2
- package/dist/core/personas/loader.d.ts +34 -16
- package/dist/core/personas/loader.js +102 -29
- package/dist/core/personas/resolve.d.ts +4 -4
- package/dist/core/personas/resolve.js +16 -14
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/placement.d.ts +32 -5
- package/dist/core/runtime/placement.js +81 -14
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +23 -18
- package/dist/core/spawn.d.ts +20 -1
- package/dist/core/spawn.js +52 -5
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +106 -55
- package/dist/pi-extensions/canvas-resume.d.ts +0 -1
- package/dist/pi-extensions/canvas-resume.js +35 -126
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +16 -0
- package/dist/prompts/skill.js +6 -1
- package/package.json +1 -1
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
package/dist/commands/node.js
CHANGED
|
@@ -15,7 +15,8 @@ import { detachToBackground, focus as placementFocus, windowAlive, windowOfPane,
|
|
|
15
15
|
import { buildLaunchSpec } from '../core/runtime/launch.js';
|
|
16
16
|
import { closeNode } from '../core/runtime/close.js';
|
|
17
17
|
import { appendInbox } from '../core/feed/inbox.js';
|
|
18
|
-
import { availableKinds } from '../core/personas/index.js';
|
|
18
|
+
import { availableKinds, kindWhenToUse, subPersonasFor } from '../core/personas/index.js';
|
|
19
|
+
import { stateBlock } from '../core/help.js';
|
|
19
20
|
import { getNode, updateNode, listNodes, subscribe, unsubscribe, subscriptionsOf, subscribersOf, readContextTokens, } from '../core/canvas/index.js';
|
|
20
21
|
// Past this much context, an ORCHESTRATOR that spawns a managed child is better
|
|
21
22
|
// off yielding than holding its fat window open for the child's result: the
|
|
@@ -47,6 +48,52 @@ function assertKind(kind) {
|
|
|
47
48
|
throw new InputError({ error: 'unknown_kind', message: `unknown kind: ${kind}`, field: 'kind', next: `Valid kinds: ${kinds.join(', ')}.` });
|
|
48
49
|
}
|
|
49
50
|
}
|
|
51
|
+
/** A live `<kinds count=N>` state element — one `<kind> — <whenToUse>` line per
|
|
52
|
+
* installed top-level persona kind (project > user > builtin), lazily built so
|
|
53
|
+
* it reflects the caller's cwd/project scope. Appended to `node new -h` and
|
|
54
|
+
* `node promote -h` so custom kinds appear in help. Soft-fails via the renderer.
|
|
55
|
+
*
|
|
56
|
+
* CONTEXT-AWARE: when the CALLER is a live node (CRTR_NODE_ID set) whose kind
|
|
57
|
+
* has sub-personas available to it, a SECOND `<sub-personas for=K count=M>`
|
|
58
|
+
* block is appended right after — the specialist sub-personas THAT kind may
|
|
59
|
+
* spawn (full kind string + whenToUse). Everything but the top-level `<kinds>`
|
|
60
|
+
* block soft-fails to omission (caller block wrapped in its own try/catch). */
|
|
61
|
+
function kindsStateBlock() {
|
|
62
|
+
const kinds = availableKinds();
|
|
63
|
+
const lines = kinds
|
|
64
|
+
.map((k) => {
|
|
65
|
+
const w = kindWhenToUse(k);
|
|
66
|
+
return w ? `${k} — ${w}` : k;
|
|
67
|
+
})
|
|
68
|
+
.join('\n');
|
|
69
|
+
const block = stateBlock('kinds', { count: kinds.length }, lines);
|
|
70
|
+
const sub = callerSubPersonasBlock();
|
|
71
|
+
return sub ? `${block}\n${sub}` : block;
|
|
72
|
+
}
|
|
73
|
+
/** When the caller is a live node whose kind has sub-personas available to it,
|
|
74
|
+
* render a `<sub-personas for="<kind>" count=M>` block — one
|
|
75
|
+
* `<full-kind-string> — <whenToUse>` line per sub-persona spawnable BY that
|
|
76
|
+
* kind (e.g. `plan/reviewers/security`). Returns '' (so the second block is
|
|
77
|
+
* omitted) when CRTR_NODE_ID is unset, getNode returns null, the caller kind
|
|
78
|
+
* has no sub-personas, or anything throws — this is help output, never error. */
|
|
79
|
+
function callerSubPersonasBlock() {
|
|
80
|
+
try {
|
|
81
|
+
const id = process.env['CRTR_NODE_ID'];
|
|
82
|
+
if (id === undefined || id === '')
|
|
83
|
+
return '';
|
|
84
|
+
const node = getNode(id);
|
|
85
|
+
if (node === null)
|
|
86
|
+
return '';
|
|
87
|
+
const subs = subPersonasFor(node.kind);
|
|
88
|
+
if (subs.length === 0)
|
|
89
|
+
return '';
|
|
90
|
+
const lines = subs.map((s) => (s.whenToUse ? `${s.kind} — ${s.whenToUse}` : s.kind)).join('\n');
|
|
91
|
+
return stateBlock('sub-personas', { for: node.kind, count: subs.length }, lines);
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
return '';
|
|
95
|
+
}
|
|
96
|
+
}
|
|
50
97
|
// ---------------------------------------------------------------------------
|
|
51
98
|
// node new — spawn a terminal worker as a background window under the root
|
|
52
99
|
// ---------------------------------------------------------------------------
|
|
@@ -60,7 +107,7 @@ const nodeNew = defineLeaf({
|
|
|
60
107
|
summary: 'spawn a terminal worker onto the canvas as a background window — returns its node id',
|
|
61
108
|
params: [
|
|
62
109
|
{ kind: 'stdin', name: 'prompt', required: true, constraint: 'First user message for the spawned node. Piped on stdin or passed as a positional.' },
|
|
63
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind — match the work
|
|
110
|
+
{ kind: 'flag', name: 'kind', type: 'string', required: false, default: 'general', constraint: 'Persona kind — match the work to the kind. The <kinds> list below names every installable kind and when to use each; the <sub-personas> block (when present) names the specialist sub-personas available to YOU, each spawnable by its full kind string (e.g. plan/reviewers/security).' },
|
|
64
111
|
{ kind: 'flag', name: 'mode', type: 'enum', choices: ['base', 'orchestrator'], required: false, default: 'base', constraint: 'Persona mode. base for a worker that finishes in one window; orchestrator to create the child directly as a sub-orchestrator (it boots with the orchestrator persona + a seeded roadmap and fans its scope out) — use it when the unit is too large for one window, e.g. a big review, instead of spawning a base worker and counting on it to promote itself.' },
|
|
65
112
|
{ kind: 'flag', name: 'cwd', type: 'path', required: false, constraint: 'Dir the node is pinned to. Defaults to the caller cwd.' },
|
|
66
113
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
|
|
@@ -76,9 +123,10 @@ const nodeNew = defineLeaf({
|
|
|
76
123
|
{ name: 'status', type: 'string', required: true, constraint: 'Always "active" on spawn.' },
|
|
77
124
|
{ name: 'follow_up', type: 'string', required: true, constraint: 'Decision road sign for the caller: the child runs independently and its finish wakes you on its own, so never wait or poll on it — either pick up other work now or end your turn. If you are an orchestrator already deep in context (>100k), it instead steers you to `crtr node yield` now so your fresh revive absorbs the child\'s result. Read it, then act.' },
|
|
78
125
|
],
|
|
126
|
+
dynamicState: () => kindsStateBlock(),
|
|
79
127
|
outputKind: 'object',
|
|
80
128
|
effects: [
|
|
81
|
-
'Creates a node under ~/.
|
|
129
|
+
'Creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db.',
|
|
82
130
|
'Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only.',
|
|
83
131
|
'Opens a tmux window running pi: a background (non-focus-stealing) window in the shared crtr session for a child; with --root, a new window in your current session (in-tmux) or the shared session (outside tmux), with the client switched to it.',
|
|
84
132
|
],
|
|
@@ -191,6 +239,7 @@ const nodeFocus = defineLeaf({
|
|
|
191
239
|
params: [
|
|
192
240
|
{ kind: 'positional', name: 'node', required: true, constraint: 'Node id to focus.' },
|
|
193
241
|
{ kind: 'flag', name: 'new-pane', type: 'bool', required: false, constraint: 'Open the node in a NEW viewport SIDE-BY-SIDE with your current pane (a second focus) instead of swapping it into your pane. Two agents on screen at once (F4).' },
|
|
242
|
+
{ kind: 'flag', name: 'pane', type: 'string', required: false, constraint: 'tmux pane id to focus INTO (default: caller TMUX_PANE). Used by the canvas browser popup to focus back into the originating pane.' },
|
|
194
243
|
],
|
|
195
244
|
output: [
|
|
196
245
|
{ name: 'focused', type: 'boolean', required: true, constraint: 'True when the node was brought into view.' },
|
|
@@ -206,11 +255,23 @@ const nodeFocus = defineLeaf({
|
|
|
206
255
|
const node = getNode(id);
|
|
207
256
|
if (node === null)
|
|
208
257
|
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
258
|
+
// A kind:'human' node is a control-plane ASK (a humanloop deck on the human's
|
|
259
|
+
// screen), NOT a pi conversation — it has no session. Reviving one boots a
|
|
260
|
+
// confused blank "you have been revived" pi, so refuse rather than focus it.
|
|
261
|
+
// (The nav/resume UIs already hide human nodes; this guards a hand-typed id.)
|
|
262
|
+
if (node.kind === 'human') {
|
|
263
|
+
throw new InputError({
|
|
264
|
+
error: 'not_focusable',
|
|
265
|
+
message: `node ${id} is a human-ask (kind:human), not a conversation — it has no pi session to focus.`,
|
|
266
|
+
next: `The pending question is already on the human's screen; see it with \`crtr human list\` / \`crtr human inbox\`, or retract it with \`crtr human cancel ${id}\`.`,
|
|
267
|
+
});
|
|
268
|
+
}
|
|
209
269
|
// Placement owns the whole act (§2.3): resolve the caller's focus (or open a
|
|
210
270
|
// new viewport with --new-pane), revive the target into the backstage if it
|
|
211
271
|
// is dormant, then hot-swap it onto the focus. The reviver is injected so
|
|
212
272
|
// placement need not import revive.ts.
|
|
213
273
|
const res = placementFocus(id, {
|
|
274
|
+
pane: input['pane'],
|
|
214
275
|
newPane: input['newPane'] === true,
|
|
215
276
|
callerNode: process.env['CRTR_NODE_ID'],
|
|
216
277
|
revive: (nid) => { reviveNode(nid, { resume: true }); },
|
|
@@ -548,7 +609,7 @@ const nodePromote = defineLeaf({
|
|
|
548
609
|
name: 'node promote',
|
|
549
610
|
summary: 'promote yourself to an orchestrator — do this when your task outgrows one context window (many phases to delegate and persist across refreshes); not for work that fits one window, and not merely because you spawned a child. Mode only — lifecycle stays as-is unless you pass --resident',
|
|
550
611
|
params: [
|
|
551
|
-
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator
|
|
612
|
+
{ kind: 'flag', name: 'kind', type: 'string', required: false, constraint: 'Specialize as this kind of orchestrator. The <kinds> list below names every installable kind and when to use each; the <sub-personas> block (when present) names the specialist sub-personas available to YOU, spawnable by full kind string. Defaults to your current kind. Promoting from a generic kind? CHOOSE a concrete one — it sets the orchestrator persona you revive into.' },
|
|
552
613
|
{ kind: 'flag', name: 'resident', type: 'bool', required: false, constraint: 'ALSO flip lifecycle→resident: make the node interactable — it stays dormant, woken by inbox/human, and is never forced to submit a final. Omit to stay terminal/orchestrator (delegates + holds a roadmap, but still owes a final up the spine and reaps when done).' },
|
|
553
614
|
{ kind: 'flag', name: 'node', type: 'string', required: false, constraint: 'Node to promote. Defaults to the caller (CRTR_NODE_ID).' },
|
|
554
615
|
],
|
|
@@ -564,6 +625,7 @@ const nodePromote = defineLeaf({
|
|
|
564
625
|
{ name: 'user_memory_path', type: 'string', required: true, constraint: 'Absolute path to your USER-GLOBAL memory index (<crtrHome>/memory/MEMORY.md) — who the human is, how they like to work; loaded into every orchestrator everywhere.' },
|
|
565
626
|
{ name: 'project_memory_path', type: 'string', required: true, constraint: 'Absolute path to your PROJECT memory index (<crtrHome>/projects/<key>/memory/MEMORY.md) — facts bound to this repo; loaded into every orchestrator working here.' },
|
|
566
627
|
],
|
|
628
|
+
dynamicState: () => kindsStateBlock(),
|
|
567
629
|
outputKind: 'object',
|
|
568
630
|
effects: ['Flips mode→orchestrator + kind→chosen (lifecycle unchanged unless --resident, which also flips lifecycle→resident); rewrites the launch spec to that kind\'s orchestrator persona; seeds context/roadmap.md scaffold + all three scoped memory stores (user-global, project, node-local) if absent.', 'Your new-role guidance is injected automatically at the turn boundary by the persona injector — the command no longer returns it.'],
|
|
569
631
|
},
|
|
@@ -13,7 +13,7 @@ import { VALID_TYPES, resolveWriteScope } from './shared.js';
|
|
|
13
13
|
export const authorGuide = defineLeaf({
|
|
14
14
|
name: 'guide',
|
|
15
15
|
description: 'load authoring workflow + skeleton for a type',
|
|
16
|
-
whenToUse: '
|
|
16
|
+
whenToUse: 'REQUIRED reading before you author a new skill OR edit an existing one — it carries the SKILL.md format, the description-drives-discovery rule (when-to-use lives in the frontmatter description, never the body), the voice constraints, and the per-type workflow. Call it once with no --type for the template picker, then again with --type for that type\'s full workflow and skeleton. Editing an existing skill counts: read this first, because the format and voice rules govern every change, not just new files.',
|
|
17
17
|
help: {
|
|
18
18
|
name: 'skill author guide',
|
|
19
19
|
summary: 'load the skill authoring workflow — two stages: omit type to pick one, pass type for its full skeleton',
|
|
@@ -140,7 +140,7 @@ export const authorScaffold = defineLeaf({
|
|
|
140
140
|
export const authorBranch = defineBranch({
|
|
141
141
|
name: 'author',
|
|
142
142
|
description: 'create and scaffold skills',
|
|
143
|
-
whenToUse: 'you have a reusable workflow, methodology, or hard-won convention worth capturing so future agents adopt it instead of re-deriving it — author carries you from picking a template through scaffolding the file. Reach for it when a task just taught you a repeatable procedure, when the same guidance keeps getting re-explained across sessions, or when the house conventions for a tool deserve to be written down once.
|
|
143
|
+
whenToUse: 'you have a reusable workflow, methodology, or hard-won convention worth capturing so future agents adopt it instead of re-deriving it — author carries you from picking a template through scaffolding the file. Reach for it when a task just taught you a repeatable procedure, when the same guidance keeps getting re-explained across sessions, or when the house conventions for a tool deserve to be written down once. Always start with `crtr skill author guide` — required reading before you author OR edit any skill (the SKILL.md format, the description-vs-body rule, and the voice constraints all live there) — then use `crtr skill author scaffold` to stub the SKILL.md file.',
|
|
144
144
|
help: {
|
|
145
145
|
name: 'skill author',
|
|
146
146
|
summary: 'create and scaffold new skills',
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/cascade-close.test.ts
|
|
2
|
+
//
|
|
3
|
+
// CASCADE CLOSE + exclusive-subtree ownership — a FAITHFUL integration test of
|
|
4
|
+
// `crtr node close` on a MIDDLE node, driven through the REAL CLI against a REAL
|
|
5
|
+
// isolated tmux session with REAL fake-pi vehicles in REAL panes, asserting off
|
|
6
|
+
// the canvas data layer and checked against the state-model ORACLE
|
|
7
|
+
// (state-model.md §4 `node close`, §5 cascade-close ownership, invariant 3,
|
|
8
|
+
// ambiguity A5).
|
|
9
|
+
//
|
|
10
|
+
// WHY this exists alongside the unit `close.test.ts`: that test exercises
|
|
11
|
+
// `closeNode()` in-process against the data layer with FABRICATED pane strings
|
|
12
|
+
// (`pane:'%x'`) — it proves the closing-set fixpoint and the status flip, but it
|
|
13
|
+
// never kills a real tmux pane, never boots a real vehicle, and never closes a
|
|
14
|
+
// MIDDLE node with a live sideways-sibling subtree. This test fills exactly that
|
|
15
|
+
// gap: it builds a real tree, closes the middle, and proves the reap is EXACTLY
|
|
16
|
+
// the closed node's own subtree — nothing above (the ancestor) or sideways (a
|
|
17
|
+
// sibling subtree) is touched, and real panes actually die.
|
|
18
|
+
//
|
|
19
|
+
// The tree (covers BOTH required shapes in one topology):
|
|
20
|
+
//
|
|
21
|
+
// A (resident root, in-process)
|
|
22
|
+
// ├── B ◄── CLOSE TARGET (the middle node)
|
|
23
|
+
// │ ├── C
|
|
24
|
+
// │ │ └── E depth : A→B→C→E (deep chain)
|
|
25
|
+
// │ └── D breadth: B has two children {C, D}
|
|
26
|
+
// └── S sideways sibling subtree — MUST survive
|
|
27
|
+
// └── G
|
|
28
|
+
//
|
|
29
|
+
// close(B) ⇒ exclusive subtree {B,C,D,E} reaped; A,S,G untouched.
|
|
30
|
+
//
|
|
31
|
+
// CRITICAL DELIVERABLE (ambiguity A5): the EXACT terminal status a cascade-
|
|
32
|
+
// reaped descendant receives under `node close`. CURRENT behavior, asserted
|
|
33
|
+
// end-to-end below: status === 'canceled', intent === null (cleared) — for the
|
|
34
|
+
// cascade ROOT and EVERY descendant alike (`transition(id,'cancel')`,
|
|
35
|
+
// close.ts → lifecycle.ts cancel row: status='canceled', intent=null, from '*').
|
|
36
|
+
// This differs from the ancestor-RESET path (`reapDescendants` → `reap` → 'done')
|
|
37
|
+
// — same act, different terminal status: A5's open question. We assert the close
|
|
38
|
+
// side and report the confirmed fact; we do NOT change it.
|
|
39
|
+
//
|
|
40
|
+
// FLAG vs the task brief: the brief expected "subscription+parent edges removed"
|
|
41
|
+
// on close. CURRENT behavior + the ORACLE (§4: "Nothing is deleted: pi sessions +
|
|
42
|
+
// edges persist → revivable") + the CLI help ("their pi sessions and canvas edges
|
|
43
|
+
// persist for a later revive") all say edges PERSIST. This test asserts the
|
|
44
|
+
// CURRENT (persist) behavior and the report flags the contradiction; production
|
|
45
|
+
// is NOT changed.
|
|
46
|
+
import { test } from 'node:test';
|
|
47
|
+
import assert from 'node:assert/strict';
|
|
48
|
+
import { spawnSync } from 'node:child_process';
|
|
49
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
50
|
+
function sessionExists(session) {
|
|
51
|
+
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
52
|
+
}
|
|
53
|
+
test('cascade close: middle-node close reaps EXACTLY its subtree (canceled), ancestor + sideways untouched, edges persist', { skip: !hasTmux() ? 'tmux unavailable' : false, timeout: 180_000 }, async () => {
|
|
54
|
+
const h = await createHarness({ sessionPrefix: 'crtr-cascade' });
|
|
55
|
+
try {
|
|
56
|
+
// ===================================================================
|
|
57
|
+
// BUILD — the real tree. A is the in-process resident root (no pane);
|
|
58
|
+
// B,C,D,E,S,G are real managed children, each a real fake-pi in a real
|
|
59
|
+
// backstage pane (spawnChild awaits each boot). Every `node new` seeds the
|
|
60
|
+
// spine edge parent→child (active) + the spawned_by/parent provenance.
|
|
61
|
+
// ===================================================================
|
|
62
|
+
const A = h.spawnRoot('cascade-close root');
|
|
63
|
+
const B = await h.spawnChild(A, 'middle node — the close target', { kind: 'developer' });
|
|
64
|
+
const C = await h.spawnChild(B, 'B child one');
|
|
65
|
+
const E = await h.spawnChild(C, 'deep leaf under C'); // depth: A→B→C→E
|
|
66
|
+
const D = await h.spawnChild(B, 'B child two'); // breadth: B has {C, D}
|
|
67
|
+
const S = await h.spawnChild(A, 'sideways sibling of B'); // sideways subtree
|
|
68
|
+
const G = await h.spawnChild(S, 'child of the sideways sibling');
|
|
69
|
+
const subtree = [B, C, D, E]; // B's exclusive subtree (the expected reap set)
|
|
70
|
+
const sideways = [S, G]; // a sibling subtree — must be wholly untouched
|
|
71
|
+
// Pre-close sanity: every node live + present, spine wired as drawn.
|
|
72
|
+
{
|
|
73
|
+
for (const id of [B, C, D, E, S, G]) {
|
|
74
|
+
assert.equal(h.status(id), 'active', `${id} active before close`);
|
|
75
|
+
assert.equal(h.paneAlive(id), true, `${id} has a live pane before close`);
|
|
76
|
+
}
|
|
77
|
+
assert.equal(h.status(A), 'active', 'A (root) active before close');
|
|
78
|
+
// Spine: A→{B,S}, B→{C,D}, C→{E}, S→{G} (all active spawn-seed edges).
|
|
79
|
+
const subIds = (n) => h.subscriptions(n).map((s) => s.node_id).sort();
|
|
80
|
+
assert.deepEqual(subIds(A), [B, S].sort(), 'A subscribes_to B and S');
|
|
81
|
+
assert.deepEqual(subIds(B), [C, D].sort(), 'B subscribes_to C and D');
|
|
82
|
+
assert.deepEqual(subIds(C), [E], 'C subscribes_to E');
|
|
83
|
+
assert.deepEqual(subIds(S), [G], 'S subscribes_to G');
|
|
84
|
+
// provenance edges (spawned_by / parent)
|
|
85
|
+
for (const [child, parent] of [[B, A], [C, B], [E, C], [D, B], [S, A], [G, S]]) {
|
|
86
|
+
assert.equal(h.node(child).parent, parent, `${child}.parent = ${parent}`);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
// Make A's inbox NON-EMPTY before the close so "ancestor inbox intact" is a
|
|
90
|
+
// non-vacuous assertion: B pushes a routine update up its spine → A (B's
|
|
91
|
+
// sole active subscriber). A is the in-process root with no live watcher,
|
|
92
|
+
// so the pointer simply accumulates and must survive the close untouched.
|
|
93
|
+
{
|
|
94
|
+
const res = h.cli(B, ['push', 'update', 'progress from B before close']);
|
|
95
|
+
assert.equal(res.code, 0, `B push update exit 0\n${res.stderr}`);
|
|
96
|
+
}
|
|
97
|
+
const aInboxBefore = h.inbox(A);
|
|
98
|
+
assert.ok(aInboxBefore.some((e) => e.from === B && e.kind === 'update'), "A's inbox holds B's pre-close update (non-vacuous 'intact' baseline)");
|
|
99
|
+
// ===================================================================
|
|
100
|
+
// CLOSE the MIDDLE node B, through the REAL CLI (`crtr node close --node B`,
|
|
101
|
+
// run AS the root A). closeNode is synchronous in the subprocess: by the
|
|
102
|
+
// time it returns, rows are flipped and panes are killed.
|
|
103
|
+
// ===================================================================
|
|
104
|
+
const closeRes = h.cli(A, ['node', 'close', '--node', B]);
|
|
105
|
+
assert.equal(closeRes.code, 0, `node close exit 0\n${closeRes.stderr}`);
|
|
106
|
+
// The CLI's own rendered report: cascade root B, exactly 4 closed, 0 spared.
|
|
107
|
+
assert.match(closeRes.stdout, new RegExp(`<closed id="${B}" count="4" spared="0"\\s*/>`), `close report names B as root, count=4, spared=0\n${closeRes.stdout}`);
|
|
108
|
+
// ===================================================================
|
|
109
|
+
// ASSERT 1 — the A5 DELIVERABLE: the EXACT terminal status of every
|
|
110
|
+
// cascade-reaped node. CURRENT behavior: status='canceled', intent=null,
|
|
111
|
+
// for the ROOT (B) and EVERY descendant {C,D,E} identically.
|
|
112
|
+
// ===================================================================
|
|
113
|
+
for (const id of subtree) {
|
|
114
|
+
const n = h.node(id);
|
|
115
|
+
assert.equal(n.status, 'canceled', `${id} terminal status === 'canceled' (cancel event)`);
|
|
116
|
+
assert.strictEqual(n.intent ?? null, null, `${id} intent cleared to null on cancel`);
|
|
117
|
+
}
|
|
118
|
+
// Pin the exact field tuple once more, explicitly, for the human decision:
|
|
119
|
+
// a cascade-reaped descendant under `node close` reads (canceled, null) —
|
|
120
|
+
// NOT (done, done) and NOT (done, null). This is the confirmed A5 fact.
|
|
121
|
+
{
|
|
122
|
+
const e = h.node(E); // the deepest descendant
|
|
123
|
+
assert.deepEqual({ status: e.status, intent: e.intent ?? null }, { status: 'canceled', intent: null }, "deepest reaped descendant E: (status,intent) === ('canceled', null)");
|
|
124
|
+
}
|
|
125
|
+
// ===================================================================
|
|
126
|
+
// ASSERT 2 — real panes destroyed for the ENTIRE subtree (invariant 12-ish:
|
|
127
|
+
// a torn-down node owns no pane). tearDownNode killed each real tmux pane.
|
|
128
|
+
// ===================================================================
|
|
129
|
+
for (const id of subtree) {
|
|
130
|
+
await h.waitForPaneGone(id);
|
|
131
|
+
assert.equal(h.paneAlive(id), false, `${id} real pane destroyed by close`);
|
|
132
|
+
}
|
|
133
|
+
// ===================================================================
|
|
134
|
+
// ASSERT 3 — EXCLUSIVE-SUBTREE OWNERSHIP: nothing ABOVE (A) and nothing
|
|
135
|
+
// SIDEWAYS (S, G) was reaped. close(B) reaped EXACTLY B's own subtree.
|
|
136
|
+
// ===================================================================
|
|
137
|
+
// Ancestor A — fully untouched: still the live resident root, inbox intact.
|
|
138
|
+
{
|
|
139
|
+
const a = h.node(A);
|
|
140
|
+
assert.equal(a.status, 'active', 'A (ancestor) still active — untouched by the cascade');
|
|
141
|
+
assert.equal(a.lifecycle, 'resident', 'A still resident');
|
|
142
|
+
assert.equal(a.intent ?? null, null, 'A intent untouched');
|
|
143
|
+
assert.deepEqual(h.inbox(A), aInboxBefore, "A's inbox is byte-for-byte intact across the close (cascade touches only closed nodes' inboxes)");
|
|
144
|
+
}
|
|
145
|
+
// Sideways subtree S→G — wholly alive: status active AND real panes alive.
|
|
146
|
+
for (const id of sideways) {
|
|
147
|
+
assert.equal(h.status(id), 'active', `${id} (sideways) still active — not reaped`);
|
|
148
|
+
assert.equal(h.paneAlive(id), true, `${id} (sideways) real pane still alive`);
|
|
149
|
+
}
|
|
150
|
+
// The strong "EXACTLY its own subtree" closure: every node in the graph is
|
|
151
|
+
// either in B's subtree (canceled) or untouched (active) — no over-reach.
|
|
152
|
+
{
|
|
153
|
+
const reaped = new Set(subtree);
|
|
154
|
+
for (const id of [A, B, C, D, E, S, G]) {
|
|
155
|
+
const want = reaped.has(id) ? 'canceled' : 'active';
|
|
156
|
+
assert.equal(h.status(id), want, `${id} status === '${want}' (no over-/under-reach)`);
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
// ===================================================================
|
|
160
|
+
// ASSERT 4 — EDGES PERSIST (CURRENT behavior + ORACLE §4). close is a pause,
|
|
161
|
+
// not a delete: a closed node keeps its spine + provenance edges so it can
|
|
162
|
+
// be revived. (FLAGGED in the report: the task brief expected "edges
|
|
163
|
+
// removed" — that contradicts current behavior; we assert persistence.)
|
|
164
|
+
// ===================================================================
|
|
165
|
+
{
|
|
166
|
+
const subIds = (n) => h.subscriptions(n).map((s) => s.node_id).sort();
|
|
167
|
+
// Ancestor's spine edge to the closed middle node survives.
|
|
168
|
+
assert.ok(h.subscriptions(A).some((s) => s.node_id === B && s.active), 'A→B subscription edge PERSISTS after close (revivable)');
|
|
169
|
+
// Intra-subtree spine edges survive too.
|
|
170
|
+
assert.deepEqual(subIds(B), [C, D].sort(), 'B→{C,D} edges persist');
|
|
171
|
+
assert.deepEqual(subIds(C), [E], 'C→E edge persists');
|
|
172
|
+
// Provenance (spawned_by / parent) survives.
|
|
173
|
+
for (const [child, parent] of [[B, A], [C, B], [E, C], [D, B]]) {
|
|
174
|
+
assert.equal(h.node(child).parent, parent, `${child}.parent = ${parent} persists`);
|
|
175
|
+
}
|
|
176
|
+
// Sideways edges obviously untouched.
|
|
177
|
+
assert.deepEqual(subIds(S), [G], 'S→G edge untouched');
|
|
178
|
+
}
|
|
179
|
+
// ===================================================================
|
|
180
|
+
// ASSERT 5 — each closed node got its cancellation notice (the resume
|
|
181
|
+
// breadcrumb), appended AFTER its watcher died (close.ts step 3). The root
|
|
182
|
+
// B reads "CLOSED by the user"; descendants read "CANCELED — an ancestor…".
|
|
183
|
+
// ===================================================================
|
|
184
|
+
{
|
|
185
|
+
const bNotice = h.inbox(B).at(-1);
|
|
186
|
+
assert.equal(bNotice.from, null, "B's cancel notice is a system message");
|
|
187
|
+
assert.match(bNotice.label, /CLOSED by the user/, 'B (cascade root) reads the CLOSED notice');
|
|
188
|
+
assert.equal(bNotice.data?.['cascade_root'], B, "B notice records the cascade root");
|
|
189
|
+
const eNotice = h.inbox(E).at(-1);
|
|
190
|
+
assert.match(eNotice.label, /CANCELED/, 'E (descendant) reads the CANCELED notice');
|
|
191
|
+
assert.equal(eNotice.data?.['cascade_root'], B, "E notice records B as the cascade root");
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
finally {
|
|
195
|
+
const session = h.session;
|
|
196
|
+
await h.dispose();
|
|
197
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
198
|
+
}
|
|
199
|
+
});
|
|
@@ -7,6 +7,7 @@ import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
|
7
7
|
import { closeDb } from '../canvas/db.js';
|
|
8
8
|
import { readInboxSince } from '../feed/inbox.js';
|
|
9
9
|
import { superviseTick } from '../../daemon/crtrd.js';
|
|
10
|
+
import { markBusy, clearBusy } from '../runtime/busy.js';
|
|
10
11
|
let home;
|
|
11
12
|
function node(id, over = {}) {
|
|
12
13
|
return {
|
|
@@ -77,9 +78,15 @@ test('a crash after boot is marked dead without a boot-failure push', async () =
|
|
|
77
78
|
intent: null,
|
|
78
79
|
}));
|
|
79
80
|
spawnEdge('P2', 'C2');
|
|
81
|
+
// MID-GENERATION when the window vanished (busy marker present, agent_end
|
|
82
|
+
// never cleared it) → a genuine mid-run crash. Without the marker a booted,
|
|
83
|
+
// unsubscribed pane-gone node would FINALIZE to 'done' instead (it would read
|
|
84
|
+
// as a finished node dismissed) — see the gone-pane routing in crtrd.ts.
|
|
85
|
+
markBusy('C2');
|
|
80
86
|
await superviseTick();
|
|
81
87
|
assert.equal(getNode('C2').status, 'dead', 'crashed child is dead');
|
|
82
88
|
assert.equal(readInboxSince('P2').length, 0, 'a real crash does not push a boot-failure pointer');
|
|
89
|
+
clearBusy('C2');
|
|
83
90
|
});
|
|
84
91
|
// A still-booting node whose window is alive must be left untouched — boot is
|
|
85
92
|
// slow, and an alive window means pi may still be coming up.
|
|
@@ -4,9 +4,10 @@ import { mkdtempSync, rmSync } from 'node:fs';
|
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
6
|
import { spawnSync } from 'node:child_process';
|
|
7
|
-
import { createNode, getNode } from '../canvas/canvas.js';
|
|
7
|
+
import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
8
8
|
import { closeDb } from '../canvas/db.js';
|
|
9
9
|
import { appendInbox } from '../feed/inbox.js';
|
|
10
|
+
import { markBusy, clearBusy } from '../runtime/busy.js';
|
|
10
11
|
import { superviseTick, isPidAlive, livenessVerdict, } from '../../daemon/crtrd.js';
|
|
11
12
|
let home;
|
|
12
13
|
function node(id, over = {}) {
|
|
@@ -149,8 +150,9 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
|
|
|
149
150
|
spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
|
|
150
151
|
// The window is alive, but the node is anchored on that dead pane. Under the
|
|
151
152
|
// old window-keyed liveness this node would read healthy (window alive + live
|
|
152
|
-
// pid). Pane-keyed: the pane is gone → the
|
|
153
|
-
//
|
|
153
|
+
// pid). Pane-keyed: the pane is gone → the gone-branch fires. The node is
|
|
154
|
+
// MID-GENERATION (busy marker present — agent_start touched it, agent_end
|
|
155
|
+
// never cleared it), so the gone-branch routes to crash → 'dead'.
|
|
154
156
|
createNode(node('G', {
|
|
155
157
|
pane: dead,
|
|
156
158
|
tmux_session: session,
|
|
@@ -159,8 +161,61 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
|
|
|
159
161
|
pi_session_id: 'booted',
|
|
160
162
|
intent: null,
|
|
161
163
|
}));
|
|
164
|
+
markBusy('G'); // pane killed inside a turn → genuine mid-run crash
|
|
162
165
|
await superviseTick();
|
|
163
|
-
assert.equal(getNode('G').status, 'dead', 'a gone pane fires the gone-branch
|
|
166
|
+
assert.equal(getNode('G').status, 'dead', 'a gone pane mid-generation fires the gone-branch → crash (dead)');
|
|
167
|
+
clearBusy('G');
|
|
168
|
+
});
|
|
169
|
+
});
|
|
170
|
+
// ---------------------------------------------------------------------------
|
|
171
|
+
// Gone-pane routing: a pane-gone node in the crash branch is no longer an
|
|
172
|
+
// unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
|
|
173
|
+
// • mid-generation (busy marker PRESENT) → crash ('dead')
|
|
174
|
+
// • finished its turn, awaiting nothing live → finalize ('done')
|
|
175
|
+
// • finished its turn, still awaiting a LIVE child → crash ('dead'), for now
|
|
176
|
+
// (The mid-generation → dead leg is covered by node 'G' above.)
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
|
|
179
|
+
await withLivePane('fin1', async (session, window) => {
|
|
180
|
+
const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
|
|
181
|
+
const dead = (sp.stdout ?? '').trim();
|
|
182
|
+
spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
|
|
183
|
+
// Booted (pi_session_id set), no busy marker (agent_end cleared it → the turn
|
|
184
|
+
// finished), and no subscription to any live node. Closing the pane was a
|
|
185
|
+
// dismissal of a node that already did its own work → finalize to 'done'.
|
|
186
|
+
createNode(node('FIN', {
|
|
187
|
+
pane: dead,
|
|
188
|
+
tmux_session: session,
|
|
189
|
+
window,
|
|
190
|
+
pi_pid: process.pid,
|
|
191
|
+
pi_session_id: 'booted',
|
|
192
|
+
intent: null,
|
|
193
|
+
}));
|
|
194
|
+
// no markBusy: the turn ended cleanly.
|
|
195
|
+
await superviseTick();
|
|
196
|
+
assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
|
|
197
|
+
});
|
|
198
|
+
});
|
|
199
|
+
test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead), for now', { skip: !hasTmux() }, async () => {
|
|
200
|
+
await withLivePane('fin2', async (session, window) => {
|
|
201
|
+
const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
|
|
202
|
+
const dead = (sp.stdout ?? '').trim();
|
|
203
|
+
spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
|
|
204
|
+
// A live child the parent is subscribed to: hasActiveLiveSubscription === true.
|
|
205
|
+
createNode(node('CHILD', { status: 'active', pi_session_id: 'booted' }));
|
|
206
|
+
createNode(node('PARENT', {
|
|
207
|
+
pane: dead,
|
|
208
|
+
tmux_session: session,
|
|
209
|
+
window,
|
|
210
|
+
pi_pid: process.pid,
|
|
211
|
+
pi_session_id: 'booted',
|
|
212
|
+
intent: null,
|
|
213
|
+
}));
|
|
214
|
+
subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
|
|
215
|
+
// Parent finished its turn (no busy marker) but is still awaiting a live
|
|
216
|
+
// child → NOT a finalize. Routes to crash ('dead') for now.
|
|
217
|
+
await superviseTick();
|
|
218
|
+
assert.equal(getNode('PARENT').status, 'dead', 'a finished node still awaiting a live child crashes (dead), not finalizes');
|
|
164
219
|
});
|
|
165
220
|
});
|
|
166
221
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/dead-pane-regression.test.ts
|
|
2
|
+
//
|
|
3
|
+
// PLACEMENT-FAMILY DEAD-PANE REGRESSION GUARD.
|
|
4
|
+
//
|
|
5
|
+
// The bug (fixed this session, committed in HEAD `src/core/spawn.ts`): the
|
|
6
|
+
// `human` command tree opens its humanloop TUI in a tmux pane via
|
|
7
|
+
// `spawnAndDetach` (a `split-window` into the CURRENT window). The canvas
|
|
8
|
+
// runtime arms `remain-on-exit on` at the WINDOW scope on a node's vehicle /
|
|
9
|
+
// focus window (the F3 freeze, `runtime/tmux.ts` setRemainOnExit). A
|
|
10
|
+
// `split-window` pane opened into such a window INHERITS the `on`, so when the
|
|
11
|
+
// TUI worker (`crtr human _run`) exits 0 (clean), tmux keeps the pane around as
|
|
12
|
+
// a dead "[Exited]" pane instead of destroying it — a lingering dead pane.
|
|
13
|
+
//
|
|
14
|
+
// The fix: after the split, `spawnAndDetach` forces `remain-on-exit off` at PANE
|
|
15
|
+
// scope on the new pane (`set-option -p -t <pane> remain-on-exit off`). That
|
|
16
|
+
// destroys the pane on a clean exit WITHOUT touching the window's value (so a
|
|
17
|
+
// focus freeze still works) or the user's global config.
|
|
18
|
+
//
|
|
19
|
+
// This file drives the REAL production `spawnAndDetach` against a REAL but
|
|
20
|
+
// isolated tmux session — no mocks. It is ADDITIVE: it adds no harness/fixture
|
|
21
|
+
// capability and edits no production file. The canvas harness
|
|
22
|
+
// (`helpers/harness.ts`) cannot reach this path — it drives canvas nodes via
|
|
23
|
+
// `openNodeWindow` (new-window backstage), never the `spawnAndDetach`
|
|
24
|
+
// split-window/human-pane path — so the tmux drivers below are LOCAL to this
|
|
25
|
+
// file (candidates for harness consolidation: a `splitDetachPane` + pane-scoped
|
|
26
|
+
// `paneAlive`/`paneIsDead`/`waitForPaneGone` keyed on a raw %pane id).
|
|
27
|
+
import { test } from 'node:test';
|
|
28
|
+
import assert from 'node:assert/strict';
|
|
29
|
+
import { spawnSync } from 'node:child_process';
|
|
30
|
+
import { hasTmux } from './helpers/harness.js';
|
|
31
|
+
import { spawnAndDetach } from '../spawn.js';
|
|
32
|
+
const SKIP = !hasTmux();
|
|
33
|
+
// --- LOCAL tmux drivers (keyed on a raw %pane id, not a canvas node) --------
|
|
34
|
+
function tmux(args) {
|
|
35
|
+
const r = spawnSync('tmux', args, { encoding: 'utf8' });
|
|
36
|
+
return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
37
|
+
}
|
|
38
|
+
/** Every pane in the session (across windows) with its dead flag. */
|
|
39
|
+
function sessionPanes(session) {
|
|
40
|
+
const r = tmux(['list-panes', '-s', '-t', session, '-F', '#{pane_id} #{pane_dead}']);
|
|
41
|
+
if (r.code !== 0)
|
|
42
|
+
return [];
|
|
43
|
+
return r.out
|
|
44
|
+
.split('\n')
|
|
45
|
+
.filter((l) => l.trim() !== '')
|
|
46
|
+
.map((l) => {
|
|
47
|
+
const [id, dead] = l.split(' ');
|
|
48
|
+
return { id: id, dead: dead === '1' };
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
function paneAlive(session, pane) {
|
|
52
|
+
return sessionPanes(session).some((p) => p.id === pane && !p.dead);
|
|
53
|
+
}
|
|
54
|
+
function paneExists(session, pane) {
|
|
55
|
+
return sessionPanes(session).some((p) => p.id === pane);
|
|
56
|
+
}
|
|
57
|
+
function paneIsDead(session, pane) {
|
|
58
|
+
return sessionPanes(session).find((p) => p.id === pane)?.dead ?? false;
|
|
59
|
+
}
|
|
60
|
+
async function waitUntil(probe, label, timeoutMs = 15_000, intervalMs = 100) {
|
|
61
|
+
const deadline = Date.now() + timeoutMs;
|
|
62
|
+
for (;;) {
|
|
63
|
+
if (probe())
|
|
64
|
+
return;
|
|
65
|
+
if (Date.now() > deadline)
|
|
66
|
+
throw new Error(`waitUntil timed out: ${label}`);
|
|
67
|
+
await new Promise((r) => setTimeout(r, intervalMs));
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
test('dead-pane regression: spawnAndDetach into a remain-on-exit-ON window is DESTROYED on clean exit', { skip: SKIP ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
|
|
71
|
+
const session = `crtr-deadpane-${process.pid}-${Date.now().toString(36)}`;
|
|
72
|
+
const origTmux = process.env['TMUX'];
|
|
73
|
+
let tmuxOverridden = false;
|
|
74
|
+
try {
|
|
75
|
+
// --- isolated session on whatever server the current env targets -------
|
|
76
|
+
const created = tmux(['new-session', '-d', '-s', session, '-c', process.cwd(), 'sleep 100000']);
|
|
77
|
+
assert.equal(created.code, 0, `create isolated session failed: ${created.err}`);
|
|
78
|
+
const info = tmux([
|
|
79
|
+
'display-message', '-p', '-t', `${session}:`,
|
|
80
|
+
'#{pane_id}\t#{window_id}\t#{socket_path}',
|
|
81
|
+
]);
|
|
82
|
+
assert.equal(info.code, 0, `display-message failed: ${info.err}`);
|
|
83
|
+
const [basePane, baseWindow, socketPath] = info.out.split('\t');
|
|
84
|
+
assert.ok(basePane && baseWindow && socketPath, `parsed session info: ${info.out}`);
|
|
85
|
+
// --- arm the BUG PRECONDITION: window default remain-on-exit ON --------
|
|
86
|
+
// (mirrors the canvas runtime's F3 freeze on a node's vehicle/focus window).
|
|
87
|
+
const armed = tmux(['set-window-option', '-t', baseWindow, 'remain-on-exit', 'on']);
|
|
88
|
+
assert.equal(armed.code, 0, `arm window remain-on-exit on failed: ${armed.err}`);
|
|
89
|
+
assert.equal(tmux(['show-window-options', '-t', baseWindow, 'remain-on-exit']).out, 'remain-on-exit on', 'precondition: the window into which we split has remain-on-exit ON');
|
|
90
|
+
// spawnAndDetach gates on isInTmux() (Boolean(process.env.TMUX)) and shells
|
|
91
|
+
// `tmux` with no -L, so point TMUX at THIS session's socket — both our
|
|
92
|
+
// helpers and spawnAndDetach then drive the same server holding `session`.
|
|
93
|
+
process.env['TMUX'] = `${socketPath},0,0`;
|
|
94
|
+
tmuxOverridden = true;
|
|
95
|
+
// ====================================================================
|
|
96
|
+
// MAIN — the REAL production spawnAndDetach split into the ON window.
|
|
97
|
+
// ====================================================================
|
|
98
|
+
const res = spawnAndDetach({
|
|
99
|
+
command: 'sleep 1', // a clean-exiting (status 0) worker — stands in for `crtr human _run`
|
|
100
|
+
cwd: process.cwd(),
|
|
101
|
+
placement: 'split-h',
|
|
102
|
+
killAfterSeconds: 0, // no self-kill of the originating pane
|
|
103
|
+
targetPane: basePane, // pin the split to our isolated window (never the user's pane)
|
|
104
|
+
});
|
|
105
|
+
assert.equal(res.status, 'spawned', `spawnAndDetach should spawn: ${res.message}`);
|
|
106
|
+
const pane = res.paneId;
|
|
107
|
+
assert.ok(pane && pane.startsWith('%'), `spawnAndDetach returned a %pane id: ${pane}`);
|
|
108
|
+
// (a) the pane landed alive in the remain-on-exit-ON window.
|
|
109
|
+
assert.equal(paneAlive(session, pane), true, 'spawned pane is alive right after the split');
|
|
110
|
+
// (b) THE FIX: the spawned pane carries a PANE-SCOPED remain-on-exit OFF
|
|
111
|
+
// override — the exact line under regression
|
|
112
|
+
// (`set-option -p -t <pane> remain-on-exit off`). If a refactor drops
|
|
113
|
+
// it, this fails loudly.
|
|
114
|
+
assert.equal(tmux(['show-options', '-p', '-t', pane, 'remain-on-exit']).out, 'remain-on-exit off', 'FIX present: pane-scoped remain-on-exit OFF on the spawnAndDetach pane');
|
|
115
|
+
// (c) the override is pane-scoped only — the WINDOW value is untouched, so
|
|
116
|
+
// a real focus-freeze on this window still works.
|
|
117
|
+
assert.equal(tmux(['show-window-options', '-t', baseWindow, 'remain-on-exit']).out, 'remain-on-exit on', 'window remain-on-exit untouched (override is pane-scoped, focus freeze intact)');
|
|
118
|
+
// (d) THE GUARANTEE: on the worker's CLEAN exit the pane is DESTROYED,
|
|
119
|
+
// never lingering as a dead [Exited] pane.
|
|
120
|
+
await waitUntil(() => !paneExists(session, pane), 'spawnAndDetach pane destroyed on clean exit');
|
|
121
|
+
assert.equal(paneExists(session, pane), false, 'spawnAndDetach pane DESTROYED on clean exit (not lingering)');
|
|
122
|
+
// ====================================================================
|
|
123
|
+
// CONTROL — prove the guard is NON-VACUOUS: the SAME split WITHOUT the
|
|
124
|
+
// pane-scoped override (i.e. pre-fix behavior) lingers as a dead pane.
|
|
125
|
+
// ====================================================================
|
|
126
|
+
const ctl = tmux([
|
|
127
|
+
'split-window', '-h', '-d', '-P', '-F', '#{pane_id}',
|
|
128
|
+
'-t', basePane, '-c', process.cwd(), 'sleep 1',
|
|
129
|
+
]);
|
|
130
|
+
assert.equal(ctl.code, 0, `control split failed: ${ctl.err}`);
|
|
131
|
+
const ctlPane = ctl.out;
|
|
132
|
+
assert.ok(ctlPane.startsWith('%'), `control pane id: ${ctlPane}`);
|
|
133
|
+
// No pane-scoped override → inherits the window's remain-on-exit ON →
|
|
134
|
+
// after its clean exit the pane is kept as DEAD rather than destroyed.
|
|
135
|
+
await waitUntil(() => paneIsDead(session, ctlPane), 'control pane reaches dead state after clean exit');
|
|
136
|
+
assert.equal(paneExists(session, ctlPane), true, 'CONTROL: un-fixed pane LINGERS (still listed) after clean exit');
|
|
137
|
+
assert.equal(paneIsDead(session, ctlPane), true, 'CONTROL: the lingering pane is DEAD — confirms remain-on-exit ON causes the exact bug the fix prevents');
|
|
138
|
+
}
|
|
139
|
+
finally {
|
|
140
|
+
// Tear down on the SAME server the session lives on (TMUX still points there),
|
|
141
|
+
// then restore the original TMUX env.
|
|
142
|
+
tmux(['kill-session', '-t', session]);
|
|
143
|
+
if (tmuxOverridden) {
|
|
144
|
+
if (origTmux === undefined)
|
|
145
|
+
delete process.env['TMUX'];
|
|
146
|
+
else
|
|
147
|
+
process.env['TMUX'] = origTmux;
|
|
148
|
+
}
|
|
149
|
+
assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
|
|
150
|
+
}
|
|
151
|
+
});
|