@crouton-kit/crouter 0.3.18 → 0.3.20
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/PERSONA.md +1 -0
- package/dist/builtin-personas/design/orchestrator.md +2 -1
- package/dist/builtin-personas/developer/PERSONA.md +1 -0
- package/dist/builtin-personas/developer/orchestrator.md +2 -1
- package/dist/builtin-personas/explore/PERSONA.md +1 -0
- package/dist/builtin-personas/explore/orchestrator.md +4 -0
- package/dist/builtin-personas/general/PERSONA.md +1 -0
- package/dist/builtin-personas/general/orchestrator.md +4 -0
- 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/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/orchestrator.md +2 -1
- package/dist/builtin-personas/plan/reviewers/architecture-fit/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/code-smells/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/pattern-consistency/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/requirements-coverage/PERSONA.md +1 -0
- package/dist/builtin-personas/plan/reviewers/security/PERSONA.md +1 -0
- package/dist/builtin-personas/review/PERSONA.md +1 -0
- package/dist/builtin-personas/review/orchestrator.md +4 -0
- package/dist/builtin-personas/runtime-base.md +6 -3
- package/dist/builtin-personas/spec/PERSONA.md +1 -0
- package/dist/builtin-personas/spec/orchestrator.md +2 -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 +54 -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 +610 -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-attach-limits.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-limits.test.js +157 -0
- package/dist/core/__tests__/broker-attach-stream.test.d.ts +1 -0
- package/dist/core/__tests__/broker-attach-stream.test.js +125 -0
- package/dist/core/__tests__/broker-crash-teardown.test.d.ts +1 -0
- package/dist/core/__tests__/broker-crash-teardown.test.js +116 -0
- package/dist/core/__tests__/broker-dialogs.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dialogs.test.js +126 -0
- package/dist/core/__tests__/broker-dormant-wake.test.d.ts +1 -0
- package/dist/core/__tests__/broker-dormant-wake.test.js +51 -0
- package/dist/core/__tests__/broker-lifecycle.test.d.ts +1 -0
- package/dist/core/__tests__/broker-lifecycle.test.js +87 -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__/canvas-inbox-watcher-hold.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +102 -0
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +2 -36
- 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/broker-clients.d.ts +43 -0
- package/dist/core/__tests__/helpers/broker-clients.js +178 -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-verbs.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation-verbs.test.js +175 -0
- package/dist/core/__tests__/live-mutation.test.js +6 -132
- 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 +16 -23
- 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 +91 -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 +54 -4
- package/dist/core/runtime/launch.js +85 -5
- 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 +37 -0
- package/dist/core/runtime/nodes.js +60 -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 +4 -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 +11 -8
- package/dist/core/runtime/revive.d.ts +2 -0
- package/dist/core/runtime/revive.js +34 -34
- package/dist/core/runtime/spawn.d.ts +23 -0
- package/dist/core/runtime/spawn.js +77 -23
- 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
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
// - home_session round-trips through meta.json (it IS durable identity)
|
|
8
8
|
// - the birth-session decision (`resolveBirthSession`) for the child /
|
|
9
9
|
// inline-root / --root cases each site sets home_session from
|
|
10
|
-
// -
|
|
10
|
+
// - recycle + relaunch (pane-recycle) births populate home_session
|
|
11
11
|
// - a legacy meta with NO home_session defaults to tmux_session ?? nodeSession()
|
|
12
12
|
import { test, before, after, beforeEach } from 'node:test';
|
|
13
13
|
import assert from 'node:assert/strict';
|
|
@@ -17,10 +17,10 @@ import { join } from 'node:path';
|
|
|
17
17
|
import { createNode, getNode, updateNode } from '../canvas/canvas.js';
|
|
18
18
|
import { nodeMetaPath } from '../canvas/paths.js';
|
|
19
19
|
import { closeDb } from '../canvas/db.js';
|
|
20
|
-
import { resolveBirthSession, homeSessionOf } from '../runtime/nodes.js';
|
|
20
|
+
import { resolveBirthSession, homeSessionOf, childBackstageOf } from '../runtime/nodes.js';
|
|
21
21
|
import { nodeSession } from '../runtime/nodes.js';
|
|
22
22
|
import { relaunchRoot } from '../runtime/reset.js';
|
|
23
|
-
import {
|
|
23
|
+
import { recycleNode } from '../runtime/recycle.js';
|
|
24
24
|
let home;
|
|
25
25
|
function node(id, over = {}) {
|
|
26
26
|
return {
|
|
@@ -35,7 +35,7 @@ function node(id, over = {}) {
|
|
|
35
35
|
...over,
|
|
36
36
|
};
|
|
37
37
|
}
|
|
38
|
-
/** Make ensureDaemon (called by
|
|
38
|
+
/** Make ensureDaemon (called by recycleNode) a no-op by faking a live daemon
|
|
39
39
|
* pidfile pointing at THIS test process — so no real daemon is ever spawned. */
|
|
40
40
|
function fakeLiveDaemon() {
|
|
41
41
|
writeFileSync(join(home, 'crtrd.pid'), String(process.pid), 'utf8');
|
|
@@ -106,6 +106,39 @@ test('homeSessionOf: unknown node falls back to the backstage', () => {
|
|
|
106
106
|
assert.equal(homeSessionOf('ghost'), nodeSession());
|
|
107
107
|
});
|
|
108
108
|
// ---------------------------------------------------------------------------
|
|
109
|
+
// childBackstageOf — the session a node's CHILDREN spawn into (their
|
|
110
|
+
// CRTR_ROOT_SESSION). REGRESSION for the front-door-root subtree-exile bug:
|
|
111
|
+
// a refreshed inline root (home_session = a USER session it adopted) was
|
|
112
|
+
// sourcing children's CRTR_ROOT_SESSION from home_session, so every yield
|
|
113
|
+
// re-pointed its entire subtree into the user's working session. A root's
|
|
114
|
+
// children must always flow to the shared backstage `nodeSession()`, never the
|
|
115
|
+
// user session, while a managed child's children inherit its backstage
|
|
116
|
+
// home_session. (Bug: node mq2u219p spawned all 13 children into `cli`.)
|
|
117
|
+
// ---------------------------------------------------------------------------
|
|
118
|
+
test('childBackstageOf: a managed child uses its backstage home_session', () => {
|
|
119
|
+
// A child's home_session is ALWAYS the backstage it was born into; its live
|
|
120
|
+
// tmux_session may be a user session (focus taint) but must NOT leak to kids.
|
|
121
|
+
createNode(node('child', { parent: 'p', home_session: 'crtr', tmux_session: 'user-sess' }));
|
|
122
|
+
assert.equal(childBackstageOf('child'), 'crtr', 'children inherit the backstage, not the tainted LOCATION');
|
|
123
|
+
});
|
|
124
|
+
test('childBackstageOf: a front-door ROOT routes children to the backstage, NOT its adopted user session', () => {
|
|
125
|
+
// The bug: an inline root adopts the user's session as home_session ('cli').
|
|
126
|
+
// Sourcing children's CRTR_ROOT_SESSION from home_session exiled the whole
|
|
127
|
+
// subtree into 'cli' on every refresh-yield. A root (parent === null) must
|
|
128
|
+
// route children to nodeSession() instead.
|
|
129
|
+
createNode(node('root', { parent: null, home_session: 'cli', tmux_session: 'cli' }));
|
|
130
|
+
assert.equal(childBackstageOf('root'), nodeSession(), "a root's children flow to the backstage");
|
|
131
|
+
assert.notEqual(childBackstageOf('root'), 'cli', 'NEVER the user session the root pane adopted');
|
|
132
|
+
});
|
|
133
|
+
test('childBackstageOf: a root honors CRTR_NODE_SESSION for the backstage', () => {
|
|
134
|
+
process.env['CRTR_NODE_SESSION'] = 'my-backstage';
|
|
135
|
+
createNode(node('root', { parent: null, home_session: 'cli' }));
|
|
136
|
+
assert.equal(childBackstageOf('root'), 'my-backstage', 'the backstage default is env-overridable');
|
|
137
|
+
});
|
|
138
|
+
test('childBackstageOf: unknown node falls back to the backstage', () => {
|
|
139
|
+
assert.equal(childBackstageOf('ghost'), nodeSession());
|
|
140
|
+
});
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
109
142
|
// The birth-session decision each site sets home_session from. Pure, so the
|
|
110
143
|
// child / inline-root / --root births are testable without a live tmux (the
|
|
111
144
|
// real spawnChild/bootRoot are tmux + pi + process.exit coupled).
|
|
@@ -130,7 +163,7 @@ test('birth: the inline front door (bootRoot) homes to its adopted session', ()
|
|
|
130
163
|
});
|
|
131
164
|
// ---------------------------------------------------------------------------
|
|
132
165
|
// Pane-recycle births populate home_session. relaunchRoot (option C) is fully
|
|
133
|
-
// unit-testable (injected respawn, no tmux);
|
|
166
|
+
// unit-testable (injected respawn, no tmux); recycleNode runs to completion with
|
|
134
167
|
// no tmux (respawn dispatch just fails) once the daemon spawn is neutralized.
|
|
135
168
|
// ---------------------------------------------------------------------------
|
|
136
169
|
test('relaunch birth: the fresh root homes to the recycled pane session', () => {
|
|
@@ -141,13 +174,13 @@ test('relaunch birth: the fresh root homes to the recycled pane session', () =>
|
|
|
141
174
|
assert.ok(res !== null, 'relaunchRoot minted a fresh root');
|
|
142
175
|
assert.equal(getNode(res.newNodeId)?.home_session, 'crtr', 'fresh root homes to the recycled pane session');
|
|
143
176
|
});
|
|
144
|
-
test('
|
|
177
|
+
test('recycle birth: the recycled root populates home_session (backstage when no pane location)', async () => {
|
|
145
178
|
createNode(node('M', { parent: null, lifecycle: 'resident', tmux_session: 'crtr', window: '@3' }));
|
|
146
179
|
fakeLiveDaemon(); // createNode ensured the home dir; now neutralize ensureDaemon
|
|
147
180
|
// No live tmux → paneLocation('%0') is null → home_session defaults to the
|
|
148
181
|
// backstage. The respawn dispatch fails (no tmux), but the fresh root is still
|
|
149
182
|
// born — and must carry a populated home_session.
|
|
150
|
-
const res = await
|
|
151
|
-
assert.ok(res.newRoot !== null, '
|
|
183
|
+
const res = await recycleNode('M', '%0');
|
|
184
|
+
assert.ok(res.newRoot !== null, 'recycle minted a fresh root');
|
|
152
185
|
assert.equal(getNode(res.newRoot)?.home_session, nodeSession(), 'recycled root homes to the backstage');
|
|
153
186
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/human-new-window-regression.test.ts
|
|
2
|
+
//
|
|
3
|
+
// BUG REGRESSION: `crtr human ask|approve|review|notify` opened NO pane at all.
|
|
4
|
+
//
|
|
5
|
+
// spawnAndDetach's 'new-window' placement passed a tmux PANE id straight to
|
|
6
|
+
// `new-window -t` (`new-window -d -a -t %<pane>`). tmux REJECTS a pane id for
|
|
7
|
+
// new-window — it exits 1 with "can't specify pane here"; only `split-window -t`
|
|
8
|
+
// accepts a pane. So once a node's watched window held >= max_panes_per_window
|
|
9
|
+
// panes, pickPlacement (shared.ts) chose 'new-window', spawnAndDetach FAILED,
|
|
10
|
+
// spawnHumanJob returned {spawned:false}, and the humanloop TUI never appeared.
|
|
11
|
+
// Deterministic for any user whose watched window is already at the pane cap.
|
|
12
|
+
//
|
|
13
|
+
// Regression from 829c1a1 ("land prompts in the watched node's session"), which
|
|
14
|
+
// began routing the TUI to the watched node's pane id but never converted it to
|
|
15
|
+
// the session:window form new-window needs.
|
|
16
|
+
//
|
|
17
|
+
// THE FIX: spawnAndDetach resolves the target pane to its session:window
|
|
18
|
+
// (`paneWindowTarget`) before `new-window -t`. This drives the REAL production
|
|
19
|
+
// spawnAndDetach against a REAL but isolated tmux session — no mocks — mirroring
|
|
20
|
+
// dead-pane-regression.test.ts.
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { spawnSync } from 'node:child_process';
|
|
24
|
+
import { hasTmux } from './helpers/harness.js';
|
|
25
|
+
import { spawnAndDetach } from '../spawn.js';
|
|
26
|
+
const SKIP = !hasTmux();
|
|
27
|
+
function tmux(args) {
|
|
28
|
+
const r = spawnSync('tmux', args, { encoding: 'utf8' });
|
|
29
|
+
return { code: r.status ?? -1, out: (r.stdout ?? '').trim(), err: (r.stderr ?? '').trim() };
|
|
30
|
+
}
|
|
31
|
+
/** window id of a pane (across the server). '' on error. */
|
|
32
|
+
function windowOfPane(pane) {
|
|
33
|
+
return tmux(['display-message', '-p', '-t', pane, '#{window_id}']).out;
|
|
34
|
+
}
|
|
35
|
+
/** Every pane in the session, across all its windows. */
|
|
36
|
+
function sessionPanes(session) {
|
|
37
|
+
const r = tmux(['list-panes', '-s', '-t', session, '-F', '#{pane_id}']);
|
|
38
|
+
if (r.code !== 0)
|
|
39
|
+
return [];
|
|
40
|
+
return r.out.split('\n').filter((l) => l.trim() !== '');
|
|
41
|
+
}
|
|
42
|
+
test('human-new-window regression: spawnAndDetach new-window into a watched-pane SPAWNS (no longer "can\'t specify pane here")', { skip: SKIP ? 'tmux unavailable' : false, timeout: 60_000 }, async () => {
|
|
43
|
+
const session = `crtr-newwin-${process.pid}-${Date.now().toString(36)}`;
|
|
44
|
+
const origTmux = process.env['TMUX'];
|
|
45
|
+
let tmuxOverridden = false;
|
|
46
|
+
try {
|
|
47
|
+
// --- isolated session on whatever server the current env targets -------
|
|
48
|
+
const created = tmux(['new-session', '-d', '-s', session, '-c', process.cwd(), 'sleep 100000']);
|
|
49
|
+
assert.equal(created.code, 0, `create isolated session failed: ${created.err}`);
|
|
50
|
+
const info = tmux([
|
|
51
|
+
'display-message', '-p', '-t', `${session}:`,
|
|
52
|
+
'#{pane_id}\t#{window_id}\t#{socket_path}',
|
|
53
|
+
]);
|
|
54
|
+
assert.equal(info.code, 0, `display-message failed: ${info.err}`);
|
|
55
|
+
const [basePane, baseWindow, socketPath] = info.out.split('\t');
|
|
56
|
+
assert.ok(basePane && baseWindow && socketPath, `parsed session info: ${info.out}`);
|
|
57
|
+
// ====================================================================
|
|
58
|
+
// CONTROL — prove the bug is REAL and the guard NON-VACUOUS: the pre-fix
|
|
59
|
+
// call shape (a PANE id straight into `new-window -t`) is REJECTED by tmux.
|
|
60
|
+
// ====================================================================
|
|
61
|
+
const ctl = tmux(['new-window', '-d', '-a', '-t', basePane, '-c', process.cwd(), 'sleep 100000']);
|
|
62
|
+
assert.notEqual(ctl.code, 0, 'CONTROL: new-window with a PANE id must FAIL (pre-fix call shape)');
|
|
63
|
+
assert.match(ctl.err, /can't specify pane here/i, `CONTROL: tmux rejects a pane id for new-window — got: ${ctl.err}`);
|
|
64
|
+
// spawnAndDetach gates on isInTmux() (Boolean(process.env.TMUX)) and shells
|
|
65
|
+
// `tmux` with no -L, so point TMUX at THIS session's socket — both our
|
|
66
|
+
// helpers and spawnAndDetach then drive the same server holding `session`.
|
|
67
|
+
process.env['TMUX'] = `${socketPath},0,0`;
|
|
68
|
+
tmuxOverridden = true;
|
|
69
|
+
// ====================================================================
|
|
70
|
+
// MAIN — the REAL production spawnAndDetach, 'new-window' placement,
|
|
71
|
+
// pinned to the watched PANE (exactly what detachHumanTui passes when
|
|
72
|
+
// pickPlacement returns 'new-window').
|
|
73
|
+
// ====================================================================
|
|
74
|
+
const res = spawnAndDetach({
|
|
75
|
+
command: 'sleep 100000', // long-lived stand-in for `crtr human _run`
|
|
76
|
+
cwd: process.cwd(),
|
|
77
|
+
placement: 'new-window',
|
|
78
|
+
detached: true, // don't switch the client (matches detachHumanTui)
|
|
79
|
+
killAfterSeconds: 0, // no self-kill of the originating pane
|
|
80
|
+
targetPane: basePane, // a PANE id — the exact value that used to break new-window
|
|
81
|
+
});
|
|
82
|
+
// (a) THE FIX: it SPAWNS (pre-fix this was 'spawn-failed' / no pane).
|
|
83
|
+
assert.equal(res.status, 'spawned', `spawnAndDetach should spawn a pane: ${res.message}`);
|
|
84
|
+
const pane = res.paneId;
|
|
85
|
+
assert.ok(pane && pane.startsWith('%'), `spawnAndDetach returned a %pane id: ${pane}`);
|
|
86
|
+
// (b) it landed in the SAME session as the watched pane (not stranded /
|
|
87
|
+
// leaked into the global current session), in a NEW window.
|
|
88
|
+
assert.ok(sessionPanes(session).includes(pane), 'spawned pane lives in the watched pane\'s session');
|
|
89
|
+
assert.notEqual(windowOfPane(pane), baseWindow, 'spawned pane is in a NEW window (new-window placement), not the watched pane\'s window');
|
|
90
|
+
}
|
|
91
|
+
finally {
|
|
92
|
+
tmux(['kill-session', '-t', session]);
|
|
93
|
+
if (tmuxOverridden) {
|
|
94
|
+
if (origTmux === undefined)
|
|
95
|
+
delete process.env['TMUX'];
|
|
96
|
+
else
|
|
97
|
+
process.env['TMUX'] = origTmux;
|
|
98
|
+
}
|
|
99
|
+
assert.equal(spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0, false, 'isolated session killed — no stray');
|
|
100
|
+
}
|
|
101
|
+
});
|
|
@@ -7,10 +7,11 @@
|
|
|
7
7
|
// the regression guard for the old "build had hidden side effects" smell.
|
|
8
8
|
import { test, before, after, beforeEach } from 'node:test';
|
|
9
9
|
import assert from 'node:assert/strict';
|
|
10
|
-
import { mkdtempSync, rmSync, existsSync } from 'node:fs';
|
|
11
|
-
import {
|
|
10
|
+
import { mkdtempSync, rmSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { createNode, subscribe } from '../canvas/canvas.js';
|
|
14
|
+
import { reportsDir } from '../canvas/paths.js';
|
|
14
15
|
import { closeDb } from '../canvas/db.js';
|
|
15
16
|
import { drainBearings, buildReviveKickoff, writeYieldMessage, yieldMessagePath, } from '../runtime/kickoff.js';
|
|
16
17
|
import { appendInbox, readCursor } from '../feed/inbox.js';
|
|
@@ -28,7 +29,9 @@ function node(id) {
|
|
|
28
29
|
};
|
|
29
30
|
}
|
|
30
31
|
before(() => {
|
|
31
|
-
|
|
32
|
+
// Under homedir (not tmpdir) so report paths fall under the home prefix and
|
|
33
|
+
// exercise the tildify() collapse in reportHistoryLines.
|
|
34
|
+
home = mkdtempSync(join(homedir(), '.crtr-kickoff-'));
|
|
32
35
|
process.env['CRTR_HOME'] = home;
|
|
33
36
|
});
|
|
34
37
|
beforeEach(() => {
|
|
@@ -70,6 +73,37 @@ test('the feed block frames awaiting workers as alive + auto-waking, so a fresh
|
|
|
70
73
|
assert.ok(/wake you the moment/.test(msg), 'states the wake is automatic on push');
|
|
71
74
|
assert.ok(/still working, not stalled/.test(msg), 'frames the empty feed as expected, not a problem');
|
|
72
75
|
});
|
|
76
|
+
test('a fresh revive is pointed at its subscriptions\' on-disk report history (catch-up bug)', () => {
|
|
77
|
+
// Regression for the fresh-revive catch-up bug: on a refresh-yield
|
|
78
|
+
// (resume:false) the old conversation is gone AND the inbox cursor has already
|
|
79
|
+
// advanced past everything drained pre-yield, so the revived node loses sight
|
|
80
|
+
// of reports its subscriptions pushed BEFORE the yield. The bodies persist
|
|
81
|
+
// forever at reports/<ts>-<kind>.md; the kickoff must point the node at those
|
|
82
|
+
// existing paths so it can catch up. Asserts the paths are rendered.
|
|
83
|
+
const parent = createNode(node('p1'));
|
|
84
|
+
const worker = createNode(node('w1'));
|
|
85
|
+
subscribe(parent.node_id, worker.node_id);
|
|
86
|
+
// The worker pushed two reports BEFORE the parent's refresh-yield.
|
|
87
|
+
const dir = reportsDir(worker.node_id);
|
|
88
|
+
mkdirSync(dir, { recursive: true });
|
|
89
|
+
const older = join(dir, '20260608T120000-update.md');
|
|
90
|
+
const newer = join(dir, '20260608T130000-final.md');
|
|
91
|
+
writeFileSync(older, '---\n---\nearly progress', 'utf8');
|
|
92
|
+
writeFileSync(newer, '---\n---\nthe result', 'utf8');
|
|
93
|
+
const msg = buildReviveKickoff(parent, drainBearings(parent));
|
|
94
|
+
// Both existing report PATHS surface (most recent first), so the revived node
|
|
95
|
+
// can dereference the history its advanced cursor would otherwise hide. They
|
|
96
|
+
// render home-relative with a leading ~ (the absolute home prefix is wasted
|
|
97
|
+
// context tokens; ~ is still dereferenceable by the revived node).
|
|
98
|
+
const olderRel = '~' + older.slice(homedir().length);
|
|
99
|
+
const newerRel = '~' + newer.slice(homedir().length);
|
|
100
|
+
assert.ok(msg.includes(olderRel), 'older report path surfaced, home-relative (~)');
|
|
101
|
+
assert.ok(msg.includes(newerRel), 'newer report path surfaced, home-relative (~)');
|
|
102
|
+
assert.ok(!msg.includes(older), 'absolute home prefix not rendered');
|
|
103
|
+
assert.ok(msg.indexOf(newerRel) < msg.indexOf(olderRel), 'most recent report listed first');
|
|
104
|
+
// ...and the hint that the full inbox history is replayable cursor-independently.
|
|
105
|
+
assert.ok(/feed read --all/.test(msg), 'points at the cursor-independent full-history replay');
|
|
106
|
+
});
|
|
73
107
|
test('buildReviveKickoff is pure — building twice eats nothing', () => {
|
|
74
108
|
const id = 'n2';
|
|
75
109
|
const meta = createNode(node(id));
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/live-mutation-verbs.test.ts
|
|
2
|
+
//
|
|
3
|
+
// AXIS: LIVE MUTATION, part 2 — the demote/recycle verb split and the A4
|
|
4
|
+
// promote-then-yield boundary. Split out of live-mutation.test.ts (see its
|
|
5
|
+
// header for the coverage rationale and the oracle reference) so node:test's
|
|
6
|
+
// file-level parallelism applies; each test builds its OWN isolated harness and
|
|
7
|
+
// is moved here UNCHANGED.
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import assert from 'node:assert/strict';
|
|
10
|
+
import { spawnSync } from 'node:child_process';
|
|
11
|
+
import { createHarness, hasTmux } from './helpers/harness.js';
|
|
12
|
+
const SKIP = !hasTmux() ? 'tmux unavailable' : false;
|
|
13
|
+
function sessionExists(session) {
|
|
14
|
+
return spawnSync('tmux', ['has-session', '-t', session], { stdio: 'ignore' }).status === 0;
|
|
15
|
+
}
|
|
16
|
+
// --- pure test-local helpers (shared verbatim with live-mutation.test.ts) ---
|
|
17
|
+
/** The injected entries delivered as a turn-boundary `steer`. */
|
|
18
|
+
function steers(inj) {
|
|
19
|
+
return inj.filter((e) => e.deliverAs === 'steer');
|
|
20
|
+
}
|
|
21
|
+
/** A steer carrying the base→orchestrator orchestration guidance. */
|
|
22
|
+
function orchestrationSteers(inj) {
|
|
23
|
+
return steers(inj).filter((e) => /ORCHESTRATOR/i.test(e.content));
|
|
24
|
+
}
|
|
25
|
+
/** Normalize the two persona axes off a NodeMeta for deepEqual. */
|
|
26
|
+
function persona(m) {
|
|
27
|
+
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
28
|
+
}
|
|
29
|
+
/** The first %pane_id of a tmux window. The spawn path records window+session
|
|
30
|
+
* but NOT pane (spawn.ts: pane is null until a reconcile/focus), so a node's
|
|
31
|
+
* live pane must be resolved from its window here. */
|
|
32
|
+
function firstPaneOf(window) {
|
|
33
|
+
const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
|
|
34
|
+
if (r.status !== 0)
|
|
35
|
+
return null;
|
|
36
|
+
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
37
|
+
}
|
|
38
|
+
// ===========================================================================
|
|
39
|
+
// (b) THE demote / recycle SPLIT — two DISTINCT verbs after the rename:
|
|
40
|
+
// • `node demote` flips a LIVE node's lifecycle→TERMINAL IN PLACE — it keeps
|
|
41
|
+
// its pane, its MODE, and its parentage, keeps running, is NOT finalized; it
|
|
42
|
+
// now merely owes a final up the spine (vision F5). It is NOT an
|
|
43
|
+
// orchestrator→base mode flip — MODE is untouched (so persona.ts
|
|
44
|
+
// `baseModeGuidance` stays unreachable via live mutation, as before).
|
|
45
|
+
// • `node recycle` is FINISH+RECYCLE — push final → done, then recycle the
|
|
46
|
+
// pane into a FRESH general/base/resident root (a DIFFERENT node). The
|
|
47
|
+
// recycled node keeps mode=orchestrator (it is merely `done`).
|
|
48
|
+
// This test drives BOTH real verbs on one live node and pins each behavior.
|
|
49
|
+
// ===========================================================================
|
|
50
|
+
test('node demote flips lifecycle→terminal IN PLACE; node recycle is FINISH+RECYCLE', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
51
|
+
const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
|
|
52
|
+
try {
|
|
53
|
+
const A = h.spawnRoot('resident root');
|
|
54
|
+
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
55
|
+
// Make B resident + orchestrator so the demote's flip→terminal is visible
|
|
56
|
+
// and we can prove MODE/parentage survive it.
|
|
57
|
+
assert.equal(h.cli(B, ['node', 'lifecycle', 'resident', '--node', B]).code, 0, 'B → resident');
|
|
58
|
+
assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
|
|
59
|
+
{
|
|
60
|
+
const b = h.node(B);
|
|
61
|
+
assert.equal(b.lifecycle, 'resident', 'B resident before demote');
|
|
62
|
+
assert.equal(b.mode, 'orchestrator', 'B orchestrator before demote');
|
|
63
|
+
}
|
|
64
|
+
const bParent = h.node(B).parent;
|
|
65
|
+
// --- node demote: flip-to-terminal IN PLACE. Keeps B alive, MODE/parentage
|
|
66
|
+
// untouched, NOT finalized.
|
|
67
|
+
const dem = h.cli(B, ['node', 'demote', '--node', B]);
|
|
68
|
+
assert.equal(dem.code, 0, `node demote exit 0\n${dem.stderr}`);
|
|
69
|
+
assert.match(dem.stdout, /<demoted /, `demote rendered\n${dem.stdout}`);
|
|
70
|
+
{
|
|
71
|
+
const b = h.node(B);
|
|
72
|
+
assert.equal(b.lifecycle, 'terminal', 'demote flips lifecycle→terminal IN PLACE');
|
|
73
|
+
assert.equal(b.mode, 'orchestrator', 'demote leaves MODE untouched (not an orchestrator→base flip)');
|
|
74
|
+
assert.equal(b.parent, bParent, 'demote leaves parentage unchanged');
|
|
75
|
+
assert.equal(b.status, 'active', 'demote does NOT finish B — it keeps running in place');
|
|
76
|
+
assert.notEqual(b.intent ?? null, 'done', 'demote does NOT finalize B');
|
|
77
|
+
}
|
|
78
|
+
// --- node recycle: FINISH + RECYCLE the SAME pane into a fresh root.
|
|
79
|
+
// Resolve B's live %pane_id from its window (the row's `pane` is null
|
|
80
|
+
// until a reconcile; the spawn path records only window+session).
|
|
81
|
+
const pane = firstPaneOf(h.node(B).window);
|
|
82
|
+
assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
|
|
83
|
+
// RECYCLE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
|
|
84
|
+
const res = h.cli(B, ['node', 'recycle', '--node', B, '--pane', pane]);
|
|
85
|
+
assert.equal(res.code, 0, `recycle exit 0\n${res.stderr}`);
|
|
86
|
+
// The leaf renders `<recycled ... finalized=".." new_root=".."/>` (not JSON).
|
|
87
|
+
assert.match(res.stdout, /<recycled /, `recycle recycled the pane\n${res.stdout}`);
|
|
88
|
+
const newRoot = /new_root="([^"]+)"/.exec(res.stdout)?.[1];
|
|
89
|
+
const finalized = /finalized="true"/.test(res.stdout);
|
|
90
|
+
// The recycled node is FINISHED, not mode-flipped.
|
|
91
|
+
{
|
|
92
|
+
const b = h.node(B);
|
|
93
|
+
assert.equal(b.status, 'done', 'recycled node → done (finished), NOT re-roled');
|
|
94
|
+
assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
|
|
95
|
+
assert.equal(b.mode, 'orchestrator', 'recycled node KEEPS mode=orchestrator — recycle is NOT a mode flip');
|
|
96
|
+
assert.ok(finalized, 'recycle pushed a final for the node');
|
|
97
|
+
}
|
|
98
|
+
// The fresh root is a DIFFERENT, BASE×RESIDENT node.
|
|
99
|
+
assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
|
|
100
|
+
{
|
|
101
|
+
const fresh = h.node(newRoot);
|
|
102
|
+
assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
|
|
103
|
+
assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident');
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
finally {
|
|
107
|
+
const session = h.session;
|
|
108
|
+
await h.dispose();
|
|
109
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
// ===========================================================================
|
|
113
|
+
// (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. The
|
|
114
|
+
// oracle/flagship boundary: the base→orchestrator guidance lands as a STEER
|
|
115
|
+
// only if a turn_end fires while the drift is pending. A `node yield` on a
|
|
116
|
+
// base node auto-promotes (mode→orchestrator, ack NOT committed) and its
|
|
117
|
+
// agent_end goes STRAIGHT to reviveInPlace (b') with NO preceding turn_end —
|
|
118
|
+
// so the only steer-delivery site (turn_end) is BYPASSED. Two deterministic
|
|
119
|
+
// facts pin the boundary, both confirmed by direct observation:
|
|
120
|
+
// (1) NO orchestration STEER is ever delivered (the LOSS).
|
|
121
|
+
// (2) The ack is silently advanced base→orchestrator at the refresh DRAIN
|
|
122
|
+
// (reviveInPlace→drainBearings→commitPersonaAck), NOT via a steer — so
|
|
123
|
+
// neither this turn nor (per A4) the fresh revive re-offers it as a
|
|
124
|
+
// steer; the guidance survives only in the kickoff PROMPT it built.
|
|
125
|
+
// ⚑ FLAGGED (not fixed): the in-place refresh of this LARGE pending-drift
|
|
126
|
+
// kickoff prompt did NOT complete a fresh fake-pi boot in the harness (it
|
|
127
|
+
// stayed at 1 boot, intent=refresh, ack=orchestrator, pane alive) — a
|
|
128
|
+
// base→orchestrator yield's giant <persona-transition> kickoff pushed
|
|
129
|
+
// through respawn-pane did not bring up the fresh vehicle. Whether a real
|
|
130
|
+
// edge (oversized argv through respawn-pane) or a harness artifact, it is
|
|
131
|
+
// out of scope to fix; this test asserts only the deterministic boundary.
|
|
132
|
+
// ===========================================================================
|
|
133
|
+
test('A4: a base→orchestrator yield with no preceding turn_end loses the orchestration STEER (ack advances silently at the refresh drain)', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
134
|
+
const h = await createHarness({ sessionPrefix: 'crtr-live-a4' });
|
|
135
|
+
try {
|
|
136
|
+
const A = h.spawnRoot('resident root');
|
|
137
|
+
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
138
|
+
assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
|
|
139
|
+
const injBefore = h.injected(B).length;
|
|
140
|
+
// `crtr node yield` (base → auto-promote → intent=refresh). INTERMEDIATE
|
|
141
|
+
// state, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
|
|
142
|
+
// turn_end injector has not run), intent=refresh, drift PENDING.
|
|
143
|
+
const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
|
|
144
|
+
assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
|
|
145
|
+
{
|
|
146
|
+
const b = h.node(B);
|
|
147
|
+
assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
|
|
148
|
+
assert.equal(b.intent, 'refresh', 'intent=refresh set by the yield');
|
|
149
|
+
assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector does');
|
|
150
|
+
}
|
|
151
|
+
// Fire the stop: agent_end sees intent=refresh → (b') reviveInPlace, whose
|
|
152
|
+
// drainBearings commits the ack synchronously BEFORE the respawn. NO
|
|
153
|
+
// turn_end fires this turn, so the turn_end steer site is bypassed.
|
|
154
|
+
await h.stop(B);
|
|
155
|
+
// (2) The ack is silently advanced to orchestrator at the refresh DRAIN —
|
|
156
|
+
// not by any steer. (waitFor: the agent_end handler runs after h.stop
|
|
157
|
+
// observes the recorded event.)
|
|
158
|
+
await h.waitFor(() => {
|
|
159
|
+
const a = h.node(B)?.persona_ack;
|
|
160
|
+
return a?.mode === 'orchestrator' && a?.lifecycle === 'terminal';
|
|
161
|
+
}, { timeoutMs: 20_000, label: 'persona_ack advanced at the refresh drain (not via a steer)' });
|
|
162
|
+
assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'ack committed base→orchestrator by drainBearings during reviveInPlace');
|
|
163
|
+
// (1) ⚑ LOSS site: across the whole yield→refresh, NO orchestration
|
|
164
|
+
// guidance was ever delivered as a turn-boundary STEER (the only steer
|
|
165
|
+
// site, turn_end, never ran). The ack moved without the agent ever being
|
|
166
|
+
// steered with the new-role guidance — it survives only in the kickoff
|
|
167
|
+
// prompt drainBearings built for the (here, non-booting) fresh vehicle.
|
|
168
|
+
assert.equal(orchestrationSteers(h.injected(B).slice(injBefore)).length, 0, '⚑ A4: no orchestration STEER delivered — the turn_end injector was bypassed by the yield');
|
|
169
|
+
}
|
|
170
|
+
finally {
|
|
171
|
+
const session = h.session;
|
|
172
|
+
await h.dispose();
|
|
173
|
+
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
174
|
+
}
|
|
175
|
+
});
|
|
@@ -2,8 +2,8 @@
|
|
|
2
2
|
//
|
|
3
3
|
// AXIS: LIVE MUTATION of the 2×2 state vector (mode {base,orchestrator} ×
|
|
4
4
|
// lifecycle {terminal,resident}) while a node is ACTIVE/LIVE — driven through
|
|
5
|
-
// the REAL `crtr node lifecycle` / `node promote` / `node demote`
|
|
6
|
-
// against a live fake-pi, with the REAL stophook / kickoff / daemon hooks doing
|
|
5
|
+
// the REAL `crtr node lifecycle` / `node promote` / `node demote` / `node recycle`
|
|
6
|
+
// CLI verbs against a live fake-pi, with the REAL stophook / kickoff / daemon hooks doing
|
|
7
7
|
// the work. Every assertion reads the canvas data layer and is checked against
|
|
8
8
|
// the state-model ORACLE (mq1su40t .../state-model.md).
|
|
9
9
|
//
|
|
@@ -24,6 +24,10 @@
|
|
|
24
24
|
// (idle-release vs dormant; persona-ack recompose; the A4 boundary) changes
|
|
25
25
|
// accordingly. None of the above drives `crtr node lifecycle` on a live node,
|
|
26
26
|
// nor asserts persona_ack mutation across a live promote, nor the A4 loss site.
|
|
27
|
+
//
|
|
28
|
+
// Part 2 of this axis — the demote/recycle verb split and the A4 promote-then-
|
|
29
|
+
// yield boundary — lives in live-mutation-verbs.test.ts (split for node:test
|
|
30
|
+
// file-level parallelism; each test holds its own isolated harness).
|
|
27
31
|
import { test } from 'node:test';
|
|
28
32
|
import assert from 'node:assert/strict';
|
|
29
33
|
import { spawnSync } from 'node:child_process';
|
|
@@ -45,15 +49,6 @@ function orchestrationSteers(inj) {
|
|
|
45
49
|
function persona(m) {
|
|
46
50
|
return { mode: m.mode, lifecycle: m.lifecycle };
|
|
47
51
|
}
|
|
48
|
-
/** The first %pane_id of a tmux window. The spawn path records window+session
|
|
49
|
-
* but NOT pane (spawn.ts: pane is null until a reconcile/focus), so a node's
|
|
50
|
-
* live pane must be resolved from its window here. */
|
|
51
|
-
function firstPaneOf(window) {
|
|
52
|
-
const r = spawnSync('tmux', ['list-panes', '-t', window, '-F', '#{pane_id}'], { encoding: 'utf8' });
|
|
53
|
-
if (r.status !== 0)
|
|
54
|
-
return null;
|
|
55
|
-
return r.stdout.split('\n').map((s) => s.trim()).filter(Boolean)[0] ?? null;
|
|
56
|
-
}
|
|
57
52
|
// ===========================================================================
|
|
58
53
|
// (a) LIFECYCLE FLIP — `crtr node lifecycle` on a LIVE node, both directions,
|
|
59
54
|
// observing the idle-release behavior change. A round-trip on ONE live
|
|
@@ -218,124 +213,3 @@ test('live mode flip: promote base→orchestrator recomposes persona_ack at turn
|
|
|
218
213
|
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
219
214
|
}
|
|
220
215
|
});
|
|
221
|
-
// ===========================================================================
|
|
222
|
-
// (b) MODE FLIP — demote. ⚑ FLAG vs the task framing ("demote orchestrator back
|
|
223
|
-
// to base; assert the mode field changes"): the `node demote` verb does NOT
|
|
224
|
-
// flip the SAME live node's mode orchestrator→base. Per ORACLE §4 (which
|
|
225
|
-
// matches the code: demote.ts) it FINISHES the node (push final → done) and
|
|
226
|
-
// RECYCLES the pane into a FRESH general/base/resident root — a DIFFERENT
|
|
227
|
-
// node. The demoted node keeps mode=orchestrator (it is merely `done`).
|
|
228
|
-
// There is NO live verb that flips a node orchestrator→base, so the
|
|
229
|
-
// persona.ts `baseModeGuidance` (orchestrator→base) is effectively
|
|
230
|
-
// UNREACHABLE via live mutation. This test pins the real behavior so the
|
|
231
|
-
// contradiction is visible; production is NOT changed.
|
|
232
|
-
// ===========================================================================
|
|
233
|
-
test('node demote is FINISH+RECYCLE, not an orchestrator→base mode flip (current behavior vs task framing)', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
234
|
-
const h = await createHarness({ sessionPrefix: 'crtr-live-demote' });
|
|
235
|
-
try {
|
|
236
|
-
const A = h.spawnRoot('resident root');
|
|
237
|
-
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
238
|
-
// Promote B so it is genuinely an orchestrator before we demote it.
|
|
239
|
-
assert.equal(h.cli(B, ['node', 'promote', '--kind', 'developer']).code, 0, 'promote B');
|
|
240
|
-
const b0 = h.node(B);
|
|
241
|
-
assert.equal(b0.mode, 'orchestrator', 'B is orchestrator before demote');
|
|
242
|
-
// Resolve B's live %pane_id from its window (the row's `pane` is null until
|
|
243
|
-
// a reconcile; the spawn path records only window+session).
|
|
244
|
-
const pane = firstPaneOf(b0.window);
|
|
245
|
-
assert.ok(typeof pane === 'string' && pane !== '', 'B has a live pane to recycle');
|
|
246
|
-
// DEMOTE via the real verb (TMUX_PANE is scrubbed from child env → pass --pane).
|
|
247
|
-
const res = h.cli(B, ['node', 'demote', '--node', B, '--pane', pane]);
|
|
248
|
-
assert.equal(res.code, 0, `demote exit 0\n${res.stderr}`);
|
|
249
|
-
// The leaf renders `<demoted ... finalized=".." new_root=".."/>` (not JSON).
|
|
250
|
-
assert.match(res.stdout, /<demoted /, `demote recycled the pane\n${res.stdout}`);
|
|
251
|
-
const newRoot = /new_root="([^"]+)"/.exec(res.stdout)?.[1];
|
|
252
|
-
const finalized = /finalized="true"/.test(res.stdout);
|
|
253
|
-
// ⚑ The demoted node is FINISHED, not mode-flipped.
|
|
254
|
-
{
|
|
255
|
-
const b = h.node(B);
|
|
256
|
-
assert.equal(b.status, 'done', 'demoted node → done (finished), NOT re-roled');
|
|
257
|
-
assert.equal(b.intent, 'done', 'intent=done (finalize), per the push-final path');
|
|
258
|
-
assert.equal(b.mode, 'orchestrator', '⚑ demoted node KEEPS mode=orchestrator — demote is NOT an orchestrator→base flip');
|
|
259
|
-
assert.ok(finalized, 'demote pushed a final for the node');
|
|
260
|
-
}
|
|
261
|
-
// The fresh root is a DIFFERENT, BASE×RESIDENT node — that is where "base"
|
|
262
|
-
// comes from, not a mutation of B.
|
|
263
|
-
assert.ok(typeof newRoot === 'string' && newRoot !== B, 'a fresh root (≠ B) was minted');
|
|
264
|
-
{
|
|
265
|
-
const fresh = h.node(newRoot);
|
|
266
|
-
assert.deepEqual(persona(fresh), { mode: 'base', lifecycle: 'resident' }, 'recycled root is born base×resident (general)');
|
|
267
|
-
// Born acked to its own persona → it will never see an orchestrator→base
|
|
268
|
-
// drift steer: that persona path is unreachable through live mutation.
|
|
269
|
-
assert.deepEqual(fresh.persona_ack, { mode: 'base', lifecycle: 'resident' }, 'fresh root born acked base×resident — no orchestrator→base drift will ever fire');
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
finally {
|
|
273
|
-
const session = h.session;
|
|
274
|
-
await h.dispose();
|
|
275
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
276
|
-
}
|
|
277
|
-
});
|
|
278
|
-
// ===========================================================================
|
|
279
|
-
// (b) A4 BOUNDARY — promote-then-yield emits a steer that is discarded. The
|
|
280
|
-
// oracle/flagship boundary: the base→orchestrator guidance lands as a STEER
|
|
281
|
-
// only if a turn_end fires while the drift is pending. A `node yield` on a
|
|
282
|
-
// base node auto-promotes (mode→orchestrator, ack NOT committed) and its
|
|
283
|
-
// agent_end goes STRAIGHT to reviveInPlace (b') with NO preceding turn_end —
|
|
284
|
-
// so the only steer-delivery site (turn_end) is BYPASSED. Two deterministic
|
|
285
|
-
// facts pin the boundary, both confirmed by direct observation:
|
|
286
|
-
// (1) NO orchestration STEER is ever delivered (the LOSS).
|
|
287
|
-
// (2) The ack is silently advanced base→orchestrator at the refresh DRAIN
|
|
288
|
-
// (reviveInPlace→drainBearings→commitPersonaAck), NOT via a steer — so
|
|
289
|
-
// neither this turn nor (per A4) the fresh revive re-offers it as a
|
|
290
|
-
// steer; the guidance survives only in the kickoff PROMPT it built.
|
|
291
|
-
// ⚑ FLAGGED (not fixed): the in-place refresh of this LARGE pending-drift
|
|
292
|
-
// kickoff prompt did NOT complete a fresh fake-pi boot in the harness (it
|
|
293
|
-
// stayed at 1 boot, intent=refresh, ack=orchestrator, pane alive) — a
|
|
294
|
-
// base→orchestrator yield's giant <persona-transition> kickoff pushed
|
|
295
|
-
// through respawn-pane did not bring up the fresh vehicle. Whether a real
|
|
296
|
-
// edge (oversized argv through respawn-pane) or a harness artifact, it is
|
|
297
|
-
// out of scope to fix; this test asserts only the deterministic boundary.
|
|
298
|
-
// ===========================================================================
|
|
299
|
-
test('A4: a base→orchestrator yield with no preceding turn_end loses the orchestration STEER (ack advances silently at the refresh drain)', { skip: SKIP, timeout: 120_000 }, async () => {
|
|
300
|
-
const h = await createHarness({ sessionPrefix: 'crtr-live-a4' });
|
|
301
|
-
try {
|
|
302
|
-
const A = h.spawnRoot('resident root');
|
|
303
|
-
const B = await h.spawnChild(A, 'do the work', { kind: 'developer' });
|
|
304
|
-
assert.deepEqual(persona(h.node(B)), { mode: 'base', lifecycle: 'terminal' }, 'B born base×terminal');
|
|
305
|
-
const injBefore = h.injected(B).length;
|
|
306
|
-
// `crtr node yield` (base → auto-promote → intent=refresh). INTERMEDIATE
|
|
307
|
-
// state, BEFORE any agent_end: mode flipped, ack NOT yet committed (the
|
|
308
|
-
// turn_end injector has not run), intent=refresh, drift PENDING.
|
|
309
|
-
const y = h.cli(B, ['node', 'yield', 'refresh against the roadmap']);
|
|
310
|
-
assert.equal(y.code, 0, `node yield exit 0\n${y.stderr}`);
|
|
311
|
-
{
|
|
312
|
-
const b = h.node(B);
|
|
313
|
-
assert.equal(b.mode, 'orchestrator', 'yield auto-promoted base→orchestrator');
|
|
314
|
-
assert.equal(b.intent, 'refresh', 'intent=refresh set by the yield');
|
|
315
|
-
assert.deepEqual(b.persona_ack, { mode: 'base', lifecycle: 'terminal' }, 'ack STILL base — promote/yield never commits it; only an injector does');
|
|
316
|
-
}
|
|
317
|
-
// Fire the stop: agent_end sees intent=refresh → (b') reviveInPlace, whose
|
|
318
|
-
// drainBearings commits the ack synchronously BEFORE the respawn. NO
|
|
319
|
-
// turn_end fires this turn, so the turn_end steer site is bypassed.
|
|
320
|
-
await h.stop(B);
|
|
321
|
-
// (2) The ack is silently advanced to orchestrator at the refresh DRAIN —
|
|
322
|
-
// not by any steer. (waitFor: the agent_end handler runs after h.stop
|
|
323
|
-
// observes the recorded event.)
|
|
324
|
-
await h.waitFor(() => {
|
|
325
|
-
const a = h.node(B)?.persona_ack;
|
|
326
|
-
return a?.mode === 'orchestrator' && a?.lifecycle === 'terminal';
|
|
327
|
-
}, { timeoutMs: 20_000, label: 'persona_ack advanced at the refresh drain (not via a steer)' });
|
|
328
|
-
assert.deepEqual(h.node(B).persona_ack, { mode: 'orchestrator', lifecycle: 'terminal' }, 'ack committed base→orchestrator by drainBearings during reviveInPlace');
|
|
329
|
-
// (1) ⚑ LOSS site: across the whole yield→refresh, NO orchestration
|
|
330
|
-
// guidance was ever delivered as a turn-boundary STEER (the only steer
|
|
331
|
-
// site, turn_end, never ran). The ack moved without the agent ever being
|
|
332
|
-
// steered with the new-role guidance — it survives only in the kickoff
|
|
333
|
-
// prompt drainBearings built for the (here, non-booting) fresh vehicle.
|
|
334
|
-
assert.equal(orchestrationSteers(h.injected(B).slice(injBefore)).length, 0, '⚑ A4: no orchestration STEER delivered — the turn_end injector was bypassed by the yield');
|
|
335
|
-
}
|
|
336
|
-
finally {
|
|
337
|
-
const session = h.session;
|
|
338
|
-
await h.dispose();
|
|
339
|
-
assert.equal(sessionExists(session), false, 'isolated session killed — no stray');
|
|
340
|
-
}
|
|
341
|
-
});
|