@crouton-kit/crouter 0.3.18 → 0.3.19
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.js +6 -0
- package/dist/builtin-memory/crouter-development/marketplaces.md +164 -0
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +58 -0
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +60 -0
- package/dist/builtin-memory/crouter-development/personas.md +117 -0
- package/dist/builtin-memory/crouter-development/plugins.md +163 -0
- package/dist/builtin-memory/design.md +60 -0
- package/dist/builtin-memory/development.md +116 -0
- package/dist/builtin-memory/planning.md +66 -0
- package/dist/builtin-memory/spec.md +99 -0
- package/dist/builtin-personas/design/orchestrator.md +1 -1
- package/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/lifecycle/resident.md +1 -1
- package/dist/builtin-personas/lifecycle/terminal.md +5 -2
- package/dist/builtin-personas/orchestration-kernel.md +15 -24
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/orchestrator.md +1 -1
- package/dist/builtin-personas/waiting.md +8 -0
- package/dist/builtin-skills/skills/crouter-development/personas/orchestrator-prompt/SKILL.md +1 -1
- package/dist/builtin-skills/skills/crouter-development/plugins/SKILL.md +2 -2
- package/dist/builtin-views/_lib/states.mjs +161 -0
- package/dist/builtin-views/canvas/client.mjs +303 -0
- package/dist/builtin-views/canvas/view.mjs +576 -0
- package/dist/builtin-views/git-pr/client.mjs +440 -0
- package/dist/builtin-views/git-pr/view.mjs +675 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +177 -0
- package/dist/builtin-views/inbox/sources/gmail.mjs +965 -0
- package/dist/builtin-views/inbox/sources/linkedin.mjs +427 -0
- package/dist/builtin-views/inbox/view.mjs +889 -0
- package/dist/builtin-views/linkedin/client.mjs +610 -0
- package/dist/builtin-views/linkedin/view.mjs +1171 -0
- package/dist/clients/attach/attach-cmd.d.ts +2 -0
- package/dist/clients/attach/attach-cmd.js +354 -0
- package/dist/clients/attach/chat-view.d.ts +77 -0
- package/dist/clients/attach/chat-view.js +450 -0
- package/dist/clients/attach/clipboard-image.d.ts +16 -0
- package/dist/clients/attach/clipboard-image.js +113 -0
- package/dist/clients/attach/config-load.d.ts +31 -0
- package/dist/clients/attach/config-load.js +113 -0
- package/dist/clients/attach/extension-dialogs.d.ts +29 -0
- package/dist/clients/attach/extension-dialogs.js +101 -0
- package/dist/clients/attach/input-controller.d.ts +54 -0
- package/dist/clients/attach/input-controller.js +204 -0
- package/dist/clients/attach/slash-commands.d.ts +36 -0
- package/dist/clients/attach/slash-commands.js +200 -0
- package/dist/clients/attach/view-socket.d.ts +48 -0
- package/dist/clients/attach/view-socket.js +126 -0
- package/dist/commands/attention.js +3 -3
- package/dist/commands/canvas-prune.js +1 -1
- package/dist/commands/daemon.js +3 -2
- package/dist/commands/human/prompts.js +1 -1
- package/dist/commands/human/queue.js +43 -8
- package/dist/commands/human/shared.d.ts +2 -0
- package/dist/commands/memory/find.d.ts +1 -0
- package/dist/commands/memory/find.js +180 -0
- package/dist/commands/memory/lint.d.ts +1 -0
- package/dist/commands/memory/lint.js +140 -0
- package/dist/commands/memory/list.d.ts +1 -0
- package/dist/commands/memory/list.js +79 -0
- package/dist/commands/memory/read.js +103 -0
- package/dist/commands/memory/shared.d.ts +30 -0
- package/dist/commands/memory/shared.js +122 -0
- package/dist/commands/memory/write.d.ts +1 -0
- package/dist/commands/memory/write.js +85 -0
- package/dist/commands/memory.d.ts +2 -0
- package/dist/commands/memory.js +27 -0
- package/dist/commands/node.d.ts +3 -2
- package/dist/commands/node.js +607 -61
- package/dist/commands/pkg/market-manage.js +1 -1
- package/dist/commands/push.js +6 -6
- package/dist/commands/revive.js +1 -1
- package/dist/commands/skill/author.js +1 -1
- package/dist/commands/skill/shared.d.ts +1 -8
- package/dist/commands/skill/shared.js +2 -55
- package/dist/commands/skill.js +9 -14
- package/dist/commands/sys/doctor.js +1 -1
- package/dist/commands/sys/update.js +1 -1
- package/dist/commands/view-cycle.d.ts +2 -0
- package/dist/commands/view-cycle.js +125 -0
- package/dist/commands/view-list.d.ts +2 -0
- package/dist/commands/view-list.js +66 -0
- package/dist/commands/view-new.d.ts +2 -0
- package/dist/commands/view-new.js +70 -0
- package/dist/commands/view-pick.d.ts +2 -0
- package/dist/commands/view-pick.js +119 -0
- package/dist/commands/view-run.d.ts +2 -0
- package/dist/commands/view-run.js +191 -0
- package/dist/commands/view.d.ts +2 -0
- package/dist/commands/view.js +29 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +677 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.d.ts +1 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +166 -0
- package/dist/core/__tests__/cascade-close.test.js +12 -2
- package/dist/core/__tests__/child-death-wake.test.d.ts +1 -0
- package/dist/core/__tests__/child-death-wake.test.js +245 -0
- package/dist/core/__tests__/context-intro.test.js +76 -62
- package/dist/core/__tests__/daemon-boot.test.js +14 -5
- package/dist/core/__tests__/daemon-liveness.test.js +34 -9
- package/dist/core/__tests__/detach-focus.test.d.ts +1 -0
- package/dist/core/__tests__/detach-focus.test.js +206 -0
- package/dist/core/__tests__/draw-style.test.d.ts +1 -0
- package/dist/core/__tests__/draw-style.test.js +258 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.d.ts +2 -0
- package/dist/core/__tests__/fixtures/c3-custom-provider-ext.js +18 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +138 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +614 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +1 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +7 -1
- package/dist/core/__tests__/frame-decoder-perf.test.d.ts +1 -0
- package/dist/core/__tests__/frame-decoder-perf.test.js +198 -0
- package/dist/core/__tests__/helpers/harness.d.ts +9 -0
- package/dist/core/__tests__/helpers/harness.js +111 -1
- package/dist/core/__tests__/home-session.test.js +41 -8
- package/dist/core/__tests__/human-new-window-regression.test.d.ts +1 -0
- package/dist/core/__tests__/human-new-window-regression.test.js +101 -0
- package/dist/core/__tests__/kickoff.test.js +37 -3
- package/dist/core/__tests__/live-mutation.test.js +50 -33
- package/dist/core/__tests__/memory.test.js +23 -115
- package/dist/core/__tests__/placement-focus.test.js +5 -0
- package/dist/core/__tests__/placement-teardown.test.js +54 -11
- package/dist/core/__tests__/relaunch.test.js +4 -3
- package/dist/core/__tests__/review-render-pane-regression.test.d.ts +1 -0
- package/dist/core/__tests__/review-render-pane-regression.test.js +133 -0
- package/dist/core/__tests__/spawn-root.test.js +10 -0
- package/dist/core/__tests__/spike-harness.test.js +1 -0
- package/dist/core/__tests__/wake-bearings.test.d.ts +1 -0
- package/dist/core/__tests__/wake-bearings.test.js +156 -0
- package/dist/core/__tests__/wake-origin.test.d.ts +1 -0
- package/dist/core/__tests__/wake-origin.test.js +110 -0
- package/dist/core/bootstrap.js +1 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +24 -2
- package/dist/core/canvas/browse/model.d.ts +38 -2
- package/dist/core/canvas/browse/model.js +134 -10
- package/dist/core/canvas/browse/render.d.ts +6 -12
- package/dist/core/canvas/browse/render.js +72 -104
- package/dist/core/canvas/canvas.js +15 -22
- package/dist/core/canvas/db.js +46 -0
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/pid.d.ts +4 -0
- package/dist/core/canvas/pid.js +23 -0
- package/dist/core/canvas/render.d.ts +11 -2
- package/dist/core/canvas/render.js +69 -0
- package/dist/core/canvas/types.d.ts +85 -3
- package/dist/core/canvas/wakeups.d.ts +76 -0
- package/dist/core/canvas/wakeups.js +185 -0
- package/dist/core/config.js +4 -1
- package/dist/core/frontmatter.js +37 -124
- package/dist/core/memory-resolver.d.ts +49 -0
- package/dist/core/memory-resolver.js +141 -0
- package/dist/core/personas/loader.d.ts +7 -0
- package/dist/core/personas/loader.js +36 -5
- package/dist/core/personas/resolve.js +13 -3
- package/dist/core/predicate.d.ts +63 -0
- package/dist/core/predicate.js +189 -0
- package/dist/core/resolver.js +26 -5
- package/dist/core/runtime/bearings.d.ts +53 -12
- package/dist/core/runtime/bearings.js +132 -59
- package/dist/core/runtime/broker-cli.d.ts +1 -0
- package/dist/core/runtime/broker-cli.js +46 -0
- package/dist/core/runtime/broker-protocol.d.ts +332 -0
- package/dist/core/runtime/broker-protocol.js +153 -0
- package/dist/core/runtime/broker-sdk.d.ts +48 -0
- package/dist/core/runtime/broker-sdk.js +72 -0
- package/dist/core/runtime/broker.d.ts +55 -0
- package/dist/core/runtime/broker.js +1128 -0
- package/dist/core/runtime/close.js +35 -6
- package/dist/core/runtime/host.d.ts +53 -0
- package/dist/core/runtime/host.js +186 -0
- package/dist/core/runtime/kickoff.d.ts +2 -1
- package/dist/core/runtime/kickoff.js +91 -5
- package/dist/core/runtime/launch.d.ts +45 -2
- package/dist/core/runtime/launch.js +65 -2
- package/dist/core/runtime/lifecycle.js +23 -6
- package/dist/core/runtime/memory.d.ts +2 -42
- package/dist/core/runtime/memory.js +11 -162
- package/dist/core/runtime/nodes.d.ts +33 -0
- package/dist/core/runtime/nodes.js +59 -1
- package/dist/core/runtime/persona.js +21 -11
- package/dist/core/runtime/pi-vendored.d.ts +18 -0
- package/dist/core/runtime/pi-vendored.js +49 -0
- package/dist/core/runtime/placement.d.ts +32 -14
- package/dist/core/runtime/placement.js +206 -52
- package/dist/core/runtime/promote.d.ts +0 -6
- package/dist/core/runtime/promote.js +1 -12
- package/dist/core/runtime/{demote.d.ts → recycle.d.ts} +5 -5
- package/dist/core/runtime/{demote.js → recycle.js} +27 -11
- package/dist/core/runtime/reset.js +8 -6
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +19 -0
- package/dist/core/runtime/spawn.js +75 -22
- package/dist/core/runtime/stop-guard.d.ts +1 -1
- package/dist/core/runtime/stop-guard.js +6 -1
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +1 -1
- package/dist/core/runtime/tmux.d.ts +28 -0
- package/dist/core/runtime/tmux.js +80 -6
- package/dist/core/scope.d.ts +11 -0
- package/dist/core/scope.js +39 -0
- package/dist/core/spawn.d.ts +5 -0
- package/dist/core/spawn.js +20 -2
- package/dist/core/substrate/gate.d.ts +13 -0
- package/dist/core/substrate/gate.js +21 -0
- package/dist/core/substrate/index.d.ts +7 -0
- package/dist/core/substrate/index.js +18 -0
- package/dist/core/substrate/on-read.d.ts +14 -0
- package/dist/core/substrate/on-read.js +292 -0
- package/dist/core/substrate/render.d.ts +25 -0
- package/dist/core/substrate/render.js +256 -0
- package/dist/core/substrate/schema.d.ts +76 -0
- package/dist/core/substrate/schema.js +124 -0
- package/dist/core/substrate/session-cache.d.ts +30 -0
- package/dist/core/substrate/session-cache.js +77 -0
- package/dist/core/substrate/subject.d.ts +41 -0
- package/dist/core/substrate/subject.js +54 -0
- package/dist/core/tui/contract.d.ts +83 -0
- package/dist/core/tui/contract.js +8 -0
- package/dist/core/tui/draw.d.ts +96 -0
- package/dist/core/tui/draw.js +339 -0
- package/dist/core/tui/host.d.ts +29 -0
- package/dist/core/tui/host.js +379 -0
- package/dist/core/tui/loader.d.ts +16 -0
- package/dist/core/tui/loader.js +94 -0
- package/dist/core/{canvas/browse → tui}/terminal.js +7 -6
- package/dist/core/wake.d.ts +86 -0
- package/dist/core/wake.js +308 -0
- package/dist/daemon/crtrd.d.ts +29 -4
- package/dist/daemon/crtrd.js +662 -46
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-context-intro.test.js +171 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +19 -12
- package/dist/pi-extensions/canvas-commands.d.ts +3 -0
- package/dist/pi-extensions/canvas-commands.js +10 -0
- package/dist/pi-extensions/canvas-context-intro.d.ts +17 -0
- package/dist/pi-extensions/canvas-context-intro.js +55 -15
- package/dist/pi-extensions/canvas-doc-substrate.d.ts +44 -0
- package/dist/pi-extensions/canvas-doc-substrate.js +112 -0
- package/dist/pi-extensions/canvas-nav.d.ts +3 -0
- package/dist/pi-extensions/canvas-nav.js +87 -23
- package/dist/pi-extensions/canvas-stophook.js +17 -8
- package/dist/pi-extensions/canvas-view.d.ts +21 -0
- package/dist/pi-extensions/canvas-view.js +75 -0
- package/dist/prompts/skill.js +19 -26
- package/dist/prompts/view.d.ts +7 -0
- package/dist/prompts/view.js +101 -0
- package/dist/types.d.ts +4 -0
- package/dist/types.js +1 -0
- package/package.json +9 -4
- package/dist/commands/skill/find.d.ts +0 -4
- package/dist/commands/skill/find.js +0 -257
- package/dist/commands/skill/read.js +0 -91
- /package/dist/commands/{skill → memory}/read.d.ts +0 -0
- /package/dist/core/{canvas/browse → tui}/terminal.d.ts +0 -0
|
@@ -1,28 +1,25 @@
|
|
|
1
|
-
// Tests for the <crtr-context> bearings preamble
|
|
2
|
-
// 1.
|
|
3
|
-
//
|
|
4
|
-
//
|
|
5
|
-
//
|
|
6
|
-
//
|
|
7
|
-
// live index POINTER LINES only (the how-to boilerplate is dropped — it
|
|
8
|
-
// lives once in the kernel); promotion delivers that same orchestrator
|
|
1
|
+
// Tests for the <crtr-context> bearings preamble:
|
|
2
|
+
// 1. Worker and orchestrator bearings carry the `## References` block
|
|
3
|
+
// (substrate reference docs + node-local docs as `###` sub-sections) in
|
|
4
|
+
// place of the removed `<memory>` block — no `label · dir` stanzas, no
|
|
5
|
+
// `(empty)` markers, no MEMORY.md index inlining. Orchestrators add the
|
|
6
|
+
// across-cycles framing; promotion delivers that same orchestrator
|
|
9
7
|
// context-dir framing to a node that spawned base.
|
|
10
|
-
//
|
|
8
|
+
// 2. canvas-context-intro injects the block as its own session message at
|
|
11
9
|
// session_start (before the first prompt), idempotent across resumes.
|
|
12
10
|
//
|
|
13
11
|
// Run: node --import tsx/esm --test src/core/__tests__/context-intro.test.ts
|
|
14
12
|
import { test, before, beforeEach, after } from 'node:test';
|
|
15
13
|
import assert from 'node:assert/strict';
|
|
16
|
-
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
14
|
+
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
|
|
17
15
|
import { tmpdir } from 'node:os';
|
|
18
16
|
import { join } from 'node:path';
|
|
19
|
-
import { existsSync } from 'node:fs';
|
|
20
17
|
import { closeDb } from '../canvas/db.js';
|
|
21
18
|
import { contextDir } from '../canvas/paths.js';
|
|
22
19
|
import { spawnNode } from '../runtime/nodes.js';
|
|
23
20
|
import { promote } from '../runtime/promote.js';
|
|
24
21
|
import { personaDrift } from '../runtime/persona.js';
|
|
25
|
-
import {
|
|
22
|
+
import { memoryDir } from '../runtime/memory.js';
|
|
26
23
|
import registerCanvasContextIntro, { buildContextIntro, renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
|
|
27
24
|
let home;
|
|
28
25
|
before(() => {
|
|
@@ -39,69 +36,80 @@ after(() => {
|
|
|
39
36
|
delete process.env['CRTR_HOME'];
|
|
40
37
|
delete process.env['CRTR_NODE_ID'];
|
|
41
38
|
});
|
|
42
|
-
test('
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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', () => {
|
|
39
|
+
test('worker bearings: base framing + ## References block (no <memory>, no label·dir stanzas), NO across-cycles framing', () => {
|
|
40
|
+
// Bug-regression: review finding M1 — buildContextBearings was changed from
|
|
41
|
+
// buildMemoryBlock (<memory> + label·dir per-store stanzas) to
|
|
42
|
+
// renderReferencesBlock (## References + ### <name> sub-sections). This test
|
|
43
|
+
// locks in the new contract.
|
|
60
44
|
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
45
|
+
// Seed a node-local substrate doc so the ## References block is non-empty.
|
|
46
|
+
const dir = memoryDir(meta.node_id);
|
|
47
|
+
mkdirSync(dir, { recursive: true });
|
|
48
|
+
writeFileSync(join(dir, 'test-ref.md'), '---\nkind: reference\nwhen: when testing\nwhy: regression fixture\nsystem-prompt-visibility: preview\n---\nTest body.\n');
|
|
61
49
|
const block = buildContextIntro(meta.node_id);
|
|
62
50
|
assert.match(block, new RegExp(`<crtr-context dir="${contextDir(meta.node_id)}">`));
|
|
63
51
|
assert.match(block, /shared document store, not a task tracker/, 'base = shared docs, not tasks');
|
|
64
|
-
|
|
65
|
-
assert.doesNotMatch(block, /<memory>/, 'no memory block
|
|
52
|
+
// New contract: NO <memory> block — replaced by ## References.
|
|
53
|
+
assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
|
|
54
|
+
// New contract: NO label·dir stanza headers (the removed per-store format).
|
|
55
|
+
assert.doesNotMatch(block, /user-global · /, 'no user-global label·dir stanza');
|
|
56
|
+
assert.doesNotMatch(block, /node-local · /, 'no node-local label·dir stanza');
|
|
57
|
+
// New contract: NO (empty) placeholder (the removed empty-store marker).
|
|
58
|
+
assert.doesNotMatch(block, /\(empty\)/, 'no (empty) placeholder');
|
|
59
|
+
// Reference content renders as ## References + ### sub-sections — no other shape.
|
|
60
|
+
assert.match(block, /## References\n\n###/, '## References followed by ### sub-sections');
|
|
61
|
+
// A terminal worker must NOT carry the orchestrator across-cycles framing.
|
|
66
62
|
assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
|
|
67
63
|
assert.match(block, /<\/crtr-context>/);
|
|
68
64
|
});
|
|
69
|
-
test('orchestrator bearings: across-cycles framing +
|
|
65
|
+
test('orchestrator bearings: across-cycles framing + node-local substrate docs ride into ## References; the MEMORY.md index never renders', () => {
|
|
66
|
+
// Bug-regression: review finding M1 — buildContextBearings replaced the old
|
|
67
|
+
// <memory> pointer-line block with ## References. Node-local substrate docs
|
|
68
|
+
// render as ### sub-sections at their rung; the old index file never surfaces.
|
|
70
69
|
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
70
|
+
promote(meta.node_id); // flip to orchestrator mode — the across-cycles gate
|
|
71
|
+
const dir = memoryDir(meta.node_id);
|
|
72
|
+
mkdirSync(dir, { recursive: true });
|
|
73
|
+
// Write a MEMORY.md file to verify it is never surfaced in the block.
|
|
74
|
+
const legacyIndexPath = join(dir, 'MEMORY.md');
|
|
75
|
+
writeFileSync(legacyIndexPath, '# 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');
|
|
76
|
+
// A node-local substrate doc DOES ride into ## References at its rung.
|
|
77
|
+
writeFileSync(join(dir, 'flaky-build.md'), '---\nkind: reference\nwhen: when the build flakes\nwhy: first run fails\nsystem-prompt-visibility: preview\n---\nFirst run always fails; rerun once.\n');
|
|
75
78
|
const block = buildContextIntro(meta.node_id);
|
|
76
79
|
assert.match(block, /shared document store, not a task tracker/, 'still carries the base framing');
|
|
77
80
|
assert.match(block, /refresh cycles/, 'orchestrator gets the across-cycles framing');
|
|
78
|
-
assert.
|
|
79
|
-
|
|
80
|
-
assert.
|
|
81
|
-
assert.match(block,
|
|
82
|
-
//
|
|
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');
|
|
81
|
+
assert.doesNotMatch(block, /<memory>/, 'no <memory> block');
|
|
82
|
+
assert.match(block, /## References\n/, 'references block present');
|
|
83
|
+
assert.match(block, /### flaky-build\n/, 'node-local doc gets its ### sub-section');
|
|
84
|
+
assert.match(block, /when the build flakes, read this reference\. first run fails\./, 'preview rung renders the routing line');
|
|
85
|
+
// The index file itself never renders: no header comment, no pointer line, no path.
|
|
86
86
|
assert.ok(!block.includes('# memory index'), 'the index header comment is NOT inlined');
|
|
87
|
+
assert.ok(!block.includes('- [Flaky build](flaky-build.md)'), 'index pointer lines are NOT inlined');
|
|
88
|
+
assert.ok(!block.includes(legacyIndexPath), 'no absolute index (MEMORY.md) path');
|
|
89
|
+
assert.ok(!block.includes('node-local · '), 'no label·dir stanza header');
|
|
87
90
|
assert.match(block, /<\/crtr-context>/);
|
|
88
91
|
});
|
|
89
|
-
test('orchestrator bearings: a
|
|
92
|
+
test('orchestrator bearings: no per-store stanzas or (empty) markers; a rung-none node-local doc still surfaces as a ### title stub', () => {
|
|
93
|
+
// Bug-regression: review findings M1 + M6 — the three-store `label · dir`
|
|
94
|
+
// stanzas and (empty) markers are gone, and node-local docs are NOT filtered
|
|
95
|
+
// on rung: a migrated node-local reference defaults
|
|
96
|
+
// system-prompt-visibility: none and must still ride into ## References as
|
|
97
|
+
// its bare title (never its body).
|
|
90
98
|
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
91
|
-
promote(meta.node_id); //
|
|
99
|
+
promote(meta.node_id); // flip to orchestrator mode
|
|
100
|
+
const dir = memoryDir(meta.node_id);
|
|
101
|
+
mkdirSync(dir, { recursive: true });
|
|
102
|
+
writeFileSync(join(dir, 'rung-none-fact.md'), '---\nkind: reference\n---\nbody that must not render at the none rung\n');
|
|
92
103
|
const block = buildContextIntro(meta.node_id);
|
|
93
|
-
//
|
|
94
|
-
assert.ok(block.includes(`
|
|
95
|
-
assert.ok(block.includes(
|
|
96
|
-
assert.ok(block.includes(
|
|
97
|
-
|
|
98
|
-
assert.
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
assert.ok(!block.includes(
|
|
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');
|
|
104
|
+
// No per-store stanza headers, no (empty) markers, no index paths.
|
|
105
|
+
assert.ok(!block.includes(`node-local · ${memoryDir(meta.node_id)}`), 'no node-local stanza header');
|
|
106
|
+
assert.ok(!block.includes('user-global · '), 'no user-global stanza header');
|
|
107
|
+
assert.ok(!block.includes('project · '), 'no project stanza header');
|
|
108
|
+
assert.doesNotMatch(block, /\(empty\)/, 'no (empty) markers');
|
|
109
|
+
assert.ok(!block.includes('MEMORY.md'), 'no MEMORY.md path in the block');
|
|
110
|
+
// M6: rung-none node-local doc surfaces as its title stub only.
|
|
111
|
+
assert.match(block, /### rung-none-fact\s*\n/, 'rung-none node-local doc surfaces as its title stub');
|
|
112
|
+
assert.ok(!block.includes('body that must not render'), 'none rung renders the title only, not the body');
|
|
105
113
|
});
|
|
106
114
|
test('promotion guidance delivers the orchestrator context-dir framing', () => {
|
|
107
115
|
// A node spawns as a base worker (no orchestrator bearings). On promotion the
|
|
@@ -145,12 +153,18 @@ test('session_start injects the block as the first message of an empty session',
|
|
|
145
153
|
test('session_start is idempotent across resume (skips if already in history)', () => {
|
|
146
154
|
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
147
155
|
process.env['CRTR_NODE_ID'] = meta.node_id;
|
|
148
|
-
// Restored session already carries
|
|
156
|
+
// Restored session already carries OUR block (which names this node id) → no
|
|
157
|
+
// duplicate injection. The content matters: the guard is fork-aware and only
|
|
158
|
+
// skips when the present bearings name THIS node (a fork inherits the
|
|
159
|
+
// SOURCE's bearings, which name a different node — see canvas-context-intro
|
|
160
|
+
// tests), so a realistic resume entry must carry the real block content.
|
|
149
161
|
const pi = makeFakePi();
|
|
150
162
|
registerCanvasContextIntro(pi);
|
|
151
|
-
const entries = [
|
|
163
|
+
const entries = [
|
|
164
|
+
{ type: 'custom_message', customType: CONTEXT_INTRO_CUSTOM_TYPE, content: buildContextIntro(meta.node_id) },
|
|
165
|
+
];
|
|
152
166
|
pi.handler({ reason: 'resume' }, fakeCtx(entries));
|
|
153
|
-
assert.equal(pi.sent.length, 0, 'block already in history → skip');
|
|
167
|
+
assert.equal(pi.sent.length, 0, 'our own block already in history → skip');
|
|
154
168
|
});
|
|
155
169
|
test('session_start is inert when CRTR_NODE_ID is absent', () => {
|
|
156
170
|
delete process.env['CRTR_NODE_ID'];
|
|
@@ -64,10 +64,14 @@ test('never-booted node is marked dead and surfaced to the parent as urgent', as
|
|
|
64
64
|
assert.equal(inbox[0].from, 'C');
|
|
65
65
|
assert.match(inbox[0].label, /Spawn failed/);
|
|
66
66
|
});
|
|
67
|
-
// A node that HAD booted (pi_session_id set) then
|
|
68
|
-
// crash, not a boot failure
|
|
69
|
-
//
|
|
70
|
-
|
|
67
|
+
// A node that HAD booted (pi_session_id set) then died mid-generation is a
|
|
68
|
+
// genuine crash, not a boot failure. It is marked dead AND wakes the inbox-
|
|
69
|
+
// waiting parent — but with a "child died" notice (surfaceChildDeath), NOT a
|
|
70
|
+
// "Spawn failed" boot-failure pointer (surfaceBootFailure). The parent-wake
|
|
71
|
+
// itself was added by main's b5abf6e (parent-wake on child death) and is locked
|
|
72
|
+
// in by child-death-wake.test.ts; this test preserves the discriminating intent
|
|
73
|
+
// — a crash is never surfaced as a false spawn-failure alarm.
|
|
74
|
+
test('a crash after boot wakes the parent with a child-death notice, not a boot-failure push', async () => {
|
|
71
75
|
createNode(node('P2', { kind: 'developer', lifecycle: 'resident' }));
|
|
72
76
|
createNode(node('C2', {
|
|
73
77
|
parent: 'P2',
|
|
@@ -85,7 +89,12 @@ test('a crash after boot is marked dead without a boot-failure push', async () =
|
|
|
85
89
|
markBusy('C2');
|
|
86
90
|
await superviseTick();
|
|
87
91
|
assert.equal(getNode('C2').status, 'dead', 'crashed child is dead');
|
|
88
|
-
|
|
92
|
+
const inbox = readInboxSince('P2');
|
|
93
|
+
assert.equal(inbox.length, 1, 'a genuine mid-run crash wakes the inbox-waiting parent (D-1 parent-wake)');
|
|
94
|
+
assert.equal(inbox[0].tier, 'urgent', 'mid-run crash is delivered as urgent');
|
|
95
|
+
assert.equal(inbox[0].from, 'C2');
|
|
96
|
+
assert.match(inbox[0].label, /died/i, 'the wake is a child-death notice (surfaceChildDeath), not silence');
|
|
97
|
+
assert.doesNotMatch(inbox[0].label, /Spawn failed/, 'a crash is NOT surfaced as a false boot-failure alarm');
|
|
89
98
|
clearBusy('C2');
|
|
90
99
|
});
|
|
91
100
|
// A still-booting node whose window is alive must be left untouched — boot is
|
|
@@ -3,7 +3,7 @@ import assert from 'node:assert/strict';
|
|
|
3
3
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
4
4
|
import { tmpdir } from 'node:os';
|
|
5
5
|
import { join } from 'node:path';
|
|
6
|
-
import { spawnSync } from 'node:child_process';
|
|
6
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
7
7
|
import { createNode, getNode, subscribe } from '../canvas/canvas.js';
|
|
8
8
|
import { closeDb } from '../canvas/db.js';
|
|
9
9
|
import { appendInbox } from '../feed/inbox.js';
|
|
@@ -32,6 +32,17 @@ function deadPid() {
|
|
|
32
32
|
// spawnSync has reaped it by the time it returns; pid may be on r.pid.
|
|
33
33
|
return r.pid ?? 0x7ffffffe; // fall back to an implausibly-high pid
|
|
34
34
|
}
|
|
35
|
+
/** A pid that is ALIVE for the test's duration but expendable. Pane-gone
|
|
36
|
+
* fixtures must NEVER use process.pid for an alive pi: the gone-pane zombie
|
|
37
|
+
* sweep (b92082e) SIGKILLs a pane-gone node's live pid, which took the whole
|
|
38
|
+
* test runner down with it. The daemon (or the after hook) reaps these. */
|
|
39
|
+
const livePids = [];
|
|
40
|
+
function disposableLivePid() {
|
|
41
|
+
const child = spawn('sleep', ['600'], { stdio: 'ignore', detached: true });
|
|
42
|
+
child.unref();
|
|
43
|
+
livePids.push(child.pid);
|
|
44
|
+
return child.pid;
|
|
45
|
+
}
|
|
35
46
|
before(() => {
|
|
36
47
|
home = mkdtempSync(join(tmpdir(), 'crtr-daemon-liveness-'));
|
|
37
48
|
process.env['CRTR_HOME'] = home;
|
|
@@ -44,6 +55,14 @@ after(() => {
|
|
|
44
55
|
closeDb();
|
|
45
56
|
rmSync(home, { recursive: true, force: true });
|
|
46
57
|
delete process.env['CRTR_HOME'];
|
|
58
|
+
for (const pid of livePids) {
|
|
59
|
+
try {
|
|
60
|
+
process.kill(pid, 'SIGKILL');
|
|
61
|
+
}
|
|
62
|
+
catch {
|
|
63
|
+
/* already reaped by the daemon's zombie sweep — fine */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
47
66
|
});
|
|
48
67
|
// ---------------------------------------------------------------------------
|
|
49
68
|
// livenessVerdict — the pure grace-window decision
|
|
@@ -157,7 +176,7 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
|
|
|
157
176
|
pane: dead,
|
|
158
177
|
tmux_session: session,
|
|
159
178
|
window,
|
|
160
|
-
pi_pid:
|
|
179
|
+
pi_pid: disposableLivePid(), // alive zombie — the gone-pane sweep SIGKILLs it
|
|
161
180
|
pi_session_id: 'booted',
|
|
162
181
|
intent: null,
|
|
163
182
|
}));
|
|
@@ -172,7 +191,7 @@ test('pane GONE while its old window is still alive → the gone-branch fires (c
|
|
|
172
191
|
// unconditional 'dead'. It routes on what the node was DOING at pane-kill time:
|
|
173
192
|
// • mid-generation (busy marker PRESENT) → crash ('dead')
|
|
174
193
|
// • finished its turn, awaiting nothing live → finalize ('done')
|
|
175
|
-
// • finished its turn, still awaiting a LIVE child →
|
|
194
|
+
// • finished its turn, still awaiting a LIVE child → release ('idle' + idle-release), revivable
|
|
176
195
|
// (The mid-generation → dead leg is covered by node 'G' above.)
|
|
177
196
|
// ---------------------------------------------------------------------------
|
|
178
197
|
test('pane gone + booted + busy ABSENT + no live subscription → finalize (done): the pane was closed to dismiss a finished node', { skip: !hasTmux() }, async () => {
|
|
@@ -187,7 +206,7 @@ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done
|
|
|
187
206
|
pane: dead,
|
|
188
207
|
tmux_session: session,
|
|
189
208
|
window,
|
|
190
|
-
pi_pid:
|
|
209
|
+
pi_pid: disposableLivePid(), // alive zombie — swept before finalize routing
|
|
191
210
|
pi_session_id: 'booted',
|
|
192
211
|
intent: null,
|
|
193
212
|
}));
|
|
@@ -196,7 +215,7 @@ test('pane gone + booted + busy ABSENT + no live subscription → finalize (done
|
|
|
196
215
|
assert.equal(getNode('FIN').status, 'done', 'a finished, unsubscribed node whose pane was closed finalizes to done');
|
|
197
216
|
});
|
|
198
217
|
});
|
|
199
|
-
test('pane gone + booted + busy ABSENT but AWAITING a LIVE child →
|
|
218
|
+
test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → idle-release (revivable, NOT dead)', { skip: !hasTmux() }, async () => {
|
|
200
219
|
await withLivePane('fin2', async (session, window) => {
|
|
201
220
|
const sp = spawnSync('tmux', ['split-window', '-d', '-P', '-F', '#{pane_id}', '-t', `${session}:${window}`, 'sleep 600'], { encoding: 'utf8' });
|
|
202
221
|
const dead = (sp.stdout ?? '').trim();
|
|
@@ -207,15 +226,21 @@ test('pane gone + booted + busy ABSENT but AWAITING a LIVE child → crash (dead
|
|
|
207
226
|
pane: dead,
|
|
208
227
|
tmux_session: session,
|
|
209
228
|
window,
|
|
210
|
-
pi_pid:
|
|
229
|
+
pi_pid: deadPid(), // the user closed the pane → pi is dead
|
|
211
230
|
pi_session_id: 'booted',
|
|
212
231
|
intent: null,
|
|
213
232
|
}));
|
|
214
233
|
subscribe('PARENT', 'CHILD', true); // active subscription to a LIVE node
|
|
215
|
-
//
|
|
216
|
-
//
|
|
234
|
+
// REGRESSION: closing the pane of a waiting orchestrator must NOT kill it.
|
|
235
|
+
// PARENT finished its turn (no busy marker) but still awaits a live child, so
|
|
236
|
+
// a hard 'dead' would orphan the child's report. It routes to idle-release
|
|
237
|
+
// (revivable); the second pass revives it on the next inbox push. No inbox
|
|
238
|
+
// entry here, so it stays dormant (idle + idle-release) — revivable, not dead.
|
|
217
239
|
await superviseTick();
|
|
218
|
-
|
|
240
|
+
const p = getNode('PARENT');
|
|
241
|
+
assert.equal(p.status, 'idle', 'a finished node still awaiting a live child is RELEASED, not killed');
|
|
242
|
+
assert.equal(p.intent, 'idle-release', 'routed to idle-release so a child push revives it');
|
|
243
|
+
assert.equal(p.window ?? null, null, 'the stale window ref is dropped');
|
|
219
244
|
});
|
|
220
245
|
});
|
|
221
246
|
// ---------------------------------------------------------------------------
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/detach-focus.test.ts
|
|
2
|
+
//
|
|
3
|
+
// GAP CLOSE (flagship-lifecycle.test.ts coverage boundary): "node demote
|
|
4
|
+
// --detach (A3: orphaned-focus-row hazard)". An END-TO-END test that drives the
|
|
5
|
+
// REAL `crtr node demote --detach` verb (subprocess, AS the node) — the actual
|
|
6
|
+
// Alt+C → D menu path — against a live fake-pi node genuinely FOCUSED in a user
|
|
7
|
+
// viewport, and
|
|
8
|
+
// proves the three things the A3 hazard is about:
|
|
9
|
+
// (a) lifecycle flips terminal IN PLACE,
|
|
10
|
+
// (b) the still-running pi's pane is RELOCATED to the backstage crtr session
|
|
11
|
+
// (the pi keeps generating off-screen, not killed),
|
|
12
|
+
// (c) the focus row it occupied is CLOSED — no orphaned/phantom viewport row
|
|
13
|
+
// lingering on the relocated %pane_id (Invariant P / Invariant F5).
|
|
14
|
+
//
|
|
15
|
+
// This is the harness-driven counterpart to placement-focus.test.ts's
|
|
16
|
+
// `detachToBackground` unit (which calls the function directly): here the FLIP
|
|
17
|
+
// and the detach both go through the real CLI leaf (node.ts nodeDemote →
|
|
18
|
+
// setLifecycle), so the whole `crtr node demote --detach` wiring — the Alt+C → D
|
|
19
|
+
// menu path — is exercised. Modeled on live-mutation.test.ts (harness +
|
|
20
|
+
// `crtr node demote` + firstPaneOf) and
|
|
21
|
+
// placement-focus.test.ts (user/back sessions + focus-row asserts).
|
|
22
|
+
//
|
|
23
|
+
// NOTE — the SIBLING focused-finish→manager-TAKEOVER path is NOT added here: the
|
|
24
|
+
// harness mints its root via in-process createNode (never a real bootRoot — see
|
|
25
|
+
// flagship S1), so a manager is always a paneless, never-booted row. Driving B
|
|
26
|
+
// through agent_end's done-branch (h.finish) with such a manager would only ever
|
|
27
|
+
// hit handFocusToManager's "live-but-paneless inline root → false" guard
|
|
28
|
+
// (closeFocusToShell), never a genuine takeover; a LIVE-backstage or dormant
|
|
29
|
+
// idle-release manager swap is unreachable through the harness. That swap is
|
|
30
|
+
// unit-covered in placement-teardown.test.ts (MAJOR 1 + the dormant idle-release
|
|
31
|
+
// case), so it is deliberately not re-driven here.
|
|
32
|
+
import { test } from 'node:test';
|
|
33
|
+
import assert from 'node:assert/strict';
|
|
34
|
+
import { spawnSync } from 'node:child_process';
|
|
35
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
36
|
+
import { setPresence, getNode } from '../canvas/canvas.js';
|
|
37
|
+
import { openFocusRow, getFocusByNode, getFocusByPane, listFocuses } from '../canvas/focuses.js';
|
|
38
|
+
import { closeDb } from '../canvas/db.js';
|
|
39
|
+
import { markBusy } from '../runtime/busy.js';
|
|
40
|
+
const SKIP = !hasTmux() ? 'tmux unavailable' : false;
|
|
41
|
+
function sessionExists(session) {
|
|
42
|
+
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
43
|
+
}
|
|
44
|
+
function tmuxOut(args) {
|
|
45
|
+
return (spawnSync('tmux', args, { encoding: 'utf8' }).stdout ?? '').trim();
|
|
46
|
+
}
|
|
47
|
+
/** The first %pane_id of a tmux window (spawn records window+session, not pane). */
|
|
48
|
+
function firstPaneOf(window) {
|
|
49
|
+
const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
|
|
50
|
+
if (r.status !== 0)
|
|
51
|
+
return null;
|
|
52
|
+
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
53
|
+
}
|
|
54
|
+
/** A pane's CURRENT session/window (display-message on its durable %id). */
|
|
55
|
+
function paneLoc(pane) {
|
|
56
|
+
const out = tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}\t#{window_id}']);
|
|
57
|
+
const [session, window] = out.split('\t');
|
|
58
|
+
if (!session || !window)
|
|
59
|
+
return null;
|
|
60
|
+
return { session, window };
|
|
61
|
+
}
|
|
62
|
+
function paneSessionReal(pane) {
|
|
63
|
+
return tmuxOut(['display-message', '-p', '-t', pane, '#{session_name}']);
|
|
64
|
+
}
|
|
65
|
+
function paneExistsReal(pane) {
|
|
66
|
+
return tmuxOut(['display-message', '-p', '-t', pane, '#{pane_id}']) === pane;
|
|
67
|
+
}
|
|
68
|
+
// ===========================================================================
|
|
69
|
+
// `crtr node demote --detach` on a FOCUSED node (A3 gap close; Alt+C → D).
|
|
70
|
+
// ===========================================================================
|
|
71
|
+
test('node demote --detach on a FOCUSED node: flips terminal, relocates the pane to the backstage, CLOSES the focus row (A3 — no orphaned focus row)', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
72
|
+
const h = await createHarness({ sessionPrefix: 'crtr-detach-focus' });
|
|
73
|
+
const user = `crtr-detach-user-${process.pid}-${Date.now().toString(36)}`;
|
|
74
|
+
try {
|
|
75
|
+
// A resident root + a live terminal child B (born into the backstage =
|
|
76
|
+
// h.session, the harness's CRTR_NODE_SESSION). B holds an active live sub
|
|
77
|
+
// to nothing extra — it just needs to be a live, focusable terminal node.
|
|
78
|
+
const A = h.spawnRoot('resident root');
|
|
79
|
+
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
80
|
+
const b0 = h.node(B);
|
|
81
|
+
assert.equal(b0.lifecycle, 'terminal', 'B born terminal');
|
|
82
|
+
assert.equal(b0.status, 'active', 'B active after boot');
|
|
83
|
+
// Resolve B's live %pane_id from its window (the spawn path records only
|
|
84
|
+
// window+session; pane is null until a reconcile/focus).
|
|
85
|
+
const bPane = firstPaneOf(b0.window);
|
|
86
|
+
assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
|
|
87
|
+
// --- Put B into a USER viewport, FOCUSED. Create a separate user session
|
|
88
|
+
// and break B's live pi pane out into it (the pi keeps running; the
|
|
89
|
+
// %id survives the break), then anchor its presence + open a focus row.
|
|
90
|
+
// This is the genuine "focused in a user pane" precondition — distinct
|
|
91
|
+
// from the backstage (h.session) the detach will relocate it back to.
|
|
92
|
+
spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
|
|
93
|
+
assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
|
|
94
|
+
const moved = paneLoc(bPane);
|
|
95
|
+
assert.equal(moved?.session, user, 'B pane physically in the user session now');
|
|
96
|
+
assert.equal(paneExistsReal(bPane), true, "B's pi is still alive after the move");
|
|
97
|
+
closeDb();
|
|
98
|
+
setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
|
|
99
|
+
openFocusRow('f-detach', bPane, user, B);
|
|
100
|
+
assert.equal(getFocusByNode(B)?.focus_id, 'f-detach', 'precondition: B is FOCUSED in the user viewport');
|
|
101
|
+
// B must read as GENERATING for the backstage-park path: isGenerating now
|
|
102
|
+
// gates the break-pane (Bug 1 / Invariant P) on the mid-turn busy marker
|
|
103
|
+
// AND a live pi_pid. The boot only recorded pi_pid (session_start); no turn
|
|
104
|
+
// fired, so set the busy marker to model an agent genuinely mid-turn —
|
|
105
|
+
// otherwise the new gate would RELEASE B (the non-generating path below).
|
|
106
|
+
markBusy(B);
|
|
107
|
+
// --- Drive the REAL verb on the FOCUSED node. The subprocess inherits
|
|
108
|
+
// CRTR_NODE_SESSION = h.session (the backstage), so detachToBackground
|
|
109
|
+
// breaks the pane back into h.session.
|
|
110
|
+
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
|
|
111
|
+
assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
|
|
112
|
+
assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
|
|
113
|
+
closeDb();
|
|
114
|
+
const b = getNode(B);
|
|
115
|
+
// (a) lifecycle flipped to terminal IN PLACE.
|
|
116
|
+
assert.equal(b.lifecycle, 'terminal', '(a) B lifecycle=terminal after the flip');
|
|
117
|
+
assert.equal(b.status, 'active', "(a) detach does NOT end the pi — B stays active");
|
|
118
|
+
// (b) the still-running pane was RELOCATED to the backstage crtr session
|
|
119
|
+
// (NOT killed): the pi keeps generating off-screen.
|
|
120
|
+
assert.equal(paneExistsReal(bPane), true, "(b) B's pi keeps generating (pane alive, relocated not killed)");
|
|
121
|
+
assert.equal(paneSessionReal(bPane), h.session, '(b) B pane relocated to the backstage crtr session');
|
|
122
|
+
assert.equal(b.tmux_session, h.session, "(b) B's LOCATION followed the pane to the backstage");
|
|
123
|
+
// (c) the focus row is CLOSED — no orphaned/phantom viewport (A3 hazard).
|
|
124
|
+
assert.equal(getFocusByNode(B), null, '(c) B no longer occupies any focus (row CLOSED — Invariant P)');
|
|
125
|
+
assert.equal(getFocusByPane(bPane), null, '(c) NO phantom focus resolves on the relocated %id');
|
|
126
|
+
assert.equal(listFocuses().length, 0, '(c) no dangling focus rows remain');
|
|
127
|
+
}
|
|
128
|
+
finally {
|
|
129
|
+
spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
|
|
130
|
+
const session = h.session;
|
|
131
|
+
await h.dispose();
|
|
132
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
// ===========================================================================
|
|
136
|
+
// Bug 1 (Invariant P / detach-idle-node): `crtr node demote --detach` on a
|
|
137
|
+
// NON-GENERATING focused node must NOT park it alive in the backstage — it must
|
|
138
|
+
// be RELEASED to dormant and its pane reaped.
|
|
139
|
+
//
|
|
140
|
+
// The backstage holds ONLY generating-but-unfocused nodes (Invariant P).
|
|
141
|
+
// detachToBackground used to break-pane to the backstage UNCONDITIONALLY, with no
|
|
142
|
+
// generating-ness check: a resident root parked idle between turns (pi alive,
|
|
143
|
+
// idle, NOT mid-turn) detached via Alt+C → D became unfocused + pi-alive-idle +
|
|
144
|
+
// holding a backstage pane forever — nothing re-enters agent_end to release it
|
|
145
|
+
// and, with no live subscription, nothing wakes it → a leaked live-idle pi that
|
|
146
|
+
// never reaps. The fix gates the break-pane on isGenerating() and RELEASES a
|
|
147
|
+
// non-generating node to dormant instead (mirrors retargetFocus's parked-viewer
|
|
148
|
+
// release on focus-away).
|
|
149
|
+
//
|
|
150
|
+
// This drives the SAME real verb as the happy-path test above, but on a node
|
|
151
|
+
// with NO busy marker (not mid-turn). isGenerating() = busy && pidAlive, so a
|
|
152
|
+
// freshly-booted node (pi_pid recorded at session_start, no turn fired) reads as
|
|
153
|
+
// NOT generating.
|
|
154
|
+
// ===========================================================================
|
|
155
|
+
test('node demote --detach on a NON-GENERATING focused node: RELEASES to dormant (idle + idle-release), reaps the pane, closes the focus row — NOT parked in the backstage (Bug 1 — Invariant P)', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
156
|
+
const h = await createHarness({ sessionPrefix: 'crtr-detach-idle' });
|
|
157
|
+
const user = `crtr-detach-idle-user-${process.pid}-${Date.now().toString(36)}`;
|
|
158
|
+
try {
|
|
159
|
+
// A resident root + a live terminal child B. B is freshly booted: pi alive
|
|
160
|
+
// (pi_pid recorded at session_start), status active, and — critically — NO
|
|
161
|
+
// busy marker, since no turn ever fired. It is parked idle, not mid-turn:
|
|
162
|
+
// exactly the non-generating occupant Bug 1 is about.
|
|
163
|
+
const A = h.spawnRoot('resident root');
|
|
164
|
+
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
165
|
+
const b0 = h.node(B);
|
|
166
|
+
assert.equal(b0.status, 'active', 'B active after boot');
|
|
167
|
+
const bPane = firstPaneOf(b0.window);
|
|
168
|
+
assert.ok(typeof bPane === 'string' && bPane !== '', 'B has a live pane');
|
|
169
|
+
// --- Put B into a USER viewport, FOCUSED (same precondition as the happy
|
|
170
|
+
// path), but DO NOT mark it busy: it stays NOT generating.
|
|
171
|
+
spawnSync('tmux', ['new-session', '-d', '-s', user, '-c', '/tmp', 'sleep 600'], { stdio: 'ignore' });
|
|
172
|
+
assert.equal(spawnSync('tmux', ['break-pane', '-d', '-a', '-s', bPane, '-t', `${user}:`]).status, 0, 'B pane broke out into the user viewport (pi kept alive)');
|
|
173
|
+
const moved = paneLoc(bPane);
|
|
174
|
+
assert.equal(moved?.session, user, 'B pane physically in the user session now');
|
|
175
|
+
assert.equal(paneExistsReal(bPane), true, "B's pi is alive (parked idle, not mid-turn)");
|
|
176
|
+
closeDb();
|
|
177
|
+
setPresence(B, { pane: bPane, tmux_session: user, window: moved.window });
|
|
178
|
+
openFocusRow('f-detach-idle', bPane, user, B);
|
|
179
|
+
assert.equal(getFocusByNode(B)?.focus_id, 'f-detach-idle', 'precondition: B is FOCUSED, NON-generating');
|
|
180
|
+
// --- Drive the REAL verb on the non-generating focused node.
|
|
181
|
+
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', bPane, '--detach']);
|
|
182
|
+
assert.equal(res.code, 0, `node demote --detach exit 0\n${res.stderr}`);
|
|
183
|
+
assert.match(res.stdout, /detached="true"/, `the agent was detached\n${res.stdout}`);
|
|
184
|
+
closeDb();
|
|
185
|
+
const b = getNode(B);
|
|
186
|
+
// (b) RELEASED to dormant — idle + intent='idle-release' (revivable via inbox
|
|
187
|
+
// / re-focus), NOT left active in the backstage.
|
|
188
|
+
assert.equal(b.status, 'idle', '(b) B released to dormant (status idle)');
|
|
189
|
+
assert.equal(b.intent, 'idle-release', "(b) B's intent is idle-release (daemon revives on inbox)");
|
|
190
|
+
// (c) the pane is REAPED / presence nulled — NOT relocated to the backstage
|
|
191
|
+
// crtr session. The leaked live-idle pi must be gone.
|
|
192
|
+
assert.equal(paneExistsReal(bPane), false, '(c) B pane reaped (NOT parked alive in the backstage)');
|
|
193
|
+
assert.equal(b.pane, null, "(c) B's presence pane is nulled");
|
|
194
|
+
assert.equal(b.tmux_session, null, "(c) B's presence session is nulled (NOT the backstage crtr session)");
|
|
195
|
+
// (a) the focus row is CLOSED — no orphaned/phantom viewport.
|
|
196
|
+
assert.equal(getFocusByNode(B), null, '(a) B no longer occupies any focus (row CLOSED — Invariant P)');
|
|
197
|
+
assert.equal(getFocusByPane(bPane), null, '(a) NO phantom focus resolves on the reaped %id');
|
|
198
|
+
assert.equal(listFocuses().length, 0, '(a) no dangling focus rows remain');
|
|
199
|
+
}
|
|
200
|
+
finally {
|
|
201
|
+
spawnSync('tmux', ['kill-session', '-t', user], { stdio: 'ignore' });
|
|
202
|
+
const session = h.session;
|
|
203
|
+
await h.dispose();
|
|
204
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
205
|
+
}
|
|
206
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|