@crouton-kit/crouter 0.3.28 → 0.3.30
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/build-root.d.ts +15 -7
- package/dist/build-root.js +48 -34
- package/dist/builtin-memory/crouter-development/marketplaces.md +7 -6
- package/dist/builtin-memory/crouter-development/personas/base-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas/orchestrator-prompt.md +1 -1
- package/dist/builtin-memory/crouter-development/personas.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +20 -18
- package/dist/builtin-memory/design.md +1 -1
- package/dist/builtin-memory/development.md +1 -1
- package/dist/builtin-memory/internal/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/INDEX.md +1 -1
- package/dist/builtin-memory/internal/examples/imessage-assistant.md +2 -2
- package/dist/builtin-memory/internal/nodes-and-canvas.md +1 -1
- package/dist/builtin-memory/internal/storage-tiers.md +1 -1
- package/dist/builtin-memory/planning.md +1 -1
- package/dist/builtin-memory/spec.md +6 -4
- package/dist/builtin-personas/orchestration-kernel.md +4 -5
- package/dist/builtin-personas/runtime-base.md +5 -4
- package/dist/builtin-personas/spec/PERSONA.md +8 -4
- package/dist/builtin-personas/spec/orchestrator.md +5 -3
- package/dist/builtin-personas/spec/requirements/PERSONA.md +10 -0
- package/dist/builtin-views/canvas/core.mjs +586 -0
- package/dist/builtin-views/canvas/text.mjs +58 -0
- package/dist/builtin-views/canvas/tui.mjs +165 -0
- package/dist/builtin-views/canvas/web.jsx +120 -0
- package/dist/builtin-views/git-pr/core.mjs +673 -0
- package/dist/builtin-views/git-pr/text.mjs +84 -0
- package/dist/builtin-views/git-pr/tui.mjs +302 -0
- package/dist/builtin-views/git-pr/web.jsx +216 -0
- package/dist/builtin-views/inbox/_lib/render.mjs +1 -3
- package/dist/builtin-views/inbox/core.mjs +1273 -0
- package/dist/builtin-views/inbox/text.mjs +73 -0
- package/dist/builtin-views/inbox/tui.mjs +312 -0
- package/dist/builtin-views/inbox/web.jsx +188 -0
- package/dist/builtin-views/linkedin/core.mjs +906 -0
- package/dist/builtin-views/linkedin/text.mjs +69 -0
- package/dist/builtin-views/linkedin/tui.mjs +427 -0
- package/dist/builtin-views/linkedin/web.jsx +206 -0
- package/dist/builtin-views/workspace-sidebar/core.mjs +621 -0
- package/dist/builtin-views/workspace-sidebar/text.mjs +53 -0
- package/dist/builtin-views/workspace-sidebar/tui.mjs +142 -0
- package/dist/builtin-views/workspace-sidebar/web.jsx +109 -0
- package/dist/cli.js +22 -17
- package/dist/clients/attach/__tests__/action-parity.test.js +48 -0
- package/dist/clients/attach/__tests__/clipboard-image.test.js +76 -0
- package/dist/clients/attach/__tests__/editor-newline.test.js +57 -0
- package/dist/clients/attach/__tests__/git-info.test.js +31 -0
- package/dist/clients/attach/__tests__/onrequest-wired.test.js +20 -0
- package/dist/clients/attach/__tests__/slash-quit-copy.test.js +42 -0
- package/dist/clients/attach/__tests__/titled-editor.test.js +39 -0
- package/dist/clients/attach/attach-cmd.js +282 -46
- package/dist/clients/attach/auth-pickers.d.ts +9 -0
- package/dist/clients/attach/auth-pickers.js +194 -0
- package/dist/clients/attach/canvas-panels.d.ts +4 -2
- package/dist/clients/attach/canvas-panels.js +45 -17
- package/dist/clients/attach/chat-view.d.ts +27 -2
- package/dist/clients/attach/chat-view.js +98 -9
- package/dist/clients/attach/clipboard-image.d.ts +47 -8
- package/dist/clients/attach/clipboard-image.js +150 -26
- package/dist/clients/attach/clipboard-text.d.ts +3 -0
- package/dist/clients/attach/clipboard-text.js +39 -0
- package/dist/clients/attach/config-load.d.ts +54 -7
- package/dist/clients/attach/config-load.js +99 -3
- package/dist/clients/attach/context-message.d.ts +12 -0
- package/dist/clients/attach/context-message.js +72 -0
- package/dist/clients/attach/git-info.d.ts +20 -0
- package/dist/clients/attach/git-info.js +44 -0
- package/dist/clients/attach/graph-overlay.d.ts +13 -4
- package/dist/clients/attach/graph-overlay.js +59 -19
- package/dist/clients/attach/input-controller.d.ts +75 -13
- package/dist/clients/attach/input-controller.js +305 -67
- package/dist/clients/attach/pickers.d.ts +58 -0
- package/dist/clients/attach/pickers.js +171 -0
- package/dist/clients/attach/slash-commands.d.ts +22 -0
- package/dist/clients/attach/slash-commands.js +91 -25
- package/dist/clients/attach/titled-editor.d.ts +33 -0
- package/dist/clients/attach/titled-editor.js +84 -0
- package/dist/clients/attach/view-socket.d.ts +18 -1
- package/dist/clients/attach/view-socket.js +70 -1
- package/dist/clients/web/dev-server.d.ts +7 -0
- package/dist/clients/web/dev-server.js +59 -0
- package/dist/clients/web/events.d.ts +14 -0
- package/dist/clients/web/events.js +151 -0
- package/dist/clients/web/server.d.ts +18 -0
- package/dist/clients/web/server.js +450 -0
- package/dist/clients/web/web-cmd.d.ts +2 -0
- package/dist/clients/web/web-cmd.js +120 -0
- package/dist/commands/canvas.js +1 -2
- package/dist/commands/chord.js +4 -25
- package/dist/commands/dashboard.js +5 -1
- package/dist/commands/memory/__tests__/lint-schema.test.js +6 -6
- package/dist/commands/memory/lint.js +14 -2
- package/dist/commands/memory/read.js +2 -2
- package/dist/commands/memory/shared.d.ts +7 -1
- package/dist/commands/memory/shared.js +18 -3
- package/dist/commands/memory/write.js +8 -4
- package/dist/commands/memory.js +4 -4
- package/dist/commands/node.d.ts +1 -1
- package/dist/commands/node.js +64 -65
- package/dist/commands/pkg.js +1 -2
- package/dist/commands/revive.js +11 -3
- package/dist/commands/sys/sync.d.ts +1 -0
- package/dist/commands/sys/sync.js +187 -0
- package/dist/commands/sys.js +3 -2
- package/dist/commands/view-cycle.js +2 -2
- package/dist/commands/view-list.js +8 -8
- package/dist/commands/view-new.js +21 -17
- package/dist/commands/view-pick.js +1 -1
- package/dist/commands/view-run.js +35 -14
- package/dist/commands/view.js +8 -6
- package/dist/commands/workspace.d.ts +2 -0
- package/dist/commands/workspace.js +161 -0
- package/dist/core/__tests__/broker-double-spawn.test.d.ts +1 -0
- package/dist/core/__tests__/broker-double-spawn.test.js +143 -0
- package/dist/core/__tests__/broker-fork-seam.test.d.ts +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +102 -0
- package/dist/core/__tests__/broker-sdk-wiring.test.js +2 -0
- package/dist/core/__tests__/child-death-wake.test.js +56 -111
- package/dist/core/__tests__/close.test.js +14 -20
- package/dist/core/__tests__/context-intro.test.js +19 -19
- package/dist/core/__tests__/daemon-boot.test.js +30 -23
- package/dist/core/__tests__/draw-style.test.js +1 -1
- package/dist/core/__tests__/error-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/error-stall-recycle.test.js +141 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +17 -1
- package/dist/core/__tests__/fixtures/fake-engine.js +22 -2
- package/dist/core/__tests__/fixtures/fake-pi-host.js +2 -2
- package/dist/core/__tests__/focuses.test.js +25 -13
- package/dist/core/__tests__/fork.test.js +22 -5
- package/dist/core/__tests__/full/broker-control-preempt.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-control-preempt.test.js +61 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-model-changed-broadcast.test.js +57 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.d.ts +1 -0
- package/dist/core/__tests__/full/broker-navigate-tree-rewelcome.test.js +80 -0
- package/dist/core/__tests__/full/broker-pane-resolution.test.js +90 -0
- package/dist/core/__tests__/full/cascade-close.test.js +22 -8
- package/dist/core/__tests__/full/detach-focus.test.js +66 -169
- package/dist/core/__tests__/helpers/harness.d.ts +6 -9
- package/dist/core/__tests__/helpers/harness.js +19 -36
- package/dist/core/__tests__/human-node-not-supervised.test.d.ts +1 -0
- package/dist/core/__tests__/human-node-not-supervised.test.js +83 -0
- package/dist/core/__tests__/lifecycle.test.js +1 -19
- package/dist/core/__tests__/listing-completeness.test.js +2 -2
- package/dist/core/__tests__/live-mutation-verbs.test.js +5 -5
- package/dist/core/__tests__/memory-resolver.test.js +1 -1
- package/dist/core/__tests__/on-read-dedup-resume.test.js +2 -2
- package/dist/core/__tests__/refresh-stall-recycle.test.d.ts +1 -0
- package/dist/core/__tests__/refresh-stall-recycle.test.js +88 -0
- package/dist/core/__tests__/relaunch-root.test.d.ts +1 -0
- package/dist/core/__tests__/relaunch-root.test.js +114 -0
- package/dist/core/__tests__/reset.test.js +19 -79
- package/dist/core/__tests__/steer-note.test.js +9 -7
- package/dist/core/__tests__/tmux-surface.test.js +13 -7
- package/dist/core/bootstrap.d.ts +0 -3
- package/dist/core/bootstrap.js +1 -143
- package/dist/core/canvas/browse/__tests__/model.test.js +86 -1
- package/dist/core/canvas/browse/__tests__/render.test.js +2 -1
- package/dist/core/canvas/browse/app.js +131 -8
- package/dist/core/canvas/browse/model.d.ts +23 -5
- package/dist/core/canvas/browse/model.js +68 -13
- package/dist/core/canvas/browse/render.d.ts +7 -1
- package/dist/core/canvas/browse/render.js +178 -47
- package/dist/core/canvas/canvas.js +1 -1
- package/dist/core/canvas/focuses.d.ts +4 -4
- package/dist/core/canvas/focuses.js +7 -7
- package/dist/core/canvas/nav-model.js +8 -7
- package/dist/core/canvas/render.d.ts +68 -9
- package/dist/core/canvas/render.js +195 -61
- package/dist/core/canvas/types.d.ts +16 -23
- package/dist/core/config.js +1 -4
- package/dist/core/memory-resolver.js +43 -5
- package/dist/core/runtime/bearings.d.ts +1 -1
- package/dist/core/runtime/bearings.js +8 -8
- package/dist/core/runtime/branded-host.d.ts +16 -0
- package/dist/core/runtime/branded-host.js +127 -0
- package/dist/core/runtime/broker-protocol.d.ts +212 -6
- package/dist/core/runtime/broker.d.ts +4 -1
- package/dist/core/runtime/broker.js +494 -58
- package/dist/core/runtime/close.d.ts +12 -2
- package/dist/core/runtime/close.js +37 -16
- package/dist/core/runtime/front-door.js +6 -14
- package/dist/core/runtime/host.d.ts +14 -34
- package/dist/core/runtime/host.js +14 -50
- package/dist/core/runtime/launch.d.ts +12 -9
- package/dist/core/runtime/launch.js +27 -15
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +15 -19
- package/dist/core/runtime/nodes.d.ts +0 -55
- package/dist/core/runtime/nodes.js +6 -74
- package/dist/core/runtime/placement.d.ts +91 -335
- package/dist/core/runtime/placement.js +291 -858
- package/dist/core/runtime/promote.d.ts +2 -0
- package/dist/core/runtime/promote.js +24 -6
- package/dist/core/runtime/recap.d.ts +8 -0
- package/dist/core/runtime/recap.js +107 -0
- package/dist/core/runtime/recycle.js +25 -61
- package/dist/core/runtime/reset.d.ts +43 -37
- package/dist/core/runtime/reset.js +131 -218
- package/dist/core/runtime/revive.d.ts +9 -29
- package/dist/core/runtime/revive.js +40 -139
- package/dist/core/runtime/spawn.d.ts +15 -16
- package/dist/core/runtime/spawn.js +165 -194
- package/dist/core/runtime/surface-bg.d.ts +11 -0
- package/dist/core/runtime/surface-bg.js +68 -0
- package/dist/core/runtime/tmux.d.ts +15 -52
- package/dist/core/runtime/tmux.js +35 -99
- package/dist/core/scope.d.ts +1 -2
- package/dist/core/scope.js +1 -5
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.d.ts +1 -0
- package/dist/core/skill-sync/__tests__/dry-run-wrote-count.test.js +57 -0
- package/dist/core/skill-sync/builtins.d.ts +42 -0
- package/dist/core/skill-sync/builtins.js +112 -0
- package/dist/core/skill-sync/claude-plugins.d.ts +23 -0
- package/dist/core/skill-sync/claude-plugins.js +71 -0
- package/dist/core/skill-sync/engine.d.ts +42 -0
- package/dist/core/skill-sync/engine.js +633 -0
- package/dist/core/skill-sync/export.d.ts +23 -0
- package/dist/core/skill-sync/export.js +86 -0
- package/dist/core/skill-sync/manifest.d.ts +64 -0
- package/dist/core/skill-sync/manifest.js +181 -0
- package/dist/core/skill-sync/profile.d.ts +76 -0
- package/dist/core/skill-sync/profile.js +173 -0
- package/dist/core/skill-sync/snapshot.d.ts +57 -0
- package/dist/core/skill-sync/snapshot.js +120 -0
- package/dist/core/substrate/index.d.ts +1 -1
- package/dist/core/substrate/index.js +1 -1
- package/dist/core/substrate/injected-store.js +3 -3
- package/dist/core/substrate/on-read.js +68 -6
- package/dist/core/substrate/render.d.ts +8 -11
- package/dist/core/substrate/render.js +29 -43
- package/dist/core/substrate/schema.d.ts +10 -3
- package/dist/core/substrate/schema.js +6 -3
- package/dist/core/tui/host.d.ts +12 -4
- package/dist/core/tui/host.js +280 -149
- package/dist/core/view/bridge.d.ts +10 -0
- package/dist/core/view/bridge.js +31 -0
- package/dist/core/view/chrome.d.ts +9 -0
- package/dist/core/view/chrome.js +22 -0
- package/dist/core/view/contract.d.ts +171 -0
- package/dist/core/view/contract.js +23 -0
- package/dist/core/view/loader.d.ts +31 -0
- package/dist/core/view/loader.js +188 -0
- package/dist/core/view/transport-local.d.ts +7 -0
- package/dist/core/view/transport-local.js +70 -0
- package/dist/core/view/transport.d.ts +4 -0
- package/dist/core/view/transport.js +15 -0
- package/dist/daemon/crtrd.d.ts +38 -27
- package/dist/daemon/crtrd.js +303 -376
- package/dist/daemon/manage.js +6 -1
- package/dist/index.js +1 -1
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +28 -20
- package/dist/pi-extensions/canvas-context-intro.js +4 -4
- package/dist/pi-extensions/canvas-doc-substrate.js +16 -16
- package/dist/pi-extensions/canvas-nav.js +5 -0
- package/dist/pi-extensions/canvas-recap.d.ts +37 -0
- package/dist/pi-extensions/canvas-recap.js +236 -0
- package/dist/pi-extensions/canvas-resume.js +2 -1
- package/dist/pi-extensions/canvas-stophook.d.ts +14 -12
- package/dist/pi-extensions/canvas-stophook.js +87 -101
- package/dist/pi-extensions/canvas-view.js +2 -1
- package/dist/pi-extensions/widget-order-bus.d.ts +6 -0
- package/dist/pi-extensions/widget-order-bus.js +34 -0
- package/dist/prompts/view.d.ts +2 -2
- package/dist/prompts/view.js +145 -73
- package/dist/types.d.ts +1 -6
- package/dist/types.js +1 -3
- package/dist/web/ViewChrome.d.ts +7 -0
- package/dist/web/ViewChrome.js +28 -0
- package/dist/web/ViewPane.d.ts +39 -0
- package/dist/web/ViewPane.js +48 -0
- package/dist/web/index.d.ts +6 -0
- package/dist/web/index.js +16 -0
- package/dist/web/runtime.d.ts +39 -0
- package/dist/web/runtime.js +133 -0
- package/dist/web/states.d.ts +24 -0
- package/dist/web/states.js +24 -0
- package/dist/web/transport-http.d.ts +5 -0
- package/dist/web/transport-http.js +28 -0
- package/dist/web-client/assets/index-BUvQb4hR.css +2 -0
- package/dist/web-client/assets/index-ClLQXYAE.js +10 -0
- package/dist/web-client/index.html +13 -0
- package/package.json +20 -6
- package/dist/builtin-views/canvas/client.mjs +0 -303
- package/dist/builtin-views/canvas/view.mjs +0 -576
- package/dist/builtin-views/git-pr/client.mjs +0 -440
- package/dist/builtin-views/git-pr/view.mjs +0 -675
- package/dist/builtin-views/inbox/sources/gmail.mjs +0 -965
- package/dist/builtin-views/inbox/sources/linkedin.mjs +0 -427
- package/dist/builtin-views/inbox/view.mjs +0 -889
- package/dist/builtin-views/linkedin/client.mjs +0 -610
- package/dist/builtin-views/linkedin/view.mjs +0 -1171
- package/dist/commands/pkg/bridge.d.ts +0 -1
- package/dist/commands/pkg/bridge.js +0 -137
- package/dist/commands/skill/author.d.ts +0 -3
- package/dist/commands/skill/author.js +0 -140
- package/dist/commands/skill/shared.d.ts +0 -3
- package/dist/commands/skill/shared.js +0 -19
- package/dist/commands/skill.d.ts +0 -2
- package/dist/commands/skill.js +0 -21
- package/dist/commands/tmux-spread.d.ts +0 -2
- package/dist/commands/tmux-spread.js +0 -144
- package/dist/core/__tests__/full/daemon-liveness-pane.full.test.js +0 -292
- package/dist/core/__tests__/full/placement-focus.test.js +0 -309
- package/dist/core/__tests__/full/placement-reconcile.test.js +0 -212
- package/dist/core/__tests__/full/placement-revive.test.js +0 -238
- package/dist/core/__tests__/full/placement-teardown.test.js +0 -297
- package/dist/core/__tests__/home-session.test.js +0 -186
- package/dist/core/__tests__/relaunch.test.js +0 -335
- package/dist/core/bridge-map.d.ts +0 -19
- package/dist/core/bridge-map.js +0 -73
- package/dist/core/tui/contract.d.ts +0 -83
- package/dist/core/tui/contract.js +0 -8
- package/dist/core/tui/loader.d.ts +0 -16
- package/dist/core/tui/loader.js +0 -94
- package/dist/prompts/skill.d.ts +0 -2
- package/dist/prompts/skill.js +0 -650
- /package/dist/{core/__tests__/full/daemon-liveness-pane.full.test.d.ts → clients/attach/__tests__/action-parity.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-focus.test.d.ts → clients/attach/__tests__/clipboard-image.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-reconcile.test.d.ts → clients/attach/__tests__/editor-newline.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-revive.test.d.ts → clients/attach/__tests__/git-info.test.d.ts} +0 -0
- /package/dist/{core/__tests__/full/placement-teardown.test.d.ts → clients/attach/__tests__/onrequest-wired.test.d.ts} +0 -0
- /package/dist/{core/__tests__/home-session.test.d.ts → clients/attach/__tests__/slash-quit-copy.test.d.ts} +0 -0
- /package/dist/{core/__tests__/relaunch.test.d.ts → clients/attach/__tests__/titled-editor.test.d.ts} +0 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/error-stall-recycle.test.ts
|
|
2
|
+
//
|
|
3
|
+
// BUG-REGRESSION (§I error-stall; gh issue #4 — node mqaeqzdu-0746b3b7, a 9h
|
|
4
|
+
// zombie). A provider outage exhausted the engine's SDK retry budget mid-task:
|
|
5
|
+
// pi appended trailing stopReason:'error' assistant messages to the session
|
|
6
|
+
// .jsonl and PARKED (the stophook's agent_end returns early on 'error' — "stay
|
|
7
|
+
// alive for re-steering"). A headless node has no human to re-steer it, so the
|
|
8
|
+
// broker stayed alive, status stayed 'active', and the daemon's pid-only
|
|
9
|
+
// liveness check read it healthy for 9 hours while its parent believed it was
|
|
10
|
+
// working. The daemon is the authority: a live, not-busy, intent-null engine
|
|
11
|
+
// whose session file has been quiet past ERROR_STALL_QUIET_MS and whose last
|
|
12
|
+
// assistant message stopped with 'error' is force-killed, so the ordinary
|
|
13
|
+
// dead-pid crash path grace-revives RESUME on the saved session — the exact
|
|
14
|
+
// manual recovery (kill broker + canvas revive) that fixed the zombie by hand.
|
|
15
|
+
//
|
|
16
|
+
// Three legs: the pure truth table (E1 — errorStallVerdict), the tail-signature
|
|
17
|
+
// parser (E2 — trailingEngineError), and the daemon integration over a real
|
|
18
|
+
// live pid the daemon must SIGTERM (E3 — superviseTick with an injected clock).
|
|
19
|
+
// Regression check: drop §I and E3's "engine killed" assert goes RED (the
|
|
20
|
+
// parked error engine lives forever, never recovered).
|
|
21
|
+
import { test } from 'node:test';
|
|
22
|
+
import assert from 'node:assert/strict';
|
|
23
|
+
import { spawn } from 'node:child_process';
|
|
24
|
+
import { writeFileSync, utimesSync, mkdtempSync, rmSync } from 'node:fs';
|
|
25
|
+
import { join } from 'node:path';
|
|
26
|
+
import { tmpdir } from 'node:os';
|
|
27
|
+
import { createHeadlessHarness } from './helpers/harness.js';
|
|
28
|
+
import { errorStallVerdict, trailingEngineError, ERROR_STALL_QUIET_MS, isPidAlive, } from '../../daemon/crtrd.js';
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
// Session-jsonl line builders (the shape pi writes: {type:'message', message}).
|
|
31
|
+
// ---------------------------------------------------------------------------
|
|
32
|
+
function line(message) {
|
|
33
|
+
return JSON.stringify({ type: 'message', id: 'x', timestamp: new Date().toISOString(), message });
|
|
34
|
+
}
|
|
35
|
+
const assistant = (stopReason, errorMessage) => line({ role: 'assistant', stopReason, ...(errorMessage === undefined ? {} : { errorMessage }) });
|
|
36
|
+
const toolResult = () => line({ role: 'toolResult' });
|
|
37
|
+
const user = () => line({ role: 'user' });
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// E1 — the pure error-stall truth table.
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
test('E1 errorStallVerdict: kill ONLY a live, not-busy, intent-null engine, quiet past grace, with a trailing error', () => {
|
|
42
|
+
const G = ERROR_STALL_QUIET_MS;
|
|
43
|
+
// The one 'kill' case.
|
|
44
|
+
assert.equal(errorStallVerdict(true, null, false, G, true), 'kill', 'alive+no-intent+!busy+quiet>=grace+error → kill');
|
|
45
|
+
assert.equal(errorStallVerdict(true, null, false, G + 60_000, true), 'kill', 'well past grace → kill');
|
|
46
|
+
// Recent session activity → leave (something is still making progress).
|
|
47
|
+
assert.equal(errorStallVerdict(true, null, false, G - 1, true), 'leave', 'within quiet grace → leave');
|
|
48
|
+
assert.equal(errorStallVerdict(true, null, false, null, true), 'leave', 'unknown quiet span → leave');
|
|
49
|
+
// No trailing error → a healthy park (attended root, awaiting node) — leave.
|
|
50
|
+
assert.equal(errorStallVerdict(true, null, false, G + 60_000, false), 'leave', 'last turn not an error → leave');
|
|
51
|
+
// A working engine is NEVER killed (wedged-on-bash is recovered by killing the
|
|
52
|
+
// subprocess, not the node).
|
|
53
|
+
assert.equal(errorStallVerdict(true, null, true, G + 60_000, true), 'leave', 'busy (mid-turn) → leave');
|
|
54
|
+
// A pending intent belongs to other paths (§H refresh; idle-release dormancy).
|
|
55
|
+
assert.equal(errorStallVerdict(true, 'refresh', false, G + 60_000, true), 'leave', 'intent=refresh → leave (§H owns it)');
|
|
56
|
+
assert.equal(errorStallVerdict(true, 'idle-release', false, G + 60_000, true), 'leave', 'intent=idle-release → leave');
|
|
57
|
+
// A dead/unknown pid is the ordinary revive path's job.
|
|
58
|
+
assert.equal(errorStallVerdict(false, null, false, G + 60_000, true), 'leave', 'dead pid → leave');
|
|
59
|
+
assert.equal(errorStallVerdict(null, null, false, G + 60_000, true), 'leave', 'unknown pid → leave');
|
|
60
|
+
});
|
|
61
|
+
// ---------------------------------------------------------------------------
|
|
62
|
+
// E2 — the tail-signature parser.
|
|
63
|
+
// ---------------------------------------------------------------------------
|
|
64
|
+
test('E2 trailingEngineError: only a trailing error assistant turn matches', () => {
|
|
65
|
+
// The observed zombie shape: trailing zero-token error assistant messages.
|
|
66
|
+
assert.equal(trailingEngineError([assistant('toolUse'), toolResult(), assistant('error', 'Connection error.')].join('\n')), 'Connection error.', 'trailing error turn → its errorMessage');
|
|
67
|
+
// Trailing non-assistant lines (toolResults, an injected user message that
|
|
68
|
+
// never got a response) are walked past to the last assistant turn.
|
|
69
|
+
assert.equal(trailingEngineError([assistant('error', 'Connection error.'), user()].join('\n')), 'Connection error.', 'trailing injected user line is walked past');
|
|
70
|
+
// A healthy park / human abort is NOT a wedge.
|
|
71
|
+
assert.equal(trailingEngineError([assistant('error', 'x'), toolResult(), assistant('stop')].join('\n')), null, 'recovered after an earlier error → null');
|
|
72
|
+
assert.equal(trailingEngineError(assistant('aborted')), null, 'human Esc → null');
|
|
73
|
+
// A tail cut mid-line (the 64KB boundary) is skipped, not fatal.
|
|
74
|
+
assert.equal(trailingEngineError(['ut"}}garbage-fragment', assistant('error', 'boom')].join('\n')), 'boom', 'cut first fragment is skipped');
|
|
75
|
+
// An error turn with no errorMessage still reads as a wedge.
|
|
76
|
+
assert.notEqual(trailingEngineError(assistant('error')), null, 'error with no errorMessage still matches');
|
|
77
|
+
assert.equal(trailingEngineError(null), null, 'no tail → null');
|
|
78
|
+
assert.equal(trailingEngineError(''), null, 'empty tail → null');
|
|
79
|
+
});
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
// E3 — daemon integration: a parked trailing-error engine is force-recycled.
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
const T0 = 9_000_000_000;
|
|
84
|
+
test('E3 daemon force-kills a wedged engine: live, not-busy, intent-null, session quiet past grace with a trailing error turn', { timeout: 30_000 }, async () => {
|
|
85
|
+
const h = await createHeadlessHarness({ sessionPrefix: 'crtr-e3' });
|
|
86
|
+
const dir = mkdtempSync(join(tmpdir(), 'crtr-e3-'));
|
|
87
|
+
// Real live subprocesses standing in for the parked engines; `sleep` dies on
|
|
88
|
+
// SIGTERM, so the daemon's force-kill is directly observable.
|
|
89
|
+
const wedged = spawn('sleep', ['300'], { stdio: 'ignore' });
|
|
90
|
+
const parked = spawn('sleep', ['300'], { stdio: 'ignore' });
|
|
91
|
+
try {
|
|
92
|
+
// The WEDGED node: session ends in an error turn, quiet for > the grace.
|
|
93
|
+
const wedgedFile = join(dir, 'wedged.jsonl');
|
|
94
|
+
writeFileSync(wedgedFile, [assistant('toolUse'), toolResult(), assistant('error', 'Connection error.')].join('\n') + '\n');
|
|
95
|
+
const staleSec = (T0 - ERROR_STALL_QUIET_MS - 1) / 1000;
|
|
96
|
+
utimesSync(wedgedFile, staleSec, staleSec);
|
|
97
|
+
const wedgedId = h.fabricateBrokerNode({
|
|
98
|
+
kind: 'developer',
|
|
99
|
+
status: 'active',
|
|
100
|
+
pi_pid: wedged.pid,
|
|
101
|
+
pi_session_id: 'sess-e3-wedged',
|
|
102
|
+
pi_session_file: wedgedFile,
|
|
103
|
+
});
|
|
104
|
+
// The CONTROL node: equally old and quiet, but its last turn is a healthy
|
|
105
|
+
// 'stop' (an attended root overnight) — must NEVER be killed.
|
|
106
|
+
const parkedFile = join(dir, 'parked.jsonl');
|
|
107
|
+
writeFileSync(parkedFile, [assistant('toolUse'), toolResult(), assistant('stop')].join('\n') + '\n');
|
|
108
|
+
utimesSync(parkedFile, staleSec, staleSec);
|
|
109
|
+
h.fabricateBrokerNode({
|
|
110
|
+
kind: 'developer',
|
|
111
|
+
status: 'active',
|
|
112
|
+
pi_pid: parked.pid,
|
|
113
|
+
pi_session_id: 'sess-e3-parked',
|
|
114
|
+
pi_session_file: parkedFile,
|
|
115
|
+
});
|
|
116
|
+
assert.equal(isPidAlive(wedged.pid), true, 'precondition: the wedged engine is alive');
|
|
117
|
+
// --- TICK @ T0: the session has already been quiet past the grace (the
|
|
118
|
+
// file mtime is the clock — no arming tick), so the daemon SIGTERMs
|
|
119
|
+
// the wedged engine on sight. ---
|
|
120
|
+
await h.tick(T0);
|
|
121
|
+
await h.waitFor(() => (isPidAlive(wedged.pid) ? null : true), {
|
|
122
|
+
label: 'daemon SIGTERM killed the wedged trailing-error engine (§I)',
|
|
123
|
+
timeoutMs: 10_000,
|
|
124
|
+
});
|
|
125
|
+
assert.equal(isPidAlive(wedged.pid), false, 'the wedged engine was force-killed');
|
|
126
|
+
assert.equal(isPidAlive(parked.pid), true, 'the healthily-parked engine was NOT touched');
|
|
127
|
+
assert.equal(h.node(wedgedId).intent ?? null, null, 'intent stays null — the dead-pid CRASH branch owns the revive (resume)');
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
for (const c of [wedged, parked]) {
|
|
131
|
+
try {
|
|
132
|
+
c.kill('SIGKILL');
|
|
133
|
+
}
|
|
134
|
+
catch {
|
|
135
|
+
/* already gone */
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
rmSync(dir, { recursive: true, force: true });
|
|
139
|
+
await h.dispose();
|
|
140
|
+
}
|
|
141
|
+
});
|
|
@@ -92,6 +92,10 @@ declare class FakeSession {
|
|
|
92
92
|
get model(): {
|
|
93
93
|
id: string;
|
|
94
94
|
} | undefined;
|
|
95
|
+
private currentModel;
|
|
96
|
+
setModel(model: {
|
|
97
|
+
id: string;
|
|
98
|
+
}): Promise<void>;
|
|
95
99
|
get isStreaming(): boolean;
|
|
96
100
|
getSessionStats(): Record<string, unknown>;
|
|
97
101
|
setSessionName(_name: string): void;
|
|
@@ -99,6 +103,15 @@ declare class FakeSession {
|
|
|
99
103
|
steer(_text: string): Promise<void>;
|
|
100
104
|
followUp(_text: string): Promise<void>;
|
|
101
105
|
abort(): Promise<void>;
|
|
106
|
+
/** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
|
|
107
|
+
* contract: the session file is unchanged, the live history is truncated,
|
|
108
|
+
* and the navigated-to user message's text comes back as `editorText`. The
|
|
109
|
+
* fake's "rewind" drops the last accrued turn so a re-welcome snapshot is
|
|
110
|
+
* observably shorter (the navigate-tree-rewelcome regression lock). */
|
|
111
|
+
navigateTree(targetId: string, _options?: unknown): Promise<{
|
|
112
|
+
editorText?: string;
|
|
113
|
+
cancelled: boolean;
|
|
114
|
+
}>;
|
|
102
115
|
subscribe(listener: (event: unknown) => void): () => void;
|
|
103
116
|
/** Fan one typed AgentSessionEvent out to every broker subscriber. A throwing
|
|
104
117
|
* listener is recorded, never propagated (mirrors the broker's m7 try/catch). */
|
|
@@ -124,7 +137,10 @@ declare class FakeSession {
|
|
|
124
137
|
interface FakeServices {
|
|
125
138
|
resourceLoader: ResourceLoader;
|
|
126
139
|
modelRegistry: {
|
|
127
|
-
find: (provider: string, id: string) =>
|
|
140
|
+
find: (provider: string, id: string) => {
|
|
141
|
+
provider: string;
|
|
142
|
+
id: string;
|
|
143
|
+
} | undefined;
|
|
128
144
|
};
|
|
129
145
|
}
|
|
130
146
|
export declare function createAgentSessionServices(options: {
|
|
@@ -184,7 +184,15 @@ class FakeSession {
|
|
|
184
184
|
return this.sm.getSessionFile();
|
|
185
185
|
}
|
|
186
186
|
get model() {
|
|
187
|
-
return
|
|
187
|
+
return this.currentModel;
|
|
188
|
+
}
|
|
189
|
+
// set_model regression surface: the broker resolves the spec via the services
|
|
190
|
+
// registry stub below, calls setModel, then broadcasts `model_changed` off
|
|
191
|
+
// this getter — store whatever it hands us.
|
|
192
|
+
currentModel;
|
|
193
|
+
setModel(model) {
|
|
194
|
+
this.currentModel = model;
|
|
195
|
+
return Promise.resolve();
|
|
188
196
|
}
|
|
189
197
|
get isStreaming() {
|
|
190
198
|
return this.streaming;
|
|
@@ -215,6 +223,15 @@ class FakeSession {
|
|
|
215
223
|
abort() {
|
|
216
224
|
return Promise.resolve();
|
|
217
225
|
}
|
|
226
|
+
/** In-place tree rewind (broker `navigate_tree`). Mirrors the real SDK's
|
|
227
|
+
* contract: the session file is unchanged, the live history is truncated,
|
|
228
|
+
* and the navigated-to user message's text comes back as `editorText`. The
|
|
229
|
+
* fake's "rewind" drops the last accrued turn so a re-welcome snapshot is
|
|
230
|
+
* observably shorter (the navigate-tree-rewelcome regression lock). */
|
|
231
|
+
navigateTree(targetId, _options) {
|
|
232
|
+
this.messageLog.pop();
|
|
233
|
+
return Promise.resolve({ cancelled: false, editorText: `rewound-to:${targetId}` });
|
|
234
|
+
}
|
|
218
235
|
// --- the single engine event stream the broker fans out (broker.ts subscribes
|
|
219
236
|
// here and broadcasts each event VERBATIM to every attached viewer) --------
|
|
220
237
|
subscribe(listener) {
|
|
@@ -581,7 +598,10 @@ export async function createAgentSessionServices(options) {
|
|
|
581
598
|
await loader.reload();
|
|
582
599
|
return {
|
|
583
600
|
resourceLoader: loader,
|
|
584
|
-
|
|
601
|
+
// A minimal model stub for any spec: the lifecycle harness passes no boot
|
|
602
|
+
// model (so this is never hit at boot), but the set_model regression test
|
|
603
|
+
// drives the broker's findModelSpec → session.setModel path through it.
|
|
604
|
+
modelRegistry: { find: (provider, id) => ({ provider, id }) },
|
|
585
605
|
};
|
|
586
606
|
}
|
|
587
607
|
export async function createAgentSessionFromServices(options) {
|
|
@@ -129,7 +129,7 @@ let eventSeq = 0;
|
|
|
129
129
|
// Append a durable record of every fired event BEFORE its handlers run. This is
|
|
130
130
|
// the harness's robust "the host received my command and is dispatching it"
|
|
131
131
|
// signal — it survives even when a handler tears the process down mid-flight
|
|
132
|
-
// (e.g. agent_end
|
|
132
|
+
// (e.g. agent_end on a refresh/done node drives the broker shutdown), which is
|
|
133
133
|
// exactly the case where an after-the-fact ack would be lost.
|
|
134
134
|
function recordEvent(event, ev) {
|
|
135
135
|
try {
|
|
@@ -219,7 +219,7 @@ const boot = {
|
|
|
219
219
|
};
|
|
220
220
|
writeFileSync(join(nodeDir, 'fake-pi.boot.json'), JSON.stringify(boot, null, 2));
|
|
221
221
|
// Append-only boot log so the harness can count re-boots (a resume after an
|
|
222
|
-
// idle-release wake, or a fresh pi after a refresh-yield
|
|
222
|
+
// idle-release wake, or a fresh pi after a refresh-yield).
|
|
223
223
|
try {
|
|
224
224
|
appendFileSync(join(nodeDir, 'fake-pi.boots.jsonl'), JSON.stringify(boot) + '\n');
|
|
225
225
|
}
|
|
@@ -11,7 +11,8 @@
|
|
|
11
11
|
// is rejected (upholds "a node occupies <=1 focus", Q5)
|
|
12
12
|
// - independent focus rows don't contend
|
|
13
13
|
// - placement focusOf / isFocused / focusByPane / focusedNodes / listFocuses
|
|
14
|
-
//
|
|
14
|
+
// GC dead-pane viewer rows on read (liveOrPrune), and pass null-pane rows
|
|
15
|
+
// through (broker-host cut: placement reads self-heal the viewer registry)
|
|
15
16
|
import { test, before, beforeEach, after } from 'node:test';
|
|
16
17
|
import assert from 'node:assert/strict';
|
|
17
18
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
@@ -136,7 +137,7 @@ test('open / setOccupant / close round-trip with the reads', () => {
|
|
|
136
137
|
setFocusOccupant('f1', 'B');
|
|
137
138
|
assert.equal(getFocusByNode('A'), null, 'A no longer occupies the focus');
|
|
138
139
|
assert.deepEqual(getFocusByNode('B'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'B' });
|
|
139
|
-
// setFocusPane re-points the pane + session cache
|
|
140
|
+
// setFocusPane re-points the pane + session cache after a viewer move.
|
|
140
141
|
setFocusPane('f1', '%a2', 'Sa2');
|
|
141
142
|
assert.deepEqual(getFocusById('f1'), { focus_id: 'f1', pane: '%a2', session: 'Sa2', node_id: 'B' });
|
|
142
143
|
assert.equal(getFocusByPane('%a'), null, 'the old pane no longer resolves');
|
|
@@ -161,7 +162,7 @@ test('UNIQUE(node_id): hot-swapping an occupant onto an already-focused node is
|
|
|
161
162
|
openFocusRow('f1', '%a', 'Sa', 'A');
|
|
162
163
|
openFocusRow('f2', '%b', 'Sb', 'B');
|
|
163
164
|
// B already occupies f2 — moving it onto f1 via setFocusOccupant must throw
|
|
164
|
-
// (the Q5 vacate-first is
|
|
165
|
+
// (the Q5 vacate-first is placement.focus()'s job, not this setter's).
|
|
165
166
|
assert.throws(() => setFocusOccupant('f1', 'B'), /UNIQUE|constraint/i);
|
|
166
167
|
assert.deepEqual(getFocusByNode('A'), { focus_id: 'f1', pane: '%a', session: 'Sa', node_id: 'A' });
|
|
167
168
|
assert.deepEqual(getFocusByNode('B'), { focus_id: 'f2', pane: '%b', session: 'Sb', node_id: 'B' });
|
|
@@ -179,18 +180,29 @@ test('independent focus rows do not contend', () => {
|
|
|
179
180
|
assert.deepEqual(listFocuses().map((f) => f.focus_id), ['f2']);
|
|
180
181
|
});
|
|
181
182
|
// ---------------------------------------------------------------------------
|
|
182
|
-
// Placement reads — focusOf / isFocused / focusByPane / focusedNodes /
|
|
183
|
-
//
|
|
183
|
+
// Placement reads — focusOf / isFocused / focusByPane / focusedNodes /
|
|
184
|
+
// listFocuses GC dead-pane viewer rows on read (broker-host cut: liveOrPrune).
|
|
185
|
+
// A row whose viewer pane no longer exists is pruned the next time it is read,
|
|
186
|
+
// so the registry self-heals without a sweeper; a row with a null pane (a
|
|
187
|
+
// registered-but-not-yet-realized viewer) is passed through. A live tmux pane
|
|
188
|
+
// can't be fabricated in the fast tier, so we cover the prune + null-pane paths
|
|
189
|
+
// here; the live-pane read is covered in the full-tier broker lifecycle suite.
|
|
184
190
|
// ---------------------------------------------------------------------------
|
|
185
|
-
test('placement
|
|
191
|
+
test('placement reads GC dead-pane viewer rows and keep null-pane rows', () => {
|
|
186
192
|
openDb();
|
|
187
|
-
openFocusRow('f1', '%a', 'Sa', 'A');
|
|
188
|
-
openFocusRow('f2',
|
|
189
|
-
|
|
193
|
+
openFocusRow('f1', '%a', 'Sa', 'A'); // %a is not a live tmux pane → pruned on read
|
|
194
|
+
openFocusRow('f2', null, 'Sb', 'B'); // null pane (not realized yet) → kept
|
|
195
|
+
// The dead-pane row is GC'd on read: placement returns null AND the row is
|
|
196
|
+
// removed from the canvas table (self-healing registry).
|
|
197
|
+
assert.equal(focusOf('A'), null, 'a viewer whose pane is gone is pruned on read');
|
|
198
|
+
assert.equal(getFocusByNode('A'), null, 'the pruned row is closed in the db');
|
|
199
|
+
assert.equal(isFocused('A'), false);
|
|
200
|
+
assert.equal(focusByPane('%a'), null, 'focusByPane prunes the dead pane too');
|
|
201
|
+
// The null-pane row survives (liveOrPrune only prunes a non-null dead pane).
|
|
202
|
+
assert.deepEqual(focusOf('B'), { focus_id: 'f2', pane: null, session: 'Sb', node_id: 'B' });
|
|
203
|
+
assert.equal(isFocused('B'), true);
|
|
190
204
|
assert.equal(focusOf('Z'), null, 'an unfocused node has no focus');
|
|
191
|
-
assert.equal(isFocused('A'), true);
|
|
192
205
|
assert.equal(isFocused('Z'), false);
|
|
193
|
-
assert.deepEqual(
|
|
194
|
-
assert.deepEqual(
|
|
195
|
-
assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['A', 'B']);
|
|
206
|
+
assert.deepEqual(focusedNodes(), new Set(['B']));
|
|
207
|
+
assert.deepEqual(placementListFocuses().map((f) => f.node_id), ['B']);
|
|
196
208
|
});
|
|
@@ -72,19 +72,36 @@ test('resolveForkSource resolves a node id to its absolute session FILE', () =>
|
|
|
72
72
|
createNode(node('src', { pi_session_id: 'uuid-1', pi_session_file: '/abs/src.jsonl' }));
|
|
73
73
|
assert.equal(resolveForkSource('src'), '/abs/src.jsonl');
|
|
74
74
|
});
|
|
75
|
-
test('resolveForkSource
|
|
75
|
+
test('resolveForkSource THROWS when a node has a session id but no captured FILE', () => {
|
|
76
|
+
// Broker-cut contract (U6/§B.broker): the broker forks an absolute .jsonl PATH,
|
|
77
|
+
// never a bare session id. A node row carrying only pi_session_id (no
|
|
78
|
+
// pi_session_file) can no longer be forked — it fails loudly pre-launch.
|
|
76
79
|
createNode(node('src', { pi_session_id: 'uuid-1' }));
|
|
77
|
-
assert.
|
|
80
|
+
assert.throws(() => resolveForkSource('src'), /no pi session yet|session FILE/);
|
|
78
81
|
});
|
|
79
82
|
test('resolveForkSource throws when the node has no pi session to fork yet', () => {
|
|
80
83
|
createNode(node('fresh'));
|
|
81
84
|
assert.throws(() => resolveForkSource('fresh'), /no pi session yet/);
|
|
82
85
|
});
|
|
83
|
-
test('resolveForkSource passes
|
|
86
|
+
test('resolveForkSource passes an absolute path straight through', () => {
|
|
84
87
|
assert.equal(resolveForkSource('/some/where/sess.jsonl'), '/some/where/sess.jsonl');
|
|
85
88
|
});
|
|
86
|
-
test('resolveForkSource
|
|
87
|
-
assert.equal(resolveForkSource('
|
|
89
|
+
test('resolveForkSource makes a relative path absolute', () => {
|
|
90
|
+
assert.equal(resolveForkSource('sub/sess.jsonl'), join(process.cwd(), 'sub/sess.jsonl'));
|
|
91
|
+
});
|
|
92
|
+
test('resolveForkSource THROWS on a bare/partial uuid that resolves to no session', () => {
|
|
93
|
+
// Broker-cut contract (U6): an unknown bare/partial uuid is scanned against
|
|
94
|
+
// pi's sessions store and fails loudly when it matches nothing — it is no
|
|
95
|
+
// longer passed through verbatim (the broker fork won't resolve a uuid).
|
|
96
|
+
const sessRoot = mkdtempSync(join(tmpdir(), 'crtr-pi-agent-'));
|
|
97
|
+
process.env['PI_CODING_AGENT_DIR'] = sessRoot;
|
|
98
|
+
try {
|
|
99
|
+
assert.throws(() => resolveForkSource('019e8ce3-322e'), /no pi session/);
|
|
100
|
+
}
|
|
101
|
+
finally {
|
|
102
|
+
delete process.env['PI_CODING_AGENT_DIR'];
|
|
103
|
+
rmSync(sessRoot, { recursive: true, force: true });
|
|
104
|
+
}
|
|
88
105
|
});
|
|
89
106
|
test('resolveForkSource rejects an empty reference', () => {
|
|
90
107
|
assert.throws(() => resolveForkSource(' '), /requires a node id/);
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-control-preempt.test.ts
|
|
2
|
+
// (FULL tier — boots a real isolated tmux session + a real headless broker.)
|
|
3
|
+
//
|
|
4
|
+
// BUG-REGRESSION (broker-universal-host cut, design §D preemptive handoff; U1
|
|
5
|
+
// report mq8ftenr-5d55313d). Before the cut, `request_control` granted control
|
|
6
|
+
// ONLY when controllerId===null and otherwise returned a `control_held` error —
|
|
7
|
+
// so a second client could NEVER preempt a live (and usually idle/abandoned)
|
|
8
|
+
// controller. With every node now broker-hosted, a tmux pane and a web tab are
|
|
9
|
+
// true peers: either must be able to take control of a node the other currently
|
|
10
|
+
// drives. The fix makes request_control preemptive last-requester-wins — it
|
|
11
|
+
// ALWAYS succeeds, reassigning control to the requester and broadcasting a
|
|
12
|
+
// control_changed that demotes the prior controller. There is no `control_held`
|
|
13
|
+
// error frame anymore.
|
|
14
|
+
//
|
|
15
|
+
// Drive: controller A attaches and holds control; observer B then requests
|
|
16
|
+
// control. Assert control reassigns to B, a control_changed broadcasts demoting
|
|
17
|
+
// A, and B got NO error frame back (the old `control_held` reject is gone).
|
|
18
|
+
// Regression check: restore the controllerId!==null reject and B's request is
|
|
19
|
+
// refused (controller_id stays A / B receives an error) → these asserts go RED.
|
|
20
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
21
|
+
import assert from 'node:assert/strict';
|
|
22
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
23
|
+
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
24
|
+
let h;
|
|
25
|
+
let id;
|
|
26
|
+
const kit = createAttachKit(() => h);
|
|
27
|
+
const { attach, attachUntil } = kit;
|
|
28
|
+
before(async () => {
|
|
29
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-ctrlpreempt' });
|
|
30
|
+
const root = h.spawnRoot('control-preempt suite root');
|
|
31
|
+
id = await h.spawnHeadlessChild(root, 'headless worker — control preemption');
|
|
32
|
+
});
|
|
33
|
+
after(async () => {
|
|
34
|
+
if (h !== undefined)
|
|
35
|
+
await h.dispose();
|
|
36
|
+
});
|
|
37
|
+
afterEach(() => {
|
|
38
|
+
kit.closeAll();
|
|
39
|
+
});
|
|
40
|
+
test('D3 — a second client preempts the live controller (last-requester-wins); the prior controller is demoted, no control_held reject', async () => {
|
|
41
|
+
// A attaches and is granted control (controllerId was null). attachUntil
|
|
42
|
+
// suffixes the client_id per retry, so the broker-assigned id is the welcome's.
|
|
43
|
+
const bId = 'd3-B';
|
|
44
|
+
const a = await attachUntil(id, 'controller', 'd3-A', (x) => x.welcome.role === 'controller', 'A admitted controller');
|
|
45
|
+
const aId = a.welcome.controller_id;
|
|
46
|
+
assert.ok(aId !== null, 'A holds control after attach');
|
|
47
|
+
// B attaches as an observer while A holds control (its welcome reflects A).
|
|
48
|
+
const b = await attach(id, 'observer', bId);
|
|
49
|
+
assert.equal(b.welcome.role, 'observer', 'B starts as an observer (A holds control)');
|
|
50
|
+
assert.equal(b.welcome.controller_id, aId, 'B sees A as the current controller');
|
|
51
|
+
// B preempts. Under the OLD model this returned a control_held error and B
|
|
52
|
+
// never became controller; under §D it ALWAYS succeeds.
|
|
53
|
+
b.send({ type: 'request_control' });
|
|
54
|
+
// A is demoted via a broadcast control_changed naming B as the new controller.
|
|
55
|
+
const changed = await a.waitFrame((f) => f.type === 'control_changed' && f.controller_id === bId, 'A receives control_changed demoting it to B');
|
|
56
|
+
assert.equal(changed.controller_id, bId, 'control reassigned to B (last-requester-wins)');
|
|
57
|
+
// B sees itself as the controller too (same broadcast).
|
|
58
|
+
await b.waitFrame((f) => f.type === 'control_changed' && f.controller_id === bId, 'B observes itself become controller');
|
|
59
|
+
// The old `control_held` reject is gone: B's request drew NO error frame.
|
|
60
|
+
assert.ok(!b.frames.some((f) => f.type === 'error'), 'B received NO error frame — there is no control_held reject in the preemptive model');
|
|
61
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-model-changed-broadcast.test.ts
|
|
2
|
+
//
|
|
3
|
+
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process,
|
|
4
|
+
// so it lives in full/ (CI), not the fast local loop.
|
|
5
|
+
//
|
|
6
|
+
// BUG-REGRESSION (real, observed 2026-06-11 in `crtr attach`): switching the
|
|
7
|
+
// model (/model <provider/id> or the picker) visibly did nothing — the viewer's
|
|
8
|
+
// status bar kept showing the OLD model until some unrelated event happened to
|
|
9
|
+
// arrive.
|
|
10
|
+
//
|
|
11
|
+
// 3-PART HEADER:
|
|
12
|
+
// (1) BUG IT LOCKS — pi emits no AgentSessionEvent for a model switch, and the
|
|
13
|
+
// broker's `set_model`/`cycle_model` handlers replied with only a bare ack
|
|
14
|
+
// to the REQUESTING client. The new model reached no viewer at all: the
|
|
15
|
+
// broker now broadcasts a `model_changed` frame (mirroring
|
|
16
|
+
// snapshot.state.model) to every client after the engine accepts the switch.
|
|
17
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE — the regression is pure frame-plumbing
|
|
18
|
+
// on view.sock: a controller `set_model` in, an ack + `model_changed`
|
|
19
|
+
// fan-out. No tmux pane or TUI is involved, so the lock holds headlessly
|
|
20
|
+
// (createHeadlessHarness + the fake engine, whose registry stub resolves
|
|
21
|
+
// any `provider/id` and whose setModel stores what it is handed).
|
|
22
|
+
// (3) HOW IT FAILS ON REGRESSION — pre-fix, NO `model_changed` frame ever
|
|
23
|
+
// arrives at either the controller or the observer (both waits time out);
|
|
24
|
+
// the ack alone still passing would not satisfy the broadcast asserts.
|
|
25
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
26
|
+
import assert from 'node:assert/strict';
|
|
27
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
28
|
+
import { createAttachKit } from '../helpers/broker-clients.js';
|
|
29
|
+
let h;
|
|
30
|
+
let id;
|
|
31
|
+
const kit = createAttachKit(() => h);
|
|
32
|
+
const { attachUntil } = kit;
|
|
33
|
+
before(async () => {
|
|
34
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brkmodel' });
|
|
35
|
+
const root = h.spawnRoot('model-changed-broadcast suite root');
|
|
36
|
+
id = await h.spawnHeadlessChild(root, 'headless worker — model_changed broadcast gate');
|
|
37
|
+
});
|
|
38
|
+
after(async () => {
|
|
39
|
+
if (h !== undefined)
|
|
40
|
+
await h.dispose();
|
|
41
|
+
});
|
|
42
|
+
afterEach(() => {
|
|
43
|
+
kit.closeAll();
|
|
44
|
+
});
|
|
45
|
+
test('set_model acks the requester and broadcasts model_changed to every viewer', async () => {
|
|
46
|
+
const c = await attachUntil(id, 'controller', 'model-ctrl', (a) => a.welcome.role === 'controller', 'model-ctrl admitted controller');
|
|
47
|
+
const o = await attachUntil(id, 'observer', 'model-obs', (a) => a.welcome.role === 'observer', 'model-obs admitted observer');
|
|
48
|
+
c.send({ type: 'set_model', model: 'anthropic/fake-fable' });
|
|
49
|
+
const ack = await c.waitFrame((f) => f.type === 'ack' && f.for === 'set_model', 'set_model ack');
|
|
50
|
+
assert.ok(ack.type === 'ack' && ack.ok, 'set_model acked ok');
|
|
51
|
+
// BOTH the requester and the observer hear the switch. Pre-fix: no
|
|
52
|
+
// model_changed frame exists, so neither wait ever resolves.
|
|
53
|
+
const cm = await c.waitFrame((f) => f.type === 'model_changed', 'controller receives model_changed');
|
|
54
|
+
assert.ok(cm.type === 'model_changed' && cm.model === 'fake-fable', 'controller sees the new model id');
|
|
55
|
+
const om = await o.waitFrame((f) => f.type === 'model_changed', 'observer receives model_changed');
|
|
56
|
+
assert.ok(om.type === 'model_changed' && om.model === 'fake-fable', 'observer sees the new model id');
|
|
57
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/full/broker-navigate-tree-rewelcome.test.ts
|
|
2
|
+
//
|
|
3
|
+
// FULL TIER (real-boot-bound): tmux-free, but it boots a REAL broker process,
|
|
4
|
+
// so it lives in full/ (CI), not the fast local loop.
|
|
5
|
+
//
|
|
6
|
+
// BUG-REGRESSION (real, observed 2026-06-11 in `crtr attach`): double-Esc (or
|
|
7
|
+
// /tree) opened the session-tree picker, but SELECTING an entry visibly did
|
|
8
|
+
// nothing — the viewer stayed on the old transcript instead of traveling back
|
|
9
|
+
// to the chosen point in time.
|
|
10
|
+
//
|
|
11
|
+
// 3-PART HEADER:
|
|
12
|
+
// (1) BUG IT LOCKS — the broker's `navigate_tree` handler called
|
|
13
|
+
// session.navigateTree() (which rewinds IN-PLACE: same session file, new
|
|
14
|
+
// leaf, NO relayed AgentSessionEvent) and then only acked. Unlike the
|
|
15
|
+
// session-replacing ops (new_session/switch_session/fork → runReplacement
|
|
16
|
+
// → reWelcomeAll), it never re-snapshotted the viewers, so every attached
|
|
17
|
+
// client kept rendering the abandoned branch. The navigated-to user
|
|
18
|
+
// message's `editorText` (pi parity: the tree navigator restores it to the
|
|
19
|
+
// editor) was dropped on the floor too.
|
|
20
|
+
// (2) WHY BROKER/SOCKET-LEVEL, NOT PANE — the regression is pure frame-plumbing
|
|
21
|
+
// on view.sock: a controller `navigate_tree` in, a re-`welcome` fan-out +
|
|
22
|
+
// ack.detail out. No tmux pane or TUI is involved, so the lock holds
|
|
23
|
+
// headlessly (createHeadlessHarness + the fake engine, whose navigateTree
|
|
24
|
+
// pops the last accrued turn and returns `rewound-to:<targetId>`).
|
|
25
|
+
// (3) HOW IT FAILS ON REGRESSION — pre-fix, NO second welcome ever arrives
|
|
26
|
+
// (the re-welcome waits time out) and the navigate_tree ack carries no
|
|
27
|
+
// detail; the rewound-history assert (the turn token gone from the new
|
|
28
|
+
// snapshot) also goes red if the re-welcome serves a stale snapshot.
|
|
29
|
+
import { test, before, after, afterEach } from 'node:test';
|
|
30
|
+
import assert from 'node:assert/strict';
|
|
31
|
+
import { createHeadlessHarness } from '../helpers/harness.js';
|
|
32
|
+
import { createAttachKit, frameHas, tok } from '../helpers/broker-clients.js';
|
|
33
|
+
let h;
|
|
34
|
+
let id;
|
|
35
|
+
const kit = createAttachKit(() => h);
|
|
36
|
+
const { attachUntil } = kit;
|
|
37
|
+
before(async () => {
|
|
38
|
+
h = await createHeadlessHarness({ sessionPrefix: 'crtr-brknav' });
|
|
39
|
+
const root = h.spawnRoot('navigate-tree-rewelcome suite root');
|
|
40
|
+
id = await h.spawnHeadlessChild(root, 'headless worker — navigate-tree rewelcome gate');
|
|
41
|
+
});
|
|
42
|
+
after(async () => {
|
|
43
|
+
if (h !== undefined)
|
|
44
|
+
await h.dispose();
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
kit.closeAll();
|
|
48
|
+
});
|
|
49
|
+
const welcomes = (frames) => frames.filter((f) => f.type === 'welcome');
|
|
50
|
+
test('navigate_tree re-welcomes every viewer onto the rewound transcript and acks the editor text', async () => {
|
|
51
|
+
const c = await attachUntil(id, 'controller', 'nav-ctrl', (a) => a.welcome.role === 'controller', 'nav-ctrl admitted controller');
|
|
52
|
+
// Accrue one observable turn so the rewind has something to drop.
|
|
53
|
+
const token = tok('NAV-TURN');
|
|
54
|
+
c.send({ type: 'prompt', text: token });
|
|
55
|
+
await c.waitFrame((f) => f.type === 'agent_end' && frameHas(f, token), 'turn relayed before navigate');
|
|
56
|
+
// The observer attaches AFTER the turn so its first welcome deterministically
|
|
57
|
+
// holds the token (the pre-navigate baseline) — same accrual trick as G3.
|
|
58
|
+
const o = await attachUntil(id, 'observer', 'nav-obs', (a) => frameHas(a.welcome, token), 'observer welcome holds the pre-navigate turn');
|
|
59
|
+
c.send({ type: 'navigate_tree', targetId: 'entry-x' });
|
|
60
|
+
// The ack carries the navigated-to user message's text in detail (the viewer
|
|
61
|
+
// restores it to the editor). Pre-fix: ack had no detail.
|
|
62
|
+
const ack = await c.waitFrame((f) => f.type === 'ack' && f.for === 'navigate_tree', 'navigate_tree ack');
|
|
63
|
+
assert.ok(ack.type === 'ack' && ack.ok, 'navigate_tree acked ok');
|
|
64
|
+
assert.equal(ack.type === 'ack' ? ack.detail : undefined, 'rewound-to:entry-x', 'ack.detail carries the rewound-to editor text');
|
|
65
|
+
// BOTH viewers get re-welcomed onto the rewound history. Pre-fix: no second
|
|
66
|
+
// welcome ever arrives (navigate_tree only acked).
|
|
67
|
+
await h.waitFor(() => (welcomes(c.frames).length >= 2 ? true : null), {
|
|
68
|
+
label: 'controller re-welcomed after navigate_tree',
|
|
69
|
+
});
|
|
70
|
+
await h.waitFor(() => (welcomes(o.frames).length >= 2 ? true : null), {
|
|
71
|
+
label: 'observer re-welcomed after navigate_tree',
|
|
72
|
+
});
|
|
73
|
+
// The re-welcome serves the REWOUND transcript: the fake's navigateTree popped
|
|
74
|
+
// the last accrued turn, so the token present in the observer's first welcome
|
|
75
|
+
// must be gone from its second.
|
|
76
|
+
const [first, second] = welcomes(o.frames);
|
|
77
|
+
assert.ok(frameHas(first, token), 'pre-navigate welcome holds the turn');
|
|
78
|
+
assert.ok(!frameHas(second, token), 're-welcome serves the rewound transcript (turn dropped)');
|
|
79
|
+
assert.equal(second.snapshot.messages.length, first.snapshot.messages.length - 1, 're-welcome history is exactly one turn shorter');
|
|
80
|
+
});
|