@crouton-kit/crouter 0.3.14 → 0.3.15
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/build-root.d.ts +8 -0
- package/dist/build-root.js +30 -0
- package/dist/builtin-personas/design/base.md +3 -7
- package/dist/builtin-personas/design/orchestrator.md +4 -3
- package/dist/builtin-personas/developer/base.md +3 -7
- package/dist/builtin-personas/developer/orchestrator.md +5 -4
- package/dist/builtin-personas/explore/base.md +3 -7
- package/dist/builtin-personas/explore/orchestrator.md +1 -5
- package/dist/builtin-personas/general/base.md +2 -4
- package/dist/builtin-personas/general/orchestrator.md +2 -4
- package/dist/builtin-personas/lifecycle/resident.md +2 -0
- package/dist/builtin-personas/lifecycle/terminal.md +6 -0
- package/dist/builtin-personas/orchestration-kernel.md +42 -3
- package/dist/builtin-personas/plan/base.md +3 -5
- package/dist/builtin-personas/plan/orchestrator.md +5 -4
- package/dist/builtin-personas/plan/reviewers/architecture-fit/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/base.md +9 -0
- package/dist/builtin-personas/plan/reviewers/security/base.md +9 -0
- package/dist/builtin-personas/review/base.md +3 -5
- package/dist/builtin-personas/review/orchestrator.md +2 -6
- package/dist/builtin-personas/runtime-base.md +3 -19
- package/dist/builtin-personas/spec/base.md +3 -5
- package/dist/builtin-personas/spec/orchestrator.md +4 -3
- package/dist/builtin-personas/spine/has-manager.md +10 -0
- package/dist/builtin-personas/spine/no-manager.md +2 -0
- package/dist/builtin-skills/skills/crouter-development/personas/SKILL.md +96 -0
- package/dist/builtin-skills/skills/crouter-development/personas/base-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +49 -0
- package/dist/builtin-skills/skills/planning/SKILL.md +1 -1
- package/dist/builtin-skills/skills/spec/SKILL.md +2 -2
- package/dist/cli.js +6 -29
- package/dist/commands/attention.js +76 -7
- package/dist/commands/canvas-prune.d.ts +2 -0
- package/dist/commands/canvas-prune.js +66 -0
- package/dist/commands/canvas.js +5 -8
- package/dist/commands/chord.d.ts +2 -0
- package/dist/commands/chord.js +143 -0
- package/dist/commands/daemon.js +8 -5
- package/dist/commands/dashboard.js +2 -0
- package/dist/commands/human/prompts.js +28 -27
- package/dist/commands/human/queue.js +30 -14
- package/dist/commands/human/shared.d.ts +26 -21
- package/dist/commands/human/shared.js +44 -66
- package/dist/commands/human.js +4 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +221 -98
- package/dist/commands/pkg/market-inspect.js +6 -4
- package/dist/commands/pkg/market-manage.js +10 -6
- package/dist/commands/pkg/market.js +2 -4
- package/dist/commands/pkg/plugin-inspect.js +6 -4
- package/dist/commands/pkg/plugin-manage.js +12 -7
- package/dist/commands/pkg/plugin.js +2 -4
- package/dist/commands/pkg.js +0 -4
- package/dist/commands/push.js +178 -15
- package/dist/commands/revive.js +5 -3
- package/dist/commands/skill/author.js +6 -4
- package/dist/commands/skill/find.js +8 -5
- package/dist/commands/skill/read.js +2 -0
- package/dist/commands/skill/state.js +6 -4
- package/dist/commands/skill.js +0 -6
- package/dist/commands/sys/config.js +21 -7
- package/dist/commands/sys/doctor.js +2 -0
- package/dist/commands/sys/update.js +4 -0
- package/dist/commands/sys.js +0 -6
- package/dist/commands/tmux-spread.d.ts +2 -0
- package/dist/commands/tmux-spread.js +130 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +25 -0
- package/dist/core/__tests__/child-followup.test.d.ts +1 -0
- package/dist/core/__tests__/child-followup.test.js +83 -0
- package/dist/core/__tests__/close.test.d.ts +1 -0
- package/dist/core/__tests__/close.test.js +148 -0
- package/dist/core/__tests__/context-intro.test.d.ts +1 -0
- package/dist/core/__tests__/context-intro.test.js +196 -0
- package/dist/core/__tests__/daemon-boot.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-boot.test.js +93 -0
- package/dist/core/__tests__/daemon-liveness.test.d.ts +1 -0
- package/dist/core/__tests__/daemon-liveness.test.js +223 -0
- package/dist/core/__tests__/focuses.test.d.ts +1 -0
- package/dist/core/__tests__/focuses.test.js +259 -0
- package/dist/core/__tests__/fork.test.d.ts +1 -0
- package/dist/core/__tests__/fork.test.js +91 -0
- package/dist/core/__tests__/home-session.test.d.ts +1 -0
- package/dist/core/__tests__/home-session.test.js +153 -0
- package/dist/core/__tests__/human-cancel-guard.test.d.ts +1 -0
- package/dist/core/__tests__/human-cancel-guard.test.js +49 -0
- package/dist/core/__tests__/keystone.test.d.ts +1 -0
- package/dist/core/__tests__/keystone.test.js +185 -0
- package/dist/core/__tests__/kickoff.test.d.ts +1 -0
- package/dist/core/__tests__/kickoff.test.js +89 -0
- package/dist/core/__tests__/lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/lifecycle.test.js +178 -0
- package/dist/core/__tests__/listing-completeness.test.d.ts +1 -0
- package/dist/core/__tests__/listing-completeness.test.js +31 -0
- package/dist/core/__tests__/memory.test.d.ts +1 -0
- package/dist/core/__tests__/memory.test.js +152 -0
- package/dist/core/__tests__/migration.test.d.ts +1 -0
- package/dist/core/__tests__/migration.test.js +238 -0
- package/dist/core/__tests__/pane-column.test.d.ts +1 -0
- package/dist/core/__tests__/pane-column.test.js +153 -0
- package/dist/core/__tests__/passive-subscription.test.js +24 -1
- package/dist/core/__tests__/persona-compose.test.d.ts +1 -0
- package/dist/core/__tests__/persona-compose.test.js +53 -0
- package/dist/core/__tests__/persona-subkind.test.d.ts +1 -0
- package/dist/core/__tests__/persona-subkind.test.js +62 -0
- package/dist/core/__tests__/persona.test.d.ts +1 -0
- package/dist/core/__tests__/persona.test.js +107 -0
- package/dist/core/__tests__/placement-focus.test.d.ts +1 -0
- package/dist/core/__tests__/placement-focus.test.js +244 -0
- package/dist/core/__tests__/placement-reconcile.test.d.ts +1 -0
- package/dist/core/__tests__/placement-reconcile.test.js +212 -0
- package/dist/core/__tests__/placement-revive.test.d.ts +1 -0
- package/dist/core/__tests__/placement-revive.test.js +238 -0
- package/dist/core/__tests__/placement-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/placement-teardown.test.js +183 -0
- package/dist/core/__tests__/prune.test.d.ts +1 -0
- package/dist/core/__tests__/prune.test.js +116 -0
- package/dist/core/__tests__/push-final-guard.test.d.ts +1 -0
- package/dist/core/__tests__/push-final-guard.test.js +71 -0
- package/dist/core/__tests__/relaunch.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch.test.js +328 -0
- package/dist/core/__tests__/reset.test.js +26 -7
- package/dist/core/__tests__/revive.test.d.ts +1 -0
- package/dist/core/__tests__/revive.test.js +217 -0
- package/dist/core/__tests__/spawn-root.test.d.ts +1 -0
- package/dist/core/__tests__/spawn-root.test.js +73 -0
- package/dist/core/__tests__/steer-note.test.d.ts +1 -0
- package/dist/core/__tests__/steer-note.test.js +39 -0
- package/dist/core/__tests__/stop-guard.test.d.ts +1 -0
- package/dist/core/__tests__/stop-guard.test.js +82 -0
- package/dist/core/__tests__/subcommand-tier.test.js +35 -33
- package/dist/core/__tests__/tmux-surface.test.d.ts +1 -0
- package/dist/core/__tests__/tmux-surface.test.js +106 -0
- package/dist/core/__tests__/unknown-path.test.js +8 -2
- package/dist/core/canvas/attention.d.ts +10 -0
- package/dist/core/canvas/attention.js +40 -0
- package/dist/core/canvas/canvas.d.ts +66 -7
- package/dist/core/canvas/canvas.js +209 -21
- package/dist/core/canvas/db.d.ts +8 -0
- package/dist/core/canvas/db.js +206 -4
- package/dist/core/canvas/focuses.d.ts +22 -0
- package/dist/core/canvas/focuses.js +80 -0
- package/dist/core/canvas/index.d.ts +3 -0
- package/dist/core/canvas/index.js +3 -0
- package/dist/core/canvas/labels.d.ts +27 -0
- package/dist/core/canvas/labels.js +36 -0
- package/dist/core/canvas/render.js +25 -10
- package/dist/core/canvas/telemetry.d.ts +14 -0
- package/dist/core/canvas/telemetry.js +35 -0
- package/dist/core/canvas/types.d.ts +115 -12
- package/dist/core/command.d.ts +25 -1
- package/dist/core/command.js +23 -15
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +3 -3
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.js +24 -11
- package/dist/core/help.d.ts +26 -13
- package/dist/core/help.js +44 -37
- package/dist/core/personas/index.d.ts +1 -1
- package/dist/core/personas/index.js +1 -1
- package/dist/core/personas/loader.d.ts +40 -1
- package/dist/core/personas/loader.js +63 -1
- package/dist/core/personas/resolve.d.ts +13 -6
- package/dist/core/personas/resolve.js +46 -34
- package/dist/core/runtime/bearings.d.ts +20 -0
- package/dist/core/runtime/bearings.js +92 -0
- package/dist/core/runtime/close.d.ts +14 -0
- package/dist/core/runtime/close.js +151 -0
- package/dist/core/runtime/demote.js +27 -10
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +23 -6
- package/dist/core/runtime/kickoff.js +92 -36
- package/dist/core/runtime/launch.d.ts +24 -12
- package/dist/core/runtime/launch.js +75 -19
- package/dist/core/runtime/lifecycle.d.ts +13 -0
- package/dist/core/runtime/lifecycle.js +86 -0
- package/dist/core/runtime/memory.d.ts +43 -0
- package/dist/core/runtime/memory.js +165 -0
- package/dist/core/runtime/naming.d.ts +22 -0
- package/dist/core/runtime/naming.js +166 -0
- package/dist/core/runtime/nodes.d.ts +32 -1
- package/dist/core/runtime/nodes.js +60 -10
- package/dist/core/runtime/persona.d.ts +25 -0
- package/dist/core/runtime/persona.js +139 -0
- package/dist/core/runtime/placement.d.ts +287 -0
- package/dist/core/runtime/placement.js +663 -0
- package/dist/core/runtime/presence.d.ts +7 -15
- package/dist/core/runtime/presence.js +90 -66
- package/dist/core/runtime/promote.d.ts +14 -7
- package/dist/core/runtime/promote.js +57 -67
- package/dist/core/runtime/reset.d.ts +47 -4
- package/dist/core/runtime/reset.js +223 -52
- package/dist/core/runtime/revive.d.ts +26 -2
- package/dist/core/runtime/revive.js +166 -39
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +163 -43
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +18 -8
- package/dist/core/runtime/tmux.d.ts +100 -14
- package/dist/core/runtime/tmux.js +201 -28
- package/dist/core/spawn.js +15 -0
- package/dist/daemon/crtrd.d.ts +12 -1
- package/dist/daemon/crtrd.js +152 -34
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +266 -0
- package/dist/pi-extensions/canvas-commands.js +16 -13
- package/dist/pi-extensions/canvas-context-intro.d.ts +70 -0
- package/dist/pi-extensions/canvas-context-intro.js +164 -0
- package/dist/pi-extensions/canvas-goal-capture.d.ts +3 -0
- package/dist/pi-extensions/canvas-goal-capture.js +15 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +11 -0
- package/dist/pi-extensions/canvas-nav.d.ts +12 -4
- package/dist/pi-extensions/canvas-nav.js +586 -262
- package/dist/pi-extensions/canvas-stophook.d.ts +16 -0
- package/dist/pi-extensions/canvas-stophook.js +344 -228
- package/dist/types.d.ts +28 -0
- package/dist/types.js +16 -0
- package/package.json +1 -1
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
// Tests for the <crtr-context> bearings preamble + scoped memory:
|
|
2
|
+
// 1. A plain spawned node gets NO MEMORY.md; promotion seeds it (the template).
|
|
3
|
+
// 2. seedMemory is idempotent (never clobbers an evolved memory).
|
|
4
|
+
// 3. Worker bearings = shared-doc framing only (no <memory> block);
|
|
5
|
+
// orchestrator bearings add the across-cycles framing + a <memory> block
|
|
6
|
+
// that MERGES the applicable stores, each a `label · dir` header over its
|
|
7
|
+
// live index POINTER LINES only (the how-to boilerplate is dropped — it
|
|
8
|
+
// lives once in the kernel); promotion delivers that same orchestrator
|
|
9
|
+
// context-dir framing to a node that spawned base.
|
|
10
|
+
// 4. canvas-context-intro injects the block as its own session message at
|
|
11
|
+
// session_start (before the first prompt), idempotent across resumes.
|
|
12
|
+
//
|
|
13
|
+
// Run: node --import tsx/esm --test src/core/__tests__/context-intro.test.ts
|
|
14
|
+
import { test, before, beforeEach, after } from 'node:test';
|
|
15
|
+
import assert from 'node:assert/strict';
|
|
16
|
+
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
|
+
import { tmpdir } from 'node:os';
|
|
18
|
+
import { join } from 'node:path';
|
|
19
|
+
import { existsSync } from 'node:fs';
|
|
20
|
+
import { closeDb } from '../canvas/db.js';
|
|
21
|
+
import { contextDir } from '../canvas/paths.js';
|
|
22
|
+
import { spawnNode } from '../runtime/nodes.js';
|
|
23
|
+
import { promote } from '../runtime/promote.js';
|
|
24
|
+
import { personaDrift } from '../runtime/persona.js';
|
|
25
|
+
import { memoryPath, memoryDir, readMemory, seedMemory, hasMemory, MEMORY_TEMPLATE, userMemoryDir, userMemoryPath, projectMemoryDir, projectMemoryPath, } from '../runtime/memory.js';
|
|
26
|
+
import registerCanvasContextIntro, { buildContextIntro, renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
|
|
27
|
+
let home;
|
|
28
|
+
before(() => {
|
|
29
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-ctxintro-'));
|
|
30
|
+
process.env['CRTR_HOME'] = home;
|
|
31
|
+
});
|
|
32
|
+
beforeEach(() => {
|
|
33
|
+
closeDb();
|
|
34
|
+
rmSync(home, { recursive: true, force: true });
|
|
35
|
+
});
|
|
36
|
+
after(() => {
|
|
37
|
+
closeDb();
|
|
38
|
+
rmSync(home, { recursive: true, force: true });
|
|
39
|
+
delete process.env['CRTR_HOME'];
|
|
40
|
+
delete process.env['CRTR_NODE_ID'];
|
|
41
|
+
});
|
|
42
|
+
test('a plain spawned node gets NO memory; promotion seeds the index + dir', () => {
|
|
43
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
44
|
+
assert.equal(hasMemory(meta.node_id), false, 'no memory before promotion');
|
|
45
|
+
promote(meta.node_id);
|
|
46
|
+
assert.ok(hasMemory(meta.node_id), 'MEMORY.md index exists after promotion');
|
|
47
|
+
assert.ok(existsSync(memoryDir(meta.node_id)), 'memory dir created for direct writes');
|
|
48
|
+
assert.ok(memoryPath(meta.node_id).endsWith('/memory/MEMORY.md'), 'index lives inside the memory dir');
|
|
49
|
+
assert.equal(readMemory(meta.node_id), MEMORY_TEMPLATE);
|
|
50
|
+
});
|
|
51
|
+
test('seedMemory is idempotent — never clobbers an evolved memory', () => {
|
|
52
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
53
|
+
seedMemory(meta.node_id);
|
|
54
|
+
const evolved = '# Memory\n\n## Lessons\n- never trust the cache\n';
|
|
55
|
+
writeFileSync(memoryPath(meta.node_id), evolved);
|
|
56
|
+
assert.equal(seedMemory(meta.node_id), false, 'returns false when one exists');
|
|
57
|
+
assert.equal(readMemory(meta.node_id), evolved, 'left untouched');
|
|
58
|
+
});
|
|
59
|
+
test('worker bearings: shared-doc framing only, no orchestrator/memory framing', () => {
|
|
60
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
61
|
+
const block = buildContextIntro(meta.node_id);
|
|
62
|
+
assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
|
|
63
|
+
assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
|
|
64
|
+
assert.doesNotMatch(block, /MEMORY\.md/, 'no memory pointer for a memory-less worker');
|
|
65
|
+
assert.doesNotMatch(block, /<memory>/, 'no memory block for a memory-less worker');
|
|
66
|
+
assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
|
|
67
|
+
assert.match(block, /<\/crtr-context>/);
|
|
68
|
+
});
|
|
69
|
+
test('orchestrator bearings: across-cycles framing + a <memory> block whose body is the live pointer lines under `label · dir`', () => {
|
|
70
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
71
|
+
seedMemory(meta.node_id);
|
|
72
|
+
// The seeded index carries the how-to comment + the empty marker; an evolved
|
|
73
|
+
// index adds a pointer line. The block must render ONLY the pointer line.
|
|
74
|
+
writeFileSync(memoryPath(meta.node_id), '# memory index — one pointer line per memory; how-to in "Your long-term memory".\n\n- [Flaky build](flaky-build.md) — first run fails\n');
|
|
75
|
+
const block = buildContextIntro(meta.node_id);
|
|
76
|
+
assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
|
|
77
|
+
assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
|
|
78
|
+
assert.match(block, /<memory>/, 'has a memory block');
|
|
79
|
+
// (a) the store renders just its pointer line under a `label · dir` header.
|
|
80
|
+
assert.ok(block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'compact `label · dir` header');
|
|
81
|
+
assert.match(block, /- \[Flaky build\]\(flaky-build\.md\) — first run fails/, 'the live pointer line is inlined');
|
|
82
|
+
// (c) no `index:` line, no absolute index path, no inlined how-to boilerplate.
|
|
83
|
+
assert.ok(!block.includes('index:'), 'no index: substring');
|
|
84
|
+
assert.ok(!block.includes(memoryPath(meta.node_id)), 'no absolute index (MEMORY.md) path');
|
|
85
|
+
assert.ok(!block.includes('how-to in'), 'the index how-to boilerplate is NOT inlined');
|
|
86
|
+
assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
|
|
87
|
+
assert.match(block, /<\/crtr-context>/);
|
|
88
|
+
});
|
|
89
|
+
test('orchestrator bearings: a promoted node merges all THREE stores; template-only stores render (empty)', () => {
|
|
90
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
91
|
+
promote(meta.node_id); // seeds user-global + project + node-local (all template-only)
|
|
92
|
+
const block = buildContextIntro(meta.node_id);
|
|
93
|
+
// Each scope present as a `label · dir` header.
|
|
94
|
+
assert.ok(block.includes(`user-global · ${userMemoryDir()}`), 'merges the user-global store');
|
|
95
|
+
assert.ok(block.includes(`project · ${projectMemoryDir('/tmp/work')}`), 'merges the project store');
|
|
96
|
+
assert.ok(block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'merges the node-local store');
|
|
97
|
+
// (b) a template-only / not-yet-written store renders the (empty) marker.
|
|
98
|
+
assert.match(block, /user-global · .*\n\(empty\)/, 'template-only user store renders (empty)');
|
|
99
|
+
assert.equal(block.match(/\(empty\)/g)?.length, 3, 'all three template-only stores render (empty)');
|
|
100
|
+
// (c) no absolute index paths leak in.
|
|
101
|
+
assert.ok(!block.includes(userMemoryPath()), 'no user-global MEMORY.md path');
|
|
102
|
+
assert.ok(!block.includes(projectMemoryPath('/tmp/work')), 'no project MEMORY.md path');
|
|
103
|
+
// The type→store mapping is taught by the kernel; the block points at it.
|
|
104
|
+
assert.match(block, /type/, 'the block references the type that decides the store');
|
|
105
|
+
});
|
|
106
|
+
test('promotion guidance delivers the orchestrator context-dir framing', () => {
|
|
107
|
+
// A node spawns as a base worker (no orchestrator bearings). On promotion the
|
|
108
|
+
// persona injector must build the across-cycles context-dir framing it never
|
|
109
|
+
// got at spawn (delivered at the next turn boundary, not returned by promote).
|
|
110
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
111
|
+
promote(meta.node_id);
|
|
112
|
+
const drift = personaDrift(meta.node_id);
|
|
113
|
+
const guidance = drift?.guidance ?? '';
|
|
114
|
+
assert.ok(drift !== null, 'promotion drifts the persona (base→orchestrator)');
|
|
115
|
+
assert.match(guidance, /refresh cycles/, 'promotion guidance carries the across-cycles framing');
|
|
116
|
+
assert.ok(guidance.includes(contextDir(meta.node_id)), 'and names the context dir path');
|
|
117
|
+
});
|
|
118
|
+
function makeFakePi() {
|
|
119
|
+
return {
|
|
120
|
+
sent: [],
|
|
121
|
+
renderers: {},
|
|
122
|
+
on(e, h) { if (e === 'session_start')
|
|
123
|
+
this.handler = h; },
|
|
124
|
+
sendMessage(m) { this.sent.push(m); },
|
|
125
|
+
registerMessageRenderer(t, r) { this.renderers[t] = r; },
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
function fakeCtx(entries = []) {
|
|
129
|
+
return { sessionManager: { getEntries: () => entries } };
|
|
130
|
+
}
|
|
131
|
+
test('session_start injects the block as the first message of an empty session', () => {
|
|
132
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
133
|
+
process.env['CRTR_NODE_ID'] = meta.node_id;
|
|
134
|
+
const pi = makeFakePi();
|
|
135
|
+
registerCanvasContextIntro(pi);
|
|
136
|
+
assert.ok(pi.handler, 'session_start handler registered');
|
|
137
|
+
// Empty session → inject the block as its own custom message (no delivery
|
|
138
|
+
// options → it precedes the first prompt).
|
|
139
|
+
pi.handler({ reason: 'startup' }, fakeCtx());
|
|
140
|
+
assert.equal(pi.sent.length, 1, 'one message injected');
|
|
141
|
+
assert.equal(pi.sent[0].customType, CONTEXT_INTRO_CUSTOM_TYPE);
|
|
142
|
+
assert.equal(pi.sent[0].display, true);
|
|
143
|
+
assert.match(pi.sent[0].content, /<crtr-context dir=/);
|
|
144
|
+
});
|
|
145
|
+
test('session_start is idempotent across resume (skips if already in history)', () => {
|
|
146
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
147
|
+
process.env['CRTR_NODE_ID'] = meta.node_id;
|
|
148
|
+
// Restored session already carries the block → no duplicate injection.
|
|
149
|
+
const pi = makeFakePi();
|
|
150
|
+
registerCanvasContextIntro(pi);
|
|
151
|
+
const entries = [{ type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE }];
|
|
152
|
+
pi.handler({ reason: 'resume' }, fakeCtx(entries));
|
|
153
|
+
assert.equal(pi.sent.length, 0, 'block already in history → skip');
|
|
154
|
+
});
|
|
155
|
+
test('session_start is inert when CRTR_NODE_ID is absent', () => {
|
|
156
|
+
delete process.env['CRTR_NODE_ID'];
|
|
157
|
+
const pi = makeFakePi();
|
|
158
|
+
registerCanvasContextIntro(pi);
|
|
159
|
+
pi.handler({ reason: 'startup' }, fakeCtx());
|
|
160
|
+
assert.equal(pi.sent.length, 0);
|
|
161
|
+
});
|
|
162
|
+
test('renderer registered for the customType; collapsed hides body, ctrl+o expand reveals it', () => {
|
|
163
|
+
const pi = makeFakePi();
|
|
164
|
+
registerCanvasContextIntro(pi);
|
|
165
|
+
const renderer = pi.renderers[CONTEXT_INTRO_CUSTOM_TYPE];
|
|
166
|
+
assert.ok(renderer, 'message renderer registered for crtr-context');
|
|
167
|
+
const body = '<crtr-context dir="/x">\nshared document store\n</crtr-context>';
|
|
168
|
+
const message = { customType: CONTEXT_INTRO_CUSTOM_TYPE, content: body };
|
|
169
|
+
const theme = {}; // no fg → plain text, easy to assert
|
|
170
|
+
// Collapsed (default): a single stub line, NONE of the body.
|
|
171
|
+
const collapsed = renderContextMessage(message, { expanded: false }, theme).render(80);
|
|
172
|
+
assert.equal(collapsed.length, 1, 'collapsed is one line');
|
|
173
|
+
assert.match(collapsed[0], /ctrl\+o to expand/);
|
|
174
|
+
assert.ok(!collapsed.join('\n').includes('shared document store'), 'body hidden when collapsed');
|
|
175
|
+
// Expanded (Ctrl+O): label + full body.
|
|
176
|
+
const expanded = renderContextMessage(message, { expanded: true }, theme).render(80);
|
|
177
|
+
const joined = expanded.join('\n');
|
|
178
|
+
assert.ok(joined.includes('shared document store'), 'body shown when expanded');
|
|
179
|
+
assert.ok(joined.includes(`[${CONTEXT_INTRO_CUSTOM_TYPE}]`), 'expanded shows the label');
|
|
180
|
+
});
|
|
181
|
+
test('renderer never emits a line wider than the terminal (truncates the collapsed stub)', () => {
|
|
182
|
+
// Regression: a fixed-width stub crashed pi's TUI at narrow widths
|
|
183
|
+
// ("Rendered line 1 exceeds terminal width"). Every emitted line — collapsed
|
|
184
|
+
// or expanded — must fit within the width handed to render().
|
|
185
|
+
const body = Array.from({ length: 6 }, () => 'x'.repeat(200)).join('\n');
|
|
186
|
+
const message = { customType: CONTEXT_INTRO_CUSTOM_TYPE, content: body };
|
|
187
|
+
const theme = {}; // no fg → plain text, so .length == visible width
|
|
188
|
+
for (const w of [1, 2, 5, 10, 20, 42, 80]) {
|
|
189
|
+
for (const expanded of [false, true]) {
|
|
190
|
+
const lines = renderContextMessage(message, { expanded }, theme).render(w);
|
|
191
|
+
for (const line of lines) {
|
|
192
|
+
assert.ok([...line].length <= w, `width=${w} expanded=${expanded}: line ${[...line].length} cols exceeds ${w}: ${JSON.stringify(line)}`);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
7
|
+
import { closeDb } from '../canvas/db.js';
|
|
8
|
+
import { readInboxSince } from '../feed/inbox.js';
|
|
9
|
+
import { superviseTick } from '../../daemon/crtrd.js';
|
|
10
|
+
let home;
|
|
11
|
+
function node(id, over = {}) {
|
|
12
|
+
return {
|
|
13
|
+
node_id: id,
|
|
14
|
+
name: id,
|
|
15
|
+
created: new Date().toISOString(),
|
|
16
|
+
cwd: '/tmp/work',
|
|
17
|
+
kind: 'general',
|
|
18
|
+
mode: 'base',
|
|
19
|
+
lifecycle: 'terminal',
|
|
20
|
+
status: 'active',
|
|
21
|
+
...over,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/** Wire `parent subscribes_to child` — the spawn-time edge (parent wakes on the
|
|
25
|
+
* child's pushes), so the daemon's boot-failure push reaches the parent. */
|
|
26
|
+
function spawnEdge(parent, child) {
|
|
27
|
+
subscribe(parent, child, true);
|
|
28
|
+
}
|
|
29
|
+
before(() => {
|
|
30
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-daemon-boot-'));
|
|
31
|
+
process.env['CRTR_HOME'] = home;
|
|
32
|
+
});
|
|
33
|
+
beforeEach(() => {
|
|
34
|
+
closeDb();
|
|
35
|
+
rmSync(home, { recursive: true, force: true });
|
|
36
|
+
});
|
|
37
|
+
after(() => {
|
|
38
|
+
closeDb();
|
|
39
|
+
rmSync(home, { recursive: true, force: true });
|
|
40
|
+
delete process.env['CRTR_HOME'];
|
|
41
|
+
});
|
|
42
|
+
// A node whose window is gone while pi_session_id was NEVER set: the vehicle
|
|
43
|
+
// died before pi ever booted. The spawner already returned status="active", so
|
|
44
|
+
// the parent must be told — the daemon marks it dead AND pushes urgent up.
|
|
45
|
+
test('never-booted node is marked dead and surfaced to the parent as urgent', async () => {
|
|
46
|
+
createNode(node('P', { kind: 'developer', lifecycle: 'resident' }));
|
|
47
|
+
createNode(node('C', {
|
|
48
|
+
parent: 'P',
|
|
49
|
+
kind: 'explore',
|
|
50
|
+
// Points at a session/window that does not exist → windowAlive() is false,
|
|
51
|
+
// so the daemon treats the window as gone.
|
|
52
|
+
tmux_session: 'crtr-test-absent-session',
|
|
53
|
+
window: '@999991',
|
|
54
|
+
pi_session_id: null, // never booted
|
|
55
|
+
intent: null,
|
|
56
|
+
}));
|
|
57
|
+
spawnEdge('P', 'C');
|
|
58
|
+
await superviseTick();
|
|
59
|
+
assert.equal(getNode('C').status, 'dead', 'never-booted child is dead');
|
|
60
|
+
const inbox = readInboxSince('P');
|
|
61
|
+
assert.equal(inbox.length, 1, 'parent received exactly one pointer');
|
|
62
|
+
assert.equal(inbox[0].tier, 'urgent', 'boot failure is delivered as urgent');
|
|
63
|
+
assert.equal(inbox[0].from, 'C');
|
|
64
|
+
assert.match(inbox[0].label, /Spawn failed/);
|
|
65
|
+
});
|
|
66
|
+
// A node that HAD booted (pi_session_id set) then lost its window is a genuine
|
|
67
|
+
// crash, not a boot failure — it is marked dead but NOT surfaced as a spawn
|
|
68
|
+
// failure (no false alarm to the parent).
|
|
69
|
+
test('a crash after boot is marked dead without a boot-failure push', async () => {
|
|
70
|
+
createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
|
|
71
|
+
createNode(node('C2', {
|
|
72
|
+
parent: 'P2',
|
|
73
|
+
kind: 'explore',
|
|
74
|
+
tmux_session: 'crtr-test-absent-session',
|
|
75
|
+
window: '@999992',
|
|
76
|
+
pi_session_id: '019e8f00-booted-once', // it booted before dying
|
|
77
|
+
intent: null,
|
|
78
|
+
}));
|
|
79
|
+
spawnEdge('P2', 'C2');
|
|
80
|
+
await superviseTick();
|
|
81
|
+
assert.equal(getNode('C2').status, 'dead', 'crashed child is dead');
|
|
82
|
+
assert.equal(readInboxSince('P2').length, 0, 'a real crash does not push a boot-failure pointer');
|
|
83
|
+
});
|
|
84
|
+
// A still-booting node whose window is alive must be left untouched — boot is
|
|
85
|
+
// slow, and an alive window means pi may still be coming up.
|
|
86
|
+
test('a node with a live window is left alone even before it boots', async () => {
|
|
87
|
+
// No tmux_session/window → treated as an inline root and skipped entirely,
|
|
88
|
+
// which exercises the "no placement → not daemon-managed" guard rather than a
|
|
89
|
+
// false reap. (We avoid depending on a real live tmux window in the test env.)
|
|
90
|
+
createNode(node('S', { status: 'active', pi_session_id: null }));
|
|
91
|
+
await superviseTick();
|
|
92
|
+
assert.equal(getNode('S').status, 'active', 'unplaced active node is not reaped');
|
|
93
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
|
+
import { tmpdir } from 'node:os';
|
|
5
|
+
import { join } from 'node:path';
|
|
6
|
+
import { spawnSync } from 'node:child_process';
|
|
7
|
+
import { createNode, getNode } from '../canvas/canvas.js';
|
|
8
|
+
import { closeDb } from '../canvas/db.js';
|
|
9
|
+
import { appendInbox } from '../feed/inbox.js';
|
|
10
|
+
import { superviseTick, isPidAlive, livenessVerdict, } from '../../daemon/crtrd.js';
|
|
11
|
+
let home;
|
|
12
|
+
function node(id, over = {}) {
|
|
13
|
+
return {
|
|
14
|
+
node_id: id,
|
|
15
|
+
name: id,
|
|
16
|
+
created: new Date().toISOString(),
|
|
17
|
+
cwd: '/tmp/work',
|
|
18
|
+
kind: 'general',
|
|
19
|
+
mode: 'base',
|
|
20
|
+
lifecycle: 'terminal',
|
|
21
|
+
status: 'active',
|
|
22
|
+
...over,
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
function hasTmux() {
|
|
26
|
+
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
27
|
+
}
|
|
28
|
+
/** A pid that is guaranteed dead: spawn a no-op and wait for it to exit. */
|
|
29
|
+
function deadPid() {
|
|
30
|
+
const r = spawnSync('true', [], { stdio: 'ignore' });
|
|
31
|
+
// spawnSync has reaped it by the time it returns; pid may be on r.pid.
|
|
32
|
+
return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
|
|
33
|
+
}
|
|
34
|
+
before(() => {
|
|
35
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-daemon-liveness-'));
|
|
36
|
+
process.env['CRTR_HOME'] = home;
|
|
37
|
+
});
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
closeDb();
|
|
40
|
+
rmSync(home, { recursive: true, force: true });
|
|
41
|
+
});
|
|
42
|
+
after(() => {
|
|
43
|
+
closeDb();
|
|
44
|
+
rmSync(home, { recursive: true, force: true });
|
|
45
|
+
delete process.env['CRTR_HOME'];
|
|
46
|
+
});
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// livenessVerdict — the pure grace-window decision
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
test('livenessVerdict: a live (or unknown) pi is left alone', () => {
|
|
51
|
+
assert.equal(livenessVerdict(true, 0), 'leave', 'alive pid → leave');
|
|
52
|
+
assert.equal(livenessVerdict(true, 10_000_000), 'leave');
|
|
53
|
+
assert.equal(livenessVerdict(null, 10_000_000), 'leave', 'no recorded pid → leave (legacy / in-flight)');
|
|
54
|
+
});
|
|
55
|
+
test('livenessVerdict: a dead pi pends through the grace window, then revives', () => {
|
|
56
|
+
assert.equal(livenessVerdict(false, null), 'pending', 'first observation → pending');
|
|
57
|
+
assert.equal(livenessVerdict(false, 0), 'pending', 'just-observed-dead → pending');
|
|
58
|
+
assert.equal(livenessVerdict(false, 1_000), 'pending', 'still inside grace → pending');
|
|
59
|
+
assert.equal(livenessVerdict(false, 10_000_000), 'revive', 'dead past grace → revive');
|
|
60
|
+
});
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// isPidAlive
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
test('isPidAlive: this process is alive; a reaped pid is dead', () => {
|
|
65
|
+
assert.equal(isPidAlive(process.pid), true, 'self is alive');
|
|
66
|
+
assert.equal(isPidAlive(deadPid()), false, 'a reaped/implausible pid is dead');
|
|
67
|
+
});
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
69
|
+
// superviseTick + a REAL live tmux window (gated on tmux availability)
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
/** Run `fn` with a real, live tmux window held open for its whole duration, then
|
|
72
|
+
* tear the session down. Gives superviseTick a genuinely-alive window to judge. */
|
|
73
|
+
async function withLiveWindow(tag, fn) {
|
|
74
|
+
const session = `crtr-livetest-${process.pid}-${tag}`;
|
|
75
|
+
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
|
|
76
|
+
try {
|
|
77
|
+
const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
|
|
78
|
+
const window = (r.stdout ?? '').trim().split('\n')[0];
|
|
79
|
+
await fn(session, window);
|
|
80
|
+
}
|
|
81
|
+
finally {
|
|
82
|
+
spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Like withLiveWindow but also resolves the window's live `%pane_id` — the v3
|
|
86
|
+
* pane-anchored handle the daemon now keys liveness on. */
|
|
87
|
+
async function withLivePane(tag, fn) {
|
|
88
|
+
await withLiveWindow(tag, async (session, window) => {
|
|
89
|
+
const r = spawnSync('tmux', ['display-message', '-p', '-t', `${session}:${window}`, '#{pane_id}'], { encoding: 'utf8' });
|
|
90
|
+
const pane = (r.stdout ?? '').trim();
|
|
91
|
+
await fn(session, window, pane);
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
test('window alive + ALIVE pi pid → healthy, untouched', { skip: !hasTmux() }, async () => {
|
|
95
|
+
await withLiveWindow('a', async (session, window) => {
|
|
96
|
+
// pi_pid = this test process: definitely alive.
|
|
97
|
+
createNode(node('A', { tmux_session: session, window, pi_pid: process.pid, pi_session_id: 'booted' }));
|
|
98
|
+
await superviseTick();
|
|
99
|
+
assert.equal(getNode('A').status, 'active', 'a node with a live pi is left active');
|
|
100
|
+
});
|
|
101
|
+
});
|
|
102
|
+
test('window alive + dead pi pid → pending on first observation (not reaped, not revived)', { skip: !hasTmux() }, async () => {
|
|
103
|
+
await withLiveWindow('b', async (session, window) => {
|
|
104
|
+
createNode(node('D', {
|
|
105
|
+
tmux_session: session,
|
|
106
|
+
window,
|
|
107
|
+
pi_pid: deadPid(),
|
|
108
|
+
pi_session_id: 'booted-once',
|
|
109
|
+
intent: null,
|
|
110
|
+
status: 'active',
|
|
111
|
+
}));
|
|
112
|
+
await superviseTick();
|
|
113
|
+
// First observation: inside the grace window → pending. The node must NOT be
|
|
114
|
+
// reaped to 'dead' (its window is alive) nor revived yet (no new pi spawned).
|
|
115
|
+
assert.equal(getNode('D').status, 'active', 'pending node stays active on first tick');
|
|
116
|
+
});
|
|
117
|
+
});
|
|
118
|
+
// ---------------------------------------------------------------------------
|
|
119
|
+
// Step 3 (Q6): PANE-existence drives the daemon verdict, not window-existence.
|
|
120
|
+
// A manual move-pane/join-pane/break-pane must never read as a death; and a
|
|
121
|
+
// pane that is genuinely gone fires the existing gone-branch even when the
|
|
122
|
+
// window it used to live in is still alive.
|
|
123
|
+
// ---------------------------------------------------------------------------
|
|
124
|
+
test('pane alive but window/session cache STALE → LIVE (reconciled, not revived/reaped)', { skip: !hasTmux() }, async () => {
|
|
125
|
+
await withLivePane('p1', async (session, window, pane) => {
|
|
126
|
+
// The pane is live, the pi is alive (this process) — but the row's window
|
|
127
|
+
// cache is bogus (as if a manual move desynced it). Pane-existence must win:
|
|
128
|
+
// the node stays active and reconcile FOLLOWS the pane back to its real window.
|
|
129
|
+
createNode(node('M', {
|
|
130
|
+
pane,
|
|
131
|
+
tmux_session: session,
|
|
132
|
+
window: '@99999', // stale/bogus — windowAlive() would call this gone
|
|
133
|
+
pi_pid: process.pid,
|
|
134
|
+
pi_session_id: 'booted',
|
|
135
|
+
}));
|
|
136
|
+
await superviseTick();
|
|
137
|
+
const m = getNode('M');
|
|
138
|
+
assert.equal(m.status, 'active', 'a live pane keeps the node active despite a stale window cache');
|
|
139
|
+
assert.equal(m.window, window, 'reconcile FOLLOWED the live pane back to its real window');
|
|
140
|
+
assert.equal(m.pane, pane, 'the durable pane id is unchanged');
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
test('pane GONE while its old window is still alive → the gone-branch fires (crash → dead)', { skip: !hasTmux() }, async () => {
|
|
144
|
+
await withLivePane('p2', async (session, window) => {
|
|
145
|
+
// Make a guaranteed-DEAD pane id inside the still-live window: split a fresh
|
|
146
|
+
// pane, then kill it (the window survives via its original pane).
|
|
147
|
+
const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
|
|
148
|
+
const dead = (sp.stdout ?? '').trim();
|
|
149
|
+
spawnSync('tmux', ['kill-pane', '-t', dead], { stdio: 'ignore' });
|
|
150
|
+
// The window is alive, but the node is anchored on that dead pane. Under the
|
|
151
|
+
// old window-keyed liveness this node would read healthy (window alive + live
|
|
152
|
+
// pid). Pane-keyed: the pane is gone → the crash branch fires. pi_session_id
|
|
153
|
+
// is set so it's a clean crash, not a boot-failure push.
|
|
154
|
+
createNode(node('G', {
|
|
155
|
+
pane: dead,
|
|
156
|
+
tmux_session: session,
|
|
157
|
+
window,
|
|
158
|
+
pi_pid: process.pid, // alive — irrelevant once the pane reads gone
|
|
159
|
+
pi_session_id: 'booted',
|
|
160
|
+
intent: null,
|
|
161
|
+
}));
|
|
162
|
+
await superviseTick();
|
|
163
|
+
assert.equal(getNode('G').status, 'dead', 'a gone pane fires the gone-branch even with a live window + pid');
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
// ---------------------------------------------------------------------------
|
|
167
|
+
// Step 7 (§5.3, F3): a focused-dormant node frozen via remain-on-exit is
|
|
168
|
+
// PANE-alive but pi-DEAD with intent='idle-release'. The daemon must NOT
|
|
169
|
+
// grace-revive it on liveness (handleLiveWindow early-returns for idle-release);
|
|
170
|
+
// it revives ONLY when an unseen inbox entry arrives (second pass, gated on pi
|
|
171
|
+
// liveness — NOT pane existence, which would skip a frozen pane forever).
|
|
172
|
+
// ---------------------------------------------------------------------------
|
|
173
|
+
test('idle-release + live (frozen) pane + DEAD pi → handleLiveWindow does NOT grace-revive it', { skip: !hasTmux() }, async () => {
|
|
174
|
+
await withLivePane('idle1', async (session, window, pane) => {
|
|
175
|
+
createNode(node('F', {
|
|
176
|
+
pane,
|
|
177
|
+
tmux_session: session,
|
|
178
|
+
window,
|
|
179
|
+
pi_pid: deadPid(), // pi is DEAD; the pane is frozen (remain-on-exit stand-in)
|
|
180
|
+
pi_session_id: 'booted',
|
|
181
|
+
intent: 'idle-release',
|
|
182
|
+
status: 'idle',
|
|
183
|
+
home_session: session,
|
|
184
|
+
}));
|
|
185
|
+
// Two ticks: the SECOND is far past REVIVE_GRACE_MS (20s). Without the
|
|
186
|
+
// idle-release early-return in handleLiveWindow, tick 1 would mark the dead
|
|
187
|
+
// pi 'pending' and tick 2 (now past grace) would grace-revive it, bumping
|
|
188
|
+
// cycles. The early-return means it is never even marked pending. No inbox
|
|
189
|
+
// entry, so the second pass never revives it either.
|
|
190
|
+
await superviseTick(1_000_000);
|
|
191
|
+
await superviseTick(1_000_000 + 60_000);
|
|
192
|
+
const m = getNode('F');
|
|
193
|
+
assert.equal(m.status, 'idle', 'still idle — the frozen focused-dormant node is left alone');
|
|
194
|
+
assert.equal(m.cycles ?? 0, 0, 'cycles NOT bumped → reviveNode never fired');
|
|
195
|
+
assert.equal(m.pane, pane, 'pane unchanged');
|
|
196
|
+
assert.equal(m.window, window, 'window unchanged');
|
|
197
|
+
// Non-vacuous: drop the `intent==='idle-release'` early-return and tick 2
|
|
198
|
+
// (deadFor 60s > 20s grace) revives → cycles===1, status flips → both fail.
|
|
199
|
+
});
|
|
200
|
+
});
|
|
201
|
+
test('idle-release + live (frozen) pane + DEAD pi + UNSEEN inbox → REVIVED on the second pass', { skip: !hasTmux() }, async () => {
|
|
202
|
+
await withLivePane('idle2', async (session, window, pane) => {
|
|
203
|
+
createNode(node('W', {
|
|
204
|
+
pane,
|
|
205
|
+
tmux_session: session,
|
|
206
|
+
window,
|
|
207
|
+
pi_pid: deadPid(),
|
|
208
|
+
pi_session_id: 'booted',
|
|
209
|
+
intent: 'idle-release',
|
|
210
|
+
status: 'idle',
|
|
211
|
+
home_session: session, // the revive window lands here (torn down with the session)
|
|
212
|
+
}));
|
|
213
|
+
appendInbox('W', { from: 'child', tier: 'normal', kind: 'update', label: 'work for you' });
|
|
214
|
+
await superviseTick();
|
|
215
|
+
// The OLD second-pass guard `if (isNodePaneAlive(r)) continue;` would SKIP a
|
|
216
|
+
// pane-alive node forever; the new `if (r.pi_pid != null && isPidAlive(...))`
|
|
217
|
+
// gate sees the DEAD pi and revives. reviveNode bumps cycles BEFORE placing
|
|
218
|
+
// the window, so the bump is the observable even though the launch is a real
|
|
219
|
+
// (short-lived, torn-down) window. Non-vacuous: the old pane-existence gate
|
|
220
|
+
// leaves cycles unbumped.
|
|
221
|
+
assert.equal(getNode('W').cycles, 1, 'reviveNode fired on the inbox push despite a live (frozen) pane');
|
|
222
|
+
});
|
|
223
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|