@crouton-kit/crouter 0.3.13 → 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/__tests__/human.test.js +73 -2
- 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.d.ts +1 -0
- package/dist/commands/human/queue.js +105 -2
- package/dist/commands/human/shared.d.ts +28 -18
- package/dist/commands/human/shared.js +53 -60
- package/dist/commands/human.js +6 -14
- package/dist/commands/node.d.ts +11 -0
- package/dist/commands/node.js +381 -87
- 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.d.ts +1 -0
- package/dist/core/__tests__/passive-subscription.test.js +164 -0
- 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.d.ts +1 -0
- package/dist/core/__tests__/subcommand-tier.test.js +99 -0
- 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/paths.d.ts +4 -0
- package/dist/core/canvas/paths.js +6 -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 +48 -7
- package/dist/core/config.js +36 -2
- package/dist/core/feed/feed.js +14 -12
- package/dist/core/feed/inbox.d.ts +3 -1
- package/dist/core/feed/inbox.js +45 -5
- package/dist/core/feed/passive.d.ts +17 -0
- package/dist/core/feed/passive.js +92 -0
- package/dist/core/help.d.ts +59 -13
- package/dist/core/help.js +73 -28
- 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.d.ts +14 -0
- package/dist/core/runtime/demote.js +120 -0
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/kickoff.d.ts +32 -6
- package/dist/core/runtime/kickoff.js +111 -37
- package/dist/core/runtime/launch.d.ts +29 -6
- package/dist/core/runtime/launch.js +85 -13
- 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 -32
- package/dist/core/runtime/presence.js +90 -110
- package/dist/core/runtime/promote.d.ts +18 -7
- package/dist/core/runtime/promote.js +70 -65
- 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/roadmap.d.ts +5 -4
- package/dist/core/runtime/roadmap.js +9 -16
- package/dist/core/runtime/spawn.d.ts +20 -5
- package/dist/core/runtime/spawn.js +169 -44
- 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 +106 -21
- package/dist/core/runtime/tmux.js +249 -45
- 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.d.ts +34 -0
- package/dist/pi-extensions/canvas-commands.js +103 -0
- 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 +21 -0
- package/dist/pi-extensions/canvas-goal-capture.js +67 -0
- 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-passive-context.d.ts +32 -0
- package/dist/pi-extensions/canvas-passive-context.js +114 -0
- 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,328 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/relaunch.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Covers the `/new`-in-a-root relaunch (option C) + clean-exit termination
|
|
4
|
+
// semantics. tmux/respawn is unavailable in CI, so the respawn is an INJECTED
|
|
5
|
+
// test double (RelaunchDeps.relaunchRootInPane) and every assertion is on the
|
|
6
|
+
// DB / edge / disk effects.
|
|
7
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
8
|
+
import assert from 'node:assert/strict';
|
|
9
|
+
import { mkdtempSync, rmSync, existsSync, writeFileSync, readdirSync } from 'node:fs';
|
|
10
|
+
import { tmpdir } from 'node:os';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { createNode, getNode, subscribe, subscriptionsOf, view, listNodes, } from '../canvas/canvas.js';
|
|
13
|
+
import { closeDb } from '../canvas/db.js';
|
|
14
|
+
import { reportsDir, inboxPath, contextDir } from '../canvas/paths.js';
|
|
15
|
+
import { roadmapPath } from '../runtime/roadmap.js';
|
|
16
|
+
import { relaunchRoot, handleNewSession, markCleanExitDone, reapDescendants, } from '../runtime/reset.js';
|
|
17
|
+
import { getFocus } from '../runtime/presence.js';
|
|
18
|
+
import { renderForest } from '../canvas/render.js';
|
|
19
|
+
let home;
|
|
20
|
+
function node(id, over = {}) {
|
|
21
|
+
return {
|
|
22
|
+
node_id: id,
|
|
23
|
+
name: id,
|
|
24
|
+
created: new Date().toISOString(),
|
|
25
|
+
cwd: '/tmp/work',
|
|
26
|
+
kind: 'general',
|
|
27
|
+
mode: 'base',
|
|
28
|
+
lifecycle: 'terminal',
|
|
29
|
+
status: 'active',
|
|
30
|
+
...over,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
/** A respawn double that records its calls and never throws (dispatch ok). */
|
|
34
|
+
function okRespawn() {
|
|
35
|
+
const calls = [];
|
|
36
|
+
return {
|
|
37
|
+
calls,
|
|
38
|
+
fn: (nodeId, pane) => { calls.push({ nodeId, pane }); },
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
/** A respawn double that simulates a dispatch failure (throws). */
|
|
42
|
+
function throwingRespawn() {
|
|
43
|
+
const calls = [];
|
|
44
|
+
return {
|
|
45
|
+
calls,
|
|
46
|
+
fn: (nodeId, pane) => {
|
|
47
|
+
calls.push({ nodeId, pane });
|
|
48
|
+
throw new Error('respawn-pane dispatch failed');
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
before(() => {
|
|
53
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-relaunch-'));
|
|
54
|
+
process.env['CRTR_HOME'] = home;
|
|
55
|
+
});
|
|
56
|
+
beforeEach(() => {
|
|
57
|
+
closeDb();
|
|
58
|
+
rmSync(home, { recursive: true, force: true });
|
|
59
|
+
});
|
|
60
|
+
after(() => {
|
|
61
|
+
closeDb();
|
|
62
|
+
rmSync(home, { recursive: true, force: true });
|
|
63
|
+
delete process.env['CRTR_HOME'];
|
|
64
|
+
});
|
|
65
|
+
// ---------------------------------------------------------------------------
|
|
66
|
+
// #1 — relaunchRoot parks the old root, keeps edges, creates a fresh root
|
|
67
|
+
// ---------------------------------------------------------------------------
|
|
68
|
+
test('relaunchRoot parks the old root (done, edges intact, no wipe) and mints a fresh root', () => {
|
|
69
|
+
createNode(node('root', {
|
|
70
|
+
parent: null,
|
|
71
|
+
lifecycle: 'resident',
|
|
72
|
+
mode: 'orchestrator',
|
|
73
|
+
pi_session_id: 'root-sess',
|
|
74
|
+
pi_session_file: '/abs/root-sess.jsonl',
|
|
75
|
+
tmux_session: 'crtr',
|
|
76
|
+
window: '@7',
|
|
77
|
+
}));
|
|
78
|
+
createNode(node('child', { parent: 'root' }));
|
|
79
|
+
createNode(node('grand', { parent: 'child' }));
|
|
80
|
+
subscribe('root', 'child', true);
|
|
81
|
+
subscribe('child', 'grand', true);
|
|
82
|
+
// Working state on the old root that parking must PRESERVE (no wipe).
|
|
83
|
+
writeFileSync(roadmapPath('root'), '# Roadmap\nold goal\n');
|
|
84
|
+
writeFileSync(inboxPath('root'), '{"ts":"x","from":"child","tier":"normal","kind":"update","label":"hi"}\n');
|
|
85
|
+
writeFileSync(join(reportsDir('root'), '20260101T000000-update.md'), 'a report');
|
|
86
|
+
const respawn = okRespawn();
|
|
87
|
+
const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
88
|
+
assert.ok(res !== null, 'relaunchRoot returns the new node id');
|
|
89
|
+
const newId = res.newNodeId;
|
|
90
|
+
assert.notEqual(newId, 'root', 'a FRESH id, not the old one');
|
|
91
|
+
// Respawn was dispatched against the NEW node in the given pane.
|
|
92
|
+
assert.deepEqual(respawn.calls, [{ nodeId: newId, pane: 'test-pane' }]);
|
|
93
|
+
// Old root: parked done, window detached, pi_session_id UNCHANGED (resumable).
|
|
94
|
+
const old = getNode('root');
|
|
95
|
+
assert.equal(old?.status, 'done', 'old root parked done');
|
|
96
|
+
assert.equal(old?.window, null, 'old root window detached');
|
|
97
|
+
assert.equal(old?.tmux_session, null, 'old root tmux_session detached');
|
|
98
|
+
assert.equal(old?.intent, null, 'old root intent cleared');
|
|
99
|
+
assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
|
|
100
|
+
assert.equal(old?.pi_session_file, '/abs/root-sess.jsonl', 'pi_session_file preserved (resumable by path)');
|
|
101
|
+
assert.equal(old?.parent, null, 'old root stays a root');
|
|
102
|
+
// Descendants: DONE (not dead), but edges intact.
|
|
103
|
+
assert.equal(getNode('child')?.status, 'done', 'child marked done (not a fault)');
|
|
104
|
+
assert.equal(getNode('grand')?.status, 'done', 'grand marked done (not a fault)');
|
|
105
|
+
assert.deepEqual(subscriptionsOf('root').map((s) => s.node_id), ['child'], 'root→child edge intact');
|
|
106
|
+
assert.deepEqual(subscriptionsOf('child').map((s) => s.node_id), ['grand'], 'child→grand edge intact');
|
|
107
|
+
// Old root working state PRESERVED (history, no wipe).
|
|
108
|
+
assert.equal(existsSync(roadmapPath('root')), true, 'roadmap preserved');
|
|
109
|
+
assert.equal(existsSync(inboxPath('root')), true, 'inbox preserved');
|
|
110
|
+
assert.equal(existsSync(join(reportsDir('root'), '20260101T000000-update.md')), true, 'report preserved');
|
|
111
|
+
// New root: fresh base resident, active, intent=refresh, empty context dir,
|
|
112
|
+
// spawned_by=old, focused.
|
|
113
|
+
const fresh = getNode(newId);
|
|
114
|
+
assert.equal(fresh?.parent, null, 'new node is a root');
|
|
115
|
+
assert.equal(fresh?.mode, 'base');
|
|
116
|
+
assert.equal(fresh?.lifecycle, 'resident');
|
|
117
|
+
assert.equal(fresh?.status, 'active');
|
|
118
|
+
assert.equal(fresh?.intent, 'refresh', 'safety-net intent until boot');
|
|
119
|
+
assert.equal(fresh?.spawned_by, 'root', 'audit-only successor link to old root');
|
|
120
|
+
assert.equal(fresh?.pi_pid, null, 'no pi yet');
|
|
121
|
+
assert.equal(fresh?.tmux_session, 'crtr', 'adopted the old root window location');
|
|
122
|
+
assert.equal(fresh?.window, '@7');
|
|
123
|
+
assert.ok(fresh?.launch, 'a fresh base launch spec was written');
|
|
124
|
+
assert.equal(readdirSync(contextDir(newId)).length, 0, 'fresh empty context dir');
|
|
125
|
+
assert.equal(getFocus(), newId, 'focus follows content to the new root');
|
|
126
|
+
});
|
|
127
|
+
// ---------------------------------------------------------------------------
|
|
128
|
+
// #1b — handleNewSession success branch: root WITH a pane routes to relaunch
|
|
129
|
+
// ---------------------------------------------------------------------------
|
|
130
|
+
test('handleNewSession on a root with a pane returns path:relaunch + parks old, mints fresh', () => {
|
|
131
|
+
createNode(node('root', {
|
|
132
|
+
parent: null,
|
|
133
|
+
lifecycle: 'resident',
|
|
134
|
+
mode: 'orchestrator',
|
|
135
|
+
pi_session_id: 'root-sess',
|
|
136
|
+
tmux_session: 'crtr',
|
|
137
|
+
window: '@7',
|
|
138
|
+
}));
|
|
139
|
+
createNode(node('child', { parent: 'root' }));
|
|
140
|
+
subscribe('root', 'child', true);
|
|
141
|
+
const respawn = okRespawn();
|
|
142
|
+
const res = handleNewSession('root', 'newsess', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
143
|
+
// The policy router's success return shape: relaunch + a fresh new node id.
|
|
144
|
+
assert.equal(res.path, 'relaunch', 'root + pane routes to option C relaunch');
|
|
145
|
+
assert.ok(res.newNodeId, 'newNodeId set on the relaunch path');
|
|
146
|
+
assert.notEqual(res.newNodeId, 'root', 'a FRESH id, not the old root');
|
|
147
|
+
// Respawn dispatched against the new node in the pane.
|
|
148
|
+
assert.deepEqual(respawn.calls, [{ nodeId: res.newNodeId, pane: 'test-pane' }]);
|
|
149
|
+
// Parked-old + fresh-new end state.
|
|
150
|
+
const old = getNode('root');
|
|
151
|
+
assert.equal(old?.status, 'done', 'old root parked done');
|
|
152
|
+
assert.equal(old?.window, null, 'old root window detached');
|
|
153
|
+
assert.equal(old?.pi_session_id, 'root-sess', 'pi_session_id preserved (resumable)');
|
|
154
|
+
assert.equal(getNode('child')?.status, 'done', 'descendant reaped done');
|
|
155
|
+
const fresh = getNode(res.newNodeId);
|
|
156
|
+
assert.equal(fresh?.parent, null, 'new node is a root');
|
|
157
|
+
assert.equal(fresh?.status, 'active', 'fresh root active');
|
|
158
|
+
assert.equal(fresh?.spawned_by, 'root', 'audit-only successor link to old root');
|
|
159
|
+
assert.equal(getFocus(), res.newNodeId, 'focus follows to the fresh root');
|
|
160
|
+
});
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
// #2 — handleNewSession on a non-root → session-id refresh only
|
|
163
|
+
// ---------------------------------------------------------------------------
|
|
164
|
+
test('handleNewSession on a non-root child only refreshes its session id', () => {
|
|
165
|
+
createNode(node('root', { parent: null }));
|
|
166
|
+
createNode(node('child', { parent: 'root', pi_session_id: 'old', pi_session_file: '/abs/old.jsonl' }));
|
|
167
|
+
subscribe('root', 'child', true);
|
|
168
|
+
const before = listNodes().length;
|
|
169
|
+
const res = handleNewSession('child', 'fresh', 'test-pane', {}, '/abs/fresh.jsonl');
|
|
170
|
+
assert.equal(res.path, 'reset-child');
|
|
171
|
+
assert.equal(res.newNodeId, undefined, 'no new node minted');
|
|
172
|
+
assert.equal(getNode('child')?.pi_session_id, 'fresh', 'session id refreshed');
|
|
173
|
+
assert.equal(getNode('child')?.pi_session_file, '/abs/fresh.jsonl', 'session FILE refreshed (path-based resume)');
|
|
174
|
+
assert.equal(getNode('child')?.status, 'active', 'child not reaped');
|
|
175
|
+
assert.equal(listNodes().length, before, 'no node added');
|
|
176
|
+
});
|
|
177
|
+
// ---------------------------------------------------------------------------
|
|
178
|
+
// #3 — handleNewSession on a root with no pane → in-place reset fallback
|
|
179
|
+
// ---------------------------------------------------------------------------
|
|
180
|
+
test('handleNewSession on a root with no pane falls back to in-place resetRoot', () => {
|
|
181
|
+
createNode(node('root', { parent: null, lifecycle: 'resident', mode: 'orchestrator' }));
|
|
182
|
+
createNode(node('child', { parent: 'root' }));
|
|
183
|
+
subscribe('root', 'child', true);
|
|
184
|
+
writeFileSync(roadmapPath('root'), '# Roadmap\n');
|
|
185
|
+
const before = listNodes().length;
|
|
186
|
+
const res = handleNewSession('root', 'newsess', undefined);
|
|
187
|
+
assert.equal(res.path, 'reset-root', 'documented degradation: in-place reset');
|
|
188
|
+
assert.equal(res.newNodeId, undefined, 'NO new node created');
|
|
189
|
+
assert.equal(listNodes().length, before, 'no node added (same id re-pointed)');
|
|
190
|
+
// Same id re-pointed to a pristine base resident.
|
|
191
|
+
const root = getNode('root');
|
|
192
|
+
assert.equal(root?.status, 'active');
|
|
193
|
+
assert.equal(root?.mode, 'base');
|
|
194
|
+
assert.equal(root?.lifecycle, 'resident');
|
|
195
|
+
assert.equal(root?.pi_session_id, 'newsess');
|
|
196
|
+
assert.equal(view('root').length, 0, 'root view emptied');
|
|
197
|
+
assert.equal(getNode('child')?.status, 'done', 'descendant marked done');
|
|
198
|
+
assert.equal(existsSync(roadmapPath('root')), false, 'working state wiped');
|
|
199
|
+
});
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// #4 — rapid double /new guard
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
test('a second relaunchRoot on an already-parked root is a no-op', () => {
|
|
204
|
+
createNode(node('root', { parent: null, lifecycle: 'resident' }));
|
|
205
|
+
const respawn = okRespawn();
|
|
206
|
+
const first = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
207
|
+
assert.ok(first !== null, 'first /new parks + relaunches');
|
|
208
|
+
const afterFirst = listNodes().length;
|
|
209
|
+
// Old root is now `done`; a second session_start in the dying old pi must
|
|
210
|
+
// no-op (no second parked node, no zombie new node).
|
|
211
|
+
const second = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
212
|
+
assert.equal(second, null, 'second relaunch is a no-op');
|
|
213
|
+
assert.equal(listNodes().length, afterFirst, 'no second new node minted');
|
|
214
|
+
assert.equal(respawn.calls.length, 1, 'respawn dispatched only once');
|
|
215
|
+
assert.equal(getNode('root')?.status, 'done', 'old root unchanged (still parked)');
|
|
216
|
+
});
|
|
217
|
+
// ---------------------------------------------------------------------------
|
|
218
|
+
// #5 — /new before the root ever spawned children
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
test('relaunchRoot on a childless root: reap is a no-op, new node minted', () => {
|
|
221
|
+
createNode(node('root', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@1' }));
|
|
222
|
+
assert.deepEqual(reapDescendants('root'), [], 'no descendants to reap');
|
|
223
|
+
const respawn = okRespawn();
|
|
224
|
+
const res = relaunchRoot('root', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
225
|
+
assert.ok(res !== null, 'new node minted without throwing');
|
|
226
|
+
assert.equal(getNode('root')?.status, 'done', 'old root parked');
|
|
227
|
+
assert.equal(getNode(res.newNodeId)?.status, 'active', 'fresh root active');
|
|
228
|
+
});
|
|
229
|
+
// ---------------------------------------------------------------------------
|
|
230
|
+
// #6 — respawn dispatch failure → rollback + resetRoot
|
|
231
|
+
// ---------------------------------------------------------------------------
|
|
232
|
+
test('a respawn dispatch failure rolls the whole transaction back and degrades to resetRoot', () => {
|
|
233
|
+
createNode(node('root', {
|
|
234
|
+
parent: null,
|
|
235
|
+
lifecycle: 'resident',
|
|
236
|
+
mode: 'orchestrator',
|
|
237
|
+
pi_session_id: 'root-sess',
|
|
238
|
+
tmux_session: 'crtr',
|
|
239
|
+
window: '@3',
|
|
240
|
+
}));
|
|
241
|
+
const respawn = throwingRespawn();
|
|
242
|
+
const res = handleNewSession('root', 'newsess', 'test-pane', { relaunchRootInPane: respawn.fn });
|
|
243
|
+
assert.equal(res.path, 'reset-root', 'degraded to in-place reset');
|
|
244
|
+
// Old root: FULLY restored by the transaction rollback (active, with its
|
|
245
|
+
// window/session intact), then re-pointed by the resetRoot degrade path.
|
|
246
|
+
const old = getNode('root');
|
|
247
|
+
assert.equal(old?.status, 'active', 'old root back to active');
|
|
248
|
+
assert.equal(old?.window, '@3', 'window restored');
|
|
249
|
+
assert.equal(old?.tmux_session, 'crtr', 'session restored');
|
|
250
|
+
assert.equal(old?.mode, 'base', 'resetRoot re-pointed to base');
|
|
251
|
+
assert.equal(old?.pi_session_id, 'newsess', 'resetRoot rebound the new session id');
|
|
252
|
+
// No partial state: the half-built new node's ROW was rolled back WITH the
|
|
253
|
+
// transaction — no zombie active node, and (unlike the old hand-rolled
|
|
254
|
+
// compensation, which left a leaked `dead` row) no leftover row at all.
|
|
255
|
+
const actives = listNodes({ status: ['active'] }).map((r) => r.node_id);
|
|
256
|
+
assert.deepEqual(actives, ['root'], 'only the old root is active — no zombie');
|
|
257
|
+
assert.equal(listNodes({ status: ['dead'] }).length, 0, 'no dead zombie — new node row rolled back');
|
|
258
|
+
assert.equal(listNodes().length, 1, 'only the old root row exists — the mint was fully undone');
|
|
259
|
+
assert.equal(getFocus(), 'root', 'focus restored to the old root');
|
|
260
|
+
});
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// #7 — markCleanExitDone guard table (termination rule)
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
test('markCleanExitDone: quit on an active/intent-null node marks it done', () => {
|
|
265
|
+
createNode(node('n', { status: 'active', intent: null }));
|
|
266
|
+
assert.equal(markCleanExitDone('n', 'quit'), true);
|
|
267
|
+
assert.equal(getNode('n')?.status, 'done');
|
|
268
|
+
});
|
|
269
|
+
test('markCleanExitDone does NOT clobber an idle-released node', () => {
|
|
270
|
+
createNode(node('n', { status: 'idle', intent: 'idle-release' }));
|
|
271
|
+
assert.equal(markCleanExitDone('n', 'quit'), false);
|
|
272
|
+
assert.equal(getNode('n')?.status, 'idle', 'unchanged');
|
|
273
|
+
assert.equal(getNode('n')?.intent, 'idle-release', 'intent unchanged');
|
|
274
|
+
});
|
|
275
|
+
test('markCleanExitDone does NOT clobber a node mid-refresh-yield', () => {
|
|
276
|
+
createNode(node('n', { status: 'active', intent: 'refresh' }));
|
|
277
|
+
assert.equal(markCleanExitDone('n', 'quit'), false);
|
|
278
|
+
assert.equal(getNode('n')?.status, 'active', 'unchanged');
|
|
279
|
+
assert.equal(getNode('n')?.intent, 'refresh', 'intent unchanged');
|
|
280
|
+
});
|
|
281
|
+
test('markCleanExitDone does NOT re-mark an already-done node', () => {
|
|
282
|
+
createNode(node('n', { status: 'done', intent: null }));
|
|
283
|
+
assert.equal(markCleanExitDone('n', 'quit'), false);
|
|
284
|
+
assert.equal(getNode('n')?.status, 'done', 'unchanged');
|
|
285
|
+
});
|
|
286
|
+
test('markCleanExitDone is a no-op for every non-quit reason', () => {
|
|
287
|
+
for (const reason of ['new', 'reload', 'resume', 'fork']) {
|
|
288
|
+
closeDb();
|
|
289
|
+
rmSync(home, { recursive: true, force: true });
|
|
290
|
+
createNode(node('n', { status: 'active', intent: null }));
|
|
291
|
+
assert.equal(markCleanExitDone('n', reason), false, `${reason} → no-op`);
|
|
292
|
+
assert.equal(getNode('n')?.status, 'active', `${reason} leaves node unchanged`);
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
test('markCleanExitDone is a no-op for an unknown node', () => {
|
|
296
|
+
assert.equal(markCleanExitDone('ghost', 'quit'), false);
|
|
297
|
+
});
|
|
298
|
+
// ---------------------------------------------------------------------------
|
|
299
|
+
// #8 — a parked / quit (done) node is invisible to the daemon's supervised set
|
|
300
|
+
// ---------------------------------------------------------------------------
|
|
301
|
+
test('a done node (parked or cleanly quit) is not in the supervised set', () => {
|
|
302
|
+
// A done node with a live-LOOKING window — the daemon never sees it because
|
|
303
|
+
// it only ever supervises listNodes({status:['active','idle']}).
|
|
304
|
+
createNode(node('parked', { parent: null, status: 'done', tmux_session: 'crtr', window: '@9' }));
|
|
305
|
+
createNode(node('live', { parent: null, status: 'active', tmux_session: 'crtr', window: '@1' }));
|
|
306
|
+
const supervised = listNodes({ status: ['active', 'idle'] }).map((r) => r.node_id);
|
|
307
|
+
assert.ok(!supervised.includes('parked'), 'parked done node excluded from supervision');
|
|
308
|
+
assert.ok(supervised.includes('live'), 'live root still supervised');
|
|
309
|
+
});
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
// #9 — renderForest excludes parked / dead / canceled roots
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
test('renderForest renders only LIVE (active|idle) roots', () => {
|
|
314
|
+
const mk = (id, status) => {
|
|
315
|
+
createNode(node(id, { parent: null, status, name: id, kind: 'general' }));
|
|
316
|
+
};
|
|
317
|
+
mk('liveroot', 'active');
|
|
318
|
+
mk('idleroot', 'idle');
|
|
319
|
+
mk('parkedroot', 'done');
|
|
320
|
+
mk('deadroot', 'dead');
|
|
321
|
+
mk('cancelroot', 'canceled');
|
|
322
|
+
const out = renderForest();
|
|
323
|
+
assert.ok(out.includes('liveroot'), 'active root rendered');
|
|
324
|
+
assert.ok(out.includes('idleroot'), 'idle root rendered');
|
|
325
|
+
assert.ok(!out.includes('parkedroot'), 'parked (done) root hidden');
|
|
326
|
+
assert.ok(!out.includes('deadroot'), 'dead root hidden');
|
|
327
|
+
assert.ok(!out.includes('cancelroot'), 'canceled root hidden');
|
|
328
|
+
});
|
|
@@ -5,6 +5,7 @@ import { mkdtempSync, rmSync, existsSync, writeFileSync } from 'node:fs';
|
|
|
5
5
|
import { tmpdir } from 'node:os';
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { createNode, getNode, subscribe, setStatus, subscriptionsOf, view, } from '../canvas/canvas.js';
|
|
8
|
+
import { openFocusRow, getFocusByNode } from '../canvas/focuses.js';
|
|
8
9
|
import { closeDb } from '../canvas/db.js';
|
|
9
10
|
import { reportsDir, inboxPath } from '../canvas/paths.js';
|
|
10
11
|
import { roadmapPath } from '../runtime/roadmap.js';
|
|
@@ -48,16 +49,16 @@ test('resetRoot empties the root view, reaps descendants, and wipes working stat
|
|
|
48
49
|
writeFileSync(inboxPath('root'), '{"ts":"x","from":"child","tier":"normal","kind":"update","label":"hi"}\n');
|
|
49
50
|
writeFileSync(join(reportsDir('root'), '20260101T000000-update.md'), 'stale report');
|
|
50
51
|
assert.equal(view('root').length, 2, 'precondition: root sees 2 descendants');
|
|
51
|
-
const res = resetRoot('root', 'new-sess');
|
|
52
|
+
const res = resetRoot('root', 'new-sess', '/abs/sessions/new.jsonl');
|
|
52
53
|
assert.equal(res.reset, true);
|
|
53
54
|
assert.deepEqual(res.detached, ['child'], 'root detaches its direct subscription');
|
|
54
55
|
assert.deepEqual(res.reaped.sort(), ['child', 'grand'], 'whole sub-DAG reaped');
|
|
55
56
|
// Graph is empty from the root's view.
|
|
56
57
|
assert.equal(view('root').length, 0, 'root view is empty after reset');
|
|
57
58
|
assert.equal(subscriptionsOf('root').length, 0, 'no outgoing edges remain');
|
|
58
|
-
// Descendants are
|
|
59
|
-
assert.equal(getNode('child')?.status, '
|
|
60
|
-
assert.equal(getNode('grand')?.status, '
|
|
59
|
+
// Descendants are done (clean teardown, not a fault; daemon skips them).
|
|
60
|
+
assert.equal(getNode('child')?.status, 'done');
|
|
61
|
+
assert.equal(getNode('grand')?.status, 'done');
|
|
61
62
|
// Working state wiped.
|
|
62
63
|
assert.equal(existsSync(roadmapPath('root')), false, 'roadmap wiped');
|
|
63
64
|
assert.equal(existsSync(inboxPath('root')), false, 'inbox wiped');
|
|
@@ -68,6 +69,7 @@ test('resetRoot empties the root view, reaps descendants, and wipes working stat
|
|
|
68
69
|
assert.equal(root?.status, 'active');
|
|
69
70
|
assert.equal(root?.intent, null);
|
|
70
71
|
assert.equal(root?.pi_session_id, 'new-sess');
|
|
72
|
+
assert.equal(root?.pi_session_file, '/abs/sessions/new.jsonl', 'session FILE rebound too');
|
|
71
73
|
assert.ok(root?.launch, 'a fresh base launch spec was written');
|
|
72
74
|
});
|
|
73
75
|
test('resetRoot on a non-root only refreshes the session id (no reap)', () => {
|
|
@@ -75,15 +77,32 @@ test('resetRoot on a non-root only refreshes the session id (no reap)', () => {
|
|
|
75
77
|
createNode(node('child', { parent: 'root', pi_session_id: 'old' }));
|
|
76
78
|
subscribe('root', 'child', true);
|
|
77
79
|
subscribe('child', 'root', false); // contrived: ensure child has an outgoing edge
|
|
78
|
-
const res = resetRoot('child', 'fresh');
|
|
80
|
+
const res = resetRoot('child', 'fresh', '/abs/sessions/fresh.jsonl');
|
|
79
81
|
assert.equal(res.reset, false, 'a non-root is not a graph reset');
|
|
80
82
|
assert.deepEqual(res.reaped, []);
|
|
81
83
|
assert.deepEqual(res.detached, []);
|
|
82
84
|
assert.equal(getNode('child')?.pi_session_id, 'fresh', 'session id still refreshed');
|
|
85
|
+
assert.equal(getNode('child')?.pi_session_file, '/abs/sessions/fresh.jsonl', 'session FILE refreshed too');
|
|
83
86
|
assert.equal(getNode('child')?.status, 'active', 'child not reaped');
|
|
84
87
|
// The root that subscribes to the child is untouched.
|
|
85
88
|
assert.equal(getNode('root')?.status, 'active');
|
|
86
89
|
});
|
|
90
|
+
test('Step 7: resetRoot reaps a FOCUSED descendant through tearDownNode (closes its focus row + nulls presence)', () => {
|
|
91
|
+
createNode(node('root', { parent: null, lifecycle: 'resident', mode: 'orchestrator' }));
|
|
92
|
+
createNode(node('desc', { parent: 'root', pane: '%d' }));
|
|
93
|
+
subscribe('root', 'desc', true);
|
|
94
|
+
openFocusRow('fD', '%d', 'Suser', 'desc');
|
|
95
|
+
resetRoot('root', 'new-sess');
|
|
96
|
+
// reapDescendants now routes each descendant through tearDownNode, so a focused
|
|
97
|
+
// descendant's focus row is closed and its LOCATION nulled. Non-vacuous:
|
|
98
|
+
// pre-Step-7 reap used closeWindow and never touched the focuses table, so
|
|
99
|
+
// getFocusByNode('desc') would still return fD.
|
|
100
|
+
assert.equal(getFocusByNode('desc'), null, 'descendant focus row closed by tearDownNode');
|
|
101
|
+
const d = getNode('desc');
|
|
102
|
+
assert.equal(d.status, 'done', 'descendant reaped (done)');
|
|
103
|
+
assert.equal(d.pane ?? null, null, 'descendant pane nulled');
|
|
104
|
+
assert.equal(d.tmux_session ?? null, null, 'descendant session nulled');
|
|
105
|
+
});
|
|
87
106
|
test('resetRoot is a no-op for an unknown node', () => {
|
|
88
107
|
const res = resetRoot('ghost', 'x');
|
|
89
108
|
assert.equal(res.reset, false);
|
|
@@ -96,10 +115,10 @@ test('reaped descendants keep their meta on disk (orphaned, not deleted)', () =>
|
|
|
96
115
|
subscribe('root', 'child', true);
|
|
97
116
|
setStatus('child', 'idle');
|
|
98
117
|
resetRoot('root', 'new');
|
|
99
|
-
// The node record persists (we detach + mark
|
|
118
|
+
// The node record persists (we detach + mark done, we don't delete the node).
|
|
100
119
|
const child = getNode('child');
|
|
101
120
|
assert.ok(child, 'child meta still on disk');
|
|
102
|
-
assert.equal(child?.status, '
|
|
121
|
+
assert.equal(child?.status, 'done');
|
|
103
122
|
// It is just unreachable from the root.
|
|
104
123
|
assert.equal(view('root').length, 0);
|
|
105
124
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/revive.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Covers the session-picker fix: a revive must resume by ABSOLUTE session-file
|
|
4
|
+
// path (cwd-immune) when one was captured, falling back to the bare uuid for
|
|
5
|
+
// older nodes — and the double-revive guard that keeps two pi processes off one
|
|
6
|
+
// session file. The argv selection is unit-tested via the pure `resumeArgs` +
|
|
7
|
+
// `buildPiArgv`; the guard is exercised against a REAL tmux window (gated on
|
|
8
|
+
// tmux availability, like daemon-liveness.test.ts).
|
|
9
|
+
import { test, before, after, beforeEach } from 'node:test';
|
|
10
|
+
import assert from 'node:assert/strict';
|
|
11
|
+
import { mkdtempSync, rmSync } from 'node:fs';
|
|
12
|
+
import { tmpdir } from 'node:os';
|
|
13
|
+
import { join } from 'node:path';
|
|
14
|
+
import { spawnSync } from 'node:child_process';
|
|
15
|
+
import { createNode, getNode } from '../canvas/canvas.js';
|
|
16
|
+
import { closeDb } from '../canvas/db.js';
|
|
17
|
+
import { buildPiArgv } from '../runtime/launch.js';
|
|
18
|
+
import { resumeArgs, reviveNode } from '../runtime/revive.js';
|
|
19
|
+
let home;
|
|
20
|
+
function node(id, over = {}) {
|
|
21
|
+
return {
|
|
22
|
+
node_id: id,
|
|
23
|
+
name: id,
|
|
24
|
+
created: new Date().toISOString(),
|
|
25
|
+
cwd: '/tmp/work',
|
|
26
|
+
kind: 'developer',
|
|
27
|
+
mode: 'base',
|
|
28
|
+
lifecycle: 'terminal',
|
|
29
|
+
status: 'active',
|
|
30
|
+
...over,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function hasTmux() {
|
|
34
|
+
return spawnSync('tmux', ['-V'], { stdio: 'ignore' }).status === 0;
|
|
35
|
+
}
|
|
36
|
+
before(() => {
|
|
37
|
+
home = mkdtempSync(join(tmpdir(), 'crtr-revive-'));
|
|
38
|
+
process.env['CRTR_HOME'] = home;
|
|
39
|
+
});
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
closeDb();
|
|
42
|
+
rmSync(home, { recursive: true, force: true });
|
|
43
|
+
});
|
|
44
|
+
after(() => {
|
|
45
|
+
closeDb();
|
|
46
|
+
rmSync(home, { recursive: true, force: true });
|
|
47
|
+
delete process.env['CRTR_HOME'];
|
|
48
|
+
});
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// resumeArgs — the pure resume-source selection
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
test('resumeArgs prefers the absolute file path and keeps the id as fallback', () => {
|
|
53
|
+
const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: '/abs/sess.jsonl' });
|
|
54
|
+
assert.deepEqual(resumeArgs(m, true), {
|
|
55
|
+
resumeSessionId: 'uuid-123',
|
|
56
|
+
resumeSessionPath: '/abs/sess.jsonl',
|
|
57
|
+
});
|
|
58
|
+
});
|
|
59
|
+
test('resumeArgs falls back to the bare id when no file was captured (older node)', () => {
|
|
60
|
+
const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: null });
|
|
61
|
+
assert.deepEqual(resumeArgs(m, true), {
|
|
62
|
+
resumeSessionId: 'uuid-123',
|
|
63
|
+
resumeSessionPath: undefined,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
test('resumeArgs selects neither source on a no-resume (refresh) revive', () => {
|
|
67
|
+
const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: '/abs/sess.jsonl' });
|
|
68
|
+
assert.deepEqual(resumeArgs(m, false), {});
|
|
69
|
+
});
|
|
70
|
+
// ---------------------------------------------------------------------------
|
|
71
|
+
// buildPiArgv — the `--session` argument the revive builds
|
|
72
|
+
// ---------------------------------------------------------------------------
|
|
73
|
+
test('buildPiArgv resumes by ABSOLUTE path when pi_session_file is set (not the uuid)', () => {
|
|
74
|
+
const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: '/abs/sess.jsonl' });
|
|
75
|
+
const { argv } = buildPiArgv(m, resumeArgs(m, true));
|
|
76
|
+
const i = argv.indexOf('--session');
|
|
77
|
+
assert.ok(i >= 0, 'argv carries --session');
|
|
78
|
+
assert.equal(argv[i + 1], '/abs/sess.jsonl', 'resumes by absolute file path');
|
|
79
|
+
assert.ok(!argv.includes('uuid-123'), 'the bare uuid is NOT used when a path exists');
|
|
80
|
+
});
|
|
81
|
+
test('buildPiArgv falls back to --session <uuid> when no path is stored', () => {
|
|
82
|
+
const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: null });
|
|
83
|
+
const { argv } = buildPiArgv(m, resumeArgs(m, true));
|
|
84
|
+
const i = argv.indexOf('--session');
|
|
85
|
+
assert.ok(i >= 0, 'argv carries --session');
|
|
86
|
+
assert.equal(argv[i + 1], 'uuid-123', 'resumes by bare uuid (older node)');
|
|
87
|
+
});
|
|
88
|
+
test('buildPiArgv passes no --session on a fresh (no-resume) launch', () => {
|
|
89
|
+
const m = node('n', { pi_session_id: 'uuid-123', pi_session_file: '/abs/sess.jsonl' });
|
|
90
|
+
const { argv } = buildPiArgv(m, { prompt: 'go' });
|
|
91
|
+
assert.ok(!argv.includes('--session'), 'a fresh launch never resumes');
|
|
92
|
+
assert.ok(argv.includes('go'), 'the kickoff prompt is the last positional');
|
|
93
|
+
});
|
|
94
|
+
// ---------------------------------------------------------------------------
|
|
95
|
+
// buildPiArgv — the resumed argv replays the persisted launch spec faithfully
|
|
96
|
+
// (kind/mode/lifecycle come back as the node's *current* self).
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
test('a resumed revive replays the persisted LaunchSpec (model, tools, extensions, prompt, env)', () => {
|
|
99
|
+
const m = node('n', {
|
|
100
|
+
kind: 'developer',
|
|
101
|
+
mode: 'orchestrator',
|
|
102
|
+
pi_session_id: 'uuid-9',
|
|
103
|
+
pi_session_file: '/abs/sess.jsonl',
|
|
104
|
+
launch: {
|
|
105
|
+
model: 'anthropic/sonnet',
|
|
106
|
+
tools: ['bash', 'read'],
|
|
107
|
+
extensions: ['/ext/a.js', '/ext/b.js'],
|
|
108
|
+
systemPrompt: 'You are a developer orchestrator.',
|
|
109
|
+
env: { FOO: 'bar' },
|
|
110
|
+
},
|
|
111
|
+
});
|
|
112
|
+
createNode(m); // scaffolds the node dir so the system prompt persists to a file
|
|
113
|
+
const { argv, env } = buildPiArgv(m, resumeArgs(m, true));
|
|
114
|
+
// Persona extensions, in order.
|
|
115
|
+
const ea = argv.indexOf('/ext/a.js');
|
|
116
|
+
const eb = argv.indexOf('/ext/b.js');
|
|
117
|
+
assert.ok(ea >= 0 && eb > ea, 'extensions replayed in order');
|
|
118
|
+
assert.equal(argv[ea - 1], '-e');
|
|
119
|
+
// Model + tools + system prompt + resume target.
|
|
120
|
+
const mi = argv.indexOf('--model');
|
|
121
|
+
assert.equal(argv[mi + 1], 'anthropic/sonnet');
|
|
122
|
+
const ti = argv.indexOf('--tools');
|
|
123
|
+
assert.equal(argv[ti + 1], 'bash,read');
|
|
124
|
+
assert.ok(argv.includes('--append-system-prompt'), 'system prompt replayed');
|
|
125
|
+
const si = argv.indexOf('--session');
|
|
126
|
+
assert.equal(argv[si + 1], '/abs/sess.jsonl', 'still resumes by file path');
|
|
127
|
+
// Env contract: launch-spec env merged over the node identity env.
|
|
128
|
+
assert.equal(env['FOO'], 'bar');
|
|
129
|
+
assert.equal(env['CRTR_NODE_ID'], 'n');
|
|
130
|
+
assert.equal(env['CRTR_KIND'], 'developer');
|
|
131
|
+
assert.equal(env['CRTR_MODE'], 'orchestrator');
|
|
132
|
+
});
|
|
133
|
+
// ---------------------------------------------------------------------------
|
|
134
|
+
// Double-revive guard — reviveNode no-ops when the window is already alive
|
|
135
|
+
// (gated on tmux, which CI may not have; the guard needs a genuinely-live window
|
|
136
|
+
// to short-circuit before any tmux mutation).
|
|
137
|
+
// ---------------------------------------------------------------------------
|
|
138
|
+
/** Hold a real, live tmux window open for the duration of `fn`, then tear down. */
|
|
139
|
+
async function withLiveWindow(tag, fn) {
|
|
140
|
+
const session = `crtr-revivetest-${process.pid}-${tag}`;
|
|
141
|
+
spawnSync('tmux', ['new-session', '-d', '-s', session, '-c', '/tmp', 'sleep 600']);
|
|
142
|
+
try {
|
|
143
|
+
const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
|
|
144
|
+
const window = (r.stdout ?? '').trim().split('\n')[0];
|
|
145
|
+
await fn(session, window);
|
|
146
|
+
}
|
|
147
|
+
finally {
|
|
148
|
+
spawnSync('tmux', ['kill-session', '-t', session], { stdio: 'ignore' });
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
function windowCount(session) {
|
|
152
|
+
const r = spawnSync('tmux', ['list-windows', '-t', session, '-F', '#{window_id}'], { encoding: 'utf8' });
|
|
153
|
+
return (r.stdout ?? '').trim().split('\n').filter((s) => s !== '').length;
|
|
154
|
+
}
|
|
155
|
+
test('reviveNode no-ops when the node pane is alive AND its pi is LIVE (double-revive guard)', { skip: !hasTmux() }, async () => {
|
|
156
|
+
await withLiveWindow('guard', async (session, window) => {
|
|
157
|
+
// pi_pid = this process: a genuinely LIVE pi. The guard now keys on pane-
|
|
158
|
+
// alive AND pi-alive (Step 7), so this models "another path already revived
|
|
159
|
+
// it" — a no-op (re-launching would double-spawn onto the same session file).
|
|
160
|
+
createNode(node('M', {
|
|
161
|
+
tmux_session: session,
|
|
162
|
+
window,
|
|
163
|
+
cycles: 3,
|
|
164
|
+
pi_pid: process.pid,
|
|
165
|
+
pi_session_id: 'uuid-1',
|
|
166
|
+
pi_session_file: '/abs/m.jsonl',
|
|
167
|
+
}));
|
|
168
|
+
const before = windowCount(session);
|
|
169
|
+
const res = reviveNode('M', { resume: true });
|
|
170
|
+
assert.equal(res.window, window, 'returns the existing live window');
|
|
171
|
+
assert.equal(res.session, session, 'returns the existing session');
|
|
172
|
+
assert.equal(res.resumed, false, 'guard path does not re-resume');
|
|
173
|
+
assert.equal(windowCount(session), before, 'no new window opened');
|
|
174
|
+
assert.equal(getNode('M')?.cycles, 3, 'cycle counter NOT bumped (guard returned early)');
|
|
175
|
+
});
|
|
176
|
+
});
|
|
177
|
+
test('reviveNode PROCEEDS when the pane is alive but the pi is DEAD (F3 frozen-pane resume)', { skip: !hasTmux() }, async () => {
|
|
178
|
+
// The Step-7 guard fix: a FROZEN focus pane (remain-on-exit) is pane-alive but
|
|
179
|
+
// pi-DEAD — the resume-into-focus case. The OLD pane-only guard would no-op
|
|
180
|
+
// here (the bug that left a frozen focused-dormant node stuck); the new guard
|
|
181
|
+
// gates on pi liveness too, so reviveNode proceeds (bumps the cycle counter).
|
|
182
|
+
await withLiveWindow('frozen', async (session, window) => {
|
|
183
|
+
createNode(node('M', {
|
|
184
|
+
tmux_session: session,
|
|
185
|
+
window,
|
|
186
|
+
cycles: 3,
|
|
187
|
+
pi_pid: 0x7ffffffe, // implausible/dead pid → a frozen pane with no live pi
|
|
188
|
+
pi_session_id: 'uuid-1',
|
|
189
|
+
pi_session_file: '/abs/m.jsonl',
|
|
190
|
+
}));
|
|
191
|
+
reviveNode('M', { resume: true });
|
|
192
|
+
assert.equal(getNode('M')?.cycles, 4, 'cycle counter BUMPED — the guard did NOT short-circuit a frozen pane');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
// ---------------------------------------------------------------------------
|
|
196
|
+
// Step 5 (§5.3): reviveNode DELEGATES placement to reviveIntoPlacement — a
|
|
197
|
+
// non-focused node targets its home_session, NEVER its (focus-tainted)
|
|
198
|
+
// tmux_session. This is the reviveNode-level bug-kill proof. Gated to run when
|
|
199
|
+
// tmux is ABSENT, so openNodeWindow no-ops (returns null) and no real pi is
|
|
200
|
+
// launched — the placement DECISION (session + LOCATION) is set synchronously
|
|
201
|
+
// regardless of whether a window actually opens, so the assertions are exact.
|
|
202
|
+
// (The WITH-tmux window-placement behaviour is proven in placement-revive.test.ts.)
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
test('reviveNode delegates to home_session for a non-focused node, IGNORING the tainted tmux_session (§5.3)', { skip: hasTmux() }, async () => {
|
|
205
|
+
const back = `crtr-back-${process.pid}`;
|
|
206
|
+
const tainted = `crtr-user-${process.pid}`; // the focus taint that must be ignored
|
|
207
|
+
// A non-focused child: home_session is the backstage; tmux_session was tainted
|
|
208
|
+
// to a user session by a prior focus and never corrected. No focus row exists.
|
|
209
|
+
createNode(node('M', { home_session: back, tmux_session: tainted, window: '@7', pane: null }));
|
|
210
|
+
const res = reviveNode('M', { resume: false });
|
|
211
|
+
assert.equal(res.session, back, 'revive targets home_session, not the tainted user session');
|
|
212
|
+
assert.notEqual(res.session, tainted, 'NEVER the tainted tmux_session');
|
|
213
|
+
const m = getNode('M');
|
|
214
|
+
assert.equal(m.tmux_session, back, 'LOCATION repointed to the backstage (taint overwritten)');
|
|
215
|
+
assert.equal(m.window, null, 'no tmux present → openNodeWindow no-op, window null (decision still recorded)');
|
|
216
|
+
assert.equal(m.cycles, 1, 'a real (non-guard) revive bumped the cycle counter');
|
|
217
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|