@crouton-kit/crouter 0.3.70 → 0.3.79
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/README.md +1 -1
- package/dist/build-root.d.ts +12 -4
- package/dist/build-root.js +25 -6
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-memory/crouter-development/plugins.md +82 -5
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +1115 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +234 -71
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +2 -516
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +11 -6
- package/dist/clients/attach/__tests__/attach-keybindings.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/attach-keybindings.test.js +113 -0
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +28 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +9 -1
- package/dist/clients/attach/attach-cmd.js +841 -792
- package/dist/clients/attach/auth-pickers.d.ts +0 -12
- package/dist/clients/attach/auth-pickers.js +64 -15
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/graph-overlay.d.ts +12 -2
- package/dist/clients/attach/graph-overlay.js +83 -33
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +11 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +127 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/human/queue.js +3 -4
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +9 -2
- package/dist/commands/pkg/plugin-inspect.js +22 -1
- package/dist/commands/pkg/plugin-manage.js +31 -9
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/__tests__/setup-core.test.js +158 -1
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/doctor.js +42 -4
- package/dist/commands/sys/setup-core.d.ts +49 -1
- package/dist/commands/sys/setup-core.js +161 -4
- package/dist/commands/sys/setup.d.ts +88 -0
- package/dist/commands/sys/setup.js +943 -169
- package/dist/commands/view-pick.d.ts +4 -0
- package/dist/commands/view-pick.js +17 -7
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +34 -9
- package/dist/core/__tests__/command-plugins-surfaces.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins-surfaces.test.js +298 -0
- package/dist/core/__tests__/command-plugins.test.d.ts +1 -0
- package/dist/core/__tests__/command-plugins.test.js +444 -0
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +6 -0
- package/dist/core/__tests__/fixtures/fake-engine.js +9 -1
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/preview-registry-sync.test.js +30 -1
- package/dist/core/__tests__/scope-crouter-home-fence.test.d.ts +1 -0
- package/dist/core/__tests__/scope-crouter-home-fence.test.js +55 -0
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.d.ts +6 -0
- package/dist/core/canvas/browse/app.js +230 -41
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/command-plugins/adapter.d.ts +15 -0
- package/dist/core/command-plugins/adapter.js +145 -0
- package/dist/core/command-plugins/compose.d.ts +5 -0
- package/dist/core/command-plugins/compose.js +56 -0
- package/dist/core/command-plugins/discovery.d.ts +104 -0
- package/dist/core/command-plugins/discovery.js +565 -0
- package/dist/core/config.d.ts +48 -0
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/bespoke-consumers.test.js +40 -0
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +257 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/profiles/select.d.ts +6 -0
- package/dist/core/profiles/select.js +86 -52
- package/dist/core/provider-management.d.ts +12 -0
- package/dist/core/provider-management.js +24 -0
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +164 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +78 -20
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/pi-vendored.d.ts +8 -0
- package/dist/core/runtime/pi-vendored.js +14 -0
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +75 -47
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/session-list-cache.d.ts +33 -0
- package/dist/core/runtime/session-list-cache.js +338 -0
- package/dist/core/runtime/spawn.js +52 -45
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +217 -110
- package/dist/core/scope.js +27 -4
- package/dist/core/subscription-state.d.ts +90 -0
- package/dist/core/subscription-state.js +762 -0
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/daemon/crtrd.js +253 -12
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +73 -46
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/types.d.ts +9 -26
- package/dist/types.js +1 -28
- package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +8 -4
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
|
@@ -1,2 +1,6 @@
|
|
|
1
1
|
import type { LeafDef } from '../core/command.js';
|
|
2
|
+
import { type BindingId, type BindingResolution, type RawTerminalInput } from '../core/keybindings/index.js';
|
|
3
|
+
declare const VIEW_PICK_ACTIONS: readonly ["crtr.view-picker.quit", "crtr.view-picker.down", "crtr.view-picker.up", "crtr.view-picker.run"];
|
|
4
|
+
export declare function resolveViewPickAction(bindings: BindingResolution<BindingId>, raw: RawTerminalInput): (typeof VIEW_PICK_ACTIONS)[number] | null;
|
|
2
5
|
export declare const viewPickLeaf: LeafDef;
|
|
6
|
+
export {};
|
|
@@ -10,6 +10,16 @@ import { defineLeaf } from '../core/command.js';
|
|
|
10
10
|
import { listViews } from '../core/view/loader.js';
|
|
11
11
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../core/tui/terminal.js';
|
|
12
12
|
import { createDraw, detectColorCaps } from '../core/tui/draw.js';
|
|
13
|
+
import { formatBinding, matchesTerminalInput, resolveUserKeybindings, } from '../core/keybindings/index.js';
|
|
14
|
+
const VIEW_PICK_ACTIONS = [
|
|
15
|
+
'crtr.view-picker.quit',
|
|
16
|
+
'crtr.view-picker.down',
|
|
17
|
+
'crtr.view-picker.up',
|
|
18
|
+
'crtr.view-picker.run',
|
|
19
|
+
];
|
|
20
|
+
export function resolveViewPickAction(bindings, raw) {
|
|
21
|
+
return VIEW_PICK_ACTIONS.find((id) => matchesTerminalInput(bindings, id, raw)) ?? null;
|
|
22
|
+
}
|
|
13
23
|
export const viewPickLeaf = defineLeaf({
|
|
14
24
|
name: 'pick',
|
|
15
25
|
tier: 'hidden',
|
|
@@ -42,6 +52,7 @@ export const viewPickLeaf = defineLeaf({
|
|
|
42
52
|
return;
|
|
43
53
|
}
|
|
44
54
|
const caps = detectColorCaps();
|
|
55
|
+
const bindings = resolveUserKeybindings();
|
|
45
56
|
let cursor = 0;
|
|
46
57
|
let scroll = 0;
|
|
47
58
|
let restored = false;
|
|
@@ -58,7 +69,7 @@ export const viewPickLeaf = defineLeaf({
|
|
|
58
69
|
const render = () => {
|
|
59
70
|
const size = getTerminalSize();
|
|
60
71
|
const { draw, frame } = createDraw(size, caps);
|
|
61
|
-
draw.text(0, 0,
|
|
72
|
+
draw.text(0, 0, `crtr views — ${formatBinding(bindings, 'crtr.view-picker.down')}/${formatBinding(bindings, 'crtr.view-picker.up')} move · ${formatBinding(bindings, 'crtr.view-picker.run')} run · ${formatBinding(bindings, 'crtr.view-picker.quit')} quit`, { bold: true });
|
|
62
73
|
draw.hline(1, 0, size.cols);
|
|
63
74
|
const rect = { row: 2, col: 0, width: size.cols, height: Math.max(1, size.rows - 3) };
|
|
64
75
|
const items = views.map((v) => ({
|
|
@@ -97,21 +108,20 @@ export const viewPickLeaf = defineLeaf({
|
|
|
97
108
|
return;
|
|
98
109
|
}
|
|
99
110
|
const { input, key } = parsed;
|
|
100
|
-
|
|
111
|
+
const action = resolveViewPickAction(bindings, { input, ...key });
|
|
112
|
+
if (action === 'crtr.view-picker.quit')
|
|
101
113
|
finish();
|
|
102
|
-
if (
|
|
103
|
-
finish();
|
|
104
|
-
if (input === 'j' || key.downArrow) {
|
|
114
|
+
if (action === 'crtr.view-picker.down') {
|
|
105
115
|
cursor = Math.min(views.length - 1, cursor + 1);
|
|
106
116
|
render();
|
|
107
117
|
return;
|
|
108
118
|
}
|
|
109
|
-
if (
|
|
119
|
+
if (action === 'crtr.view-picker.up') {
|
|
110
120
|
cursor = Math.max(0, cursor - 1);
|
|
111
121
|
render();
|
|
112
122
|
return;
|
|
113
123
|
}
|
|
114
|
-
if (
|
|
124
|
+
if (action === 'crtr.view-picker.run')
|
|
115
125
|
launch(views[cursor].id);
|
|
116
126
|
});
|
|
117
127
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import assert from 'node:assert/strict';
|
|
2
|
+
import test from 'node:test';
|
|
3
|
+
import { renderPreferencesForSubject } from '../substrate/render.js';
|
|
4
|
+
function subject(mode) {
|
|
5
|
+
return {
|
|
6
|
+
kind: 'plan/reviewers/security',
|
|
7
|
+
mode,
|
|
8
|
+
lifecycle: 'terminal',
|
|
9
|
+
hasManager: true,
|
|
10
|
+
cwd: process.cwd(),
|
|
11
|
+
scope: 'project',
|
|
12
|
+
orchestration: { depth: 1 },
|
|
13
|
+
profile: null,
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
test('base sub-personas receive hands-on guidance instead of universal delegation pressure', () => {
|
|
17
|
+
const base = renderPreferencesForSubject(subject('base')).block;
|
|
18
|
+
assert.match(base, /You are a hands-on worker/);
|
|
19
|
+
assert.match(base, /Each child gets a bounded outcome distinct from your whole assignment/);
|
|
20
|
+
assert.doesNotMatch(base, /Hand any self-contained unit of work to a child/);
|
|
21
|
+
const orchestrator = renderPreferencesForSubject(subject('orchestrator')).block;
|
|
22
|
+
assert.doesNotMatch(orchestrator, /You are a hands-on worker/);
|
|
23
|
+
assert.match(orchestrator, /You are an orchestrator/);
|
|
24
|
+
});
|
|
@@ -10,7 +10,8 @@ import { mkdirSync, rmSync } from 'node:fs';
|
|
|
10
10
|
import { tmpdir } from 'node:os';
|
|
11
11
|
import { join } from 'node:path';
|
|
12
12
|
import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
|
|
13
|
-
import { appendInbox } from '../feed/inbox.js';
|
|
13
|
+
import { appendInbox, readCursor } from '../feed/inbox.js';
|
|
14
|
+
import { appendSituationalContext } from '../runtime/situational-context.js';
|
|
14
15
|
import { createNode, setIntent } from '../canvas/canvas.js';
|
|
15
16
|
import { closeDb } from '../canvas/db.js';
|
|
16
17
|
// Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
|
|
@@ -36,11 +37,25 @@ function makeFakePi() {
|
|
|
36
37
|
const handlers = {};
|
|
37
38
|
return {
|
|
38
39
|
injected: [],
|
|
40
|
+
sentMessages: [],
|
|
39
41
|
on(e, h) { handlers[e] = h; },
|
|
40
42
|
sendUserMessage(content, options) { this.injected.push({ content, deliverAs: options?.deliverAs }); },
|
|
43
|
+
sendMessage(message) { this.sentMessages.push(message); },
|
|
41
44
|
fire(e, ev, ctx) { handlers[e]?.(ev, ctx); },
|
|
42
45
|
};
|
|
43
46
|
}
|
|
47
|
+
// Fire agent_settled — the ONLY durable-commit boundary in the at-least-once
|
|
48
|
+
// model. `pending` maps to pi's `hasPendingMessages()`: false = pi's
|
|
49
|
+
// steer/followUp queue is drained (the normal production case — pi 0.80.6 drains
|
|
50
|
+
// retry/queued continuations within the same prompt cycle before this settle —
|
|
51
|
+
// so commit proceeds); true = the context still reports an unconsumed handed-off
|
|
52
|
+
// send, which exercises the extension's DEFENSIVE coarse hold (commit is HELD
|
|
53
|
+
// until a later drained settle). The true case is an API-behavior scenario for
|
|
54
|
+
// the guard, not a lifecycle the normal production settle emits.
|
|
55
|
+
function settle(pi, opts = {}) {
|
|
56
|
+
const pending = opts.pending === true;
|
|
57
|
+
pi.fire('agent_settled', { type: 'agent_settled' }, { isIdle: () => true, hasPendingMessages: () => pending });
|
|
58
|
+
}
|
|
44
59
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
45
60
|
let origTick;
|
|
46
61
|
let origDebounce;
|
|
@@ -116,3 +131,219 @@ describe('canvas inbox watcher — hold + idle delivery', () => {
|
|
|
116
131
|
assert.equal(pi.injected[0].deliverAs, undefined, 'idle → sendUserMessage triggers a turn, no deliverAs');
|
|
117
132
|
});
|
|
118
133
|
});
|
|
134
|
+
// Durable-cursor model (at-least-once, one conservative boundary): the in-memory
|
|
135
|
+
// `cursor` advances at buffer time (so a burst is never re-read into the same
|
|
136
|
+
// watcher's buffer), but the on-disk cursor commits ONLY at `agent_settled` (and
|
|
137
|
+
// `session_shutdown` as the graceful safety net) — never on the fire-and-forget
|
|
138
|
+
// send return, never at `agent_end` (which fires before pi decides on retry/
|
|
139
|
+
// continuation). A send returning normally is a HANDOFF, and the commit clamps
|
|
140
|
+
// below anything still buffered. When pi still holds an unconsumed handed-off
|
|
141
|
+
// send — a failed/aborted turn RETAINS its in-memory steer/followUp queue for the
|
|
142
|
+
// next turn — `ctx.hasPendingMessages()` is true and the commit is HELD until a
|
|
143
|
+
// later settle drains it. A watcher that dies (crash, SIGTERM, daemon kick)
|
|
144
|
+
// before a committing settle leaves the on-disk cursor behind, so a fresh watcher
|
|
145
|
+
// re-reads and re-delivers rather than silently losing the entry. See
|
|
146
|
+
// canvas-inbox-watcher.ts's header/inline comments on `handedOffWatermark`,
|
|
147
|
+
// `commitTarget`, and `commitAtSettle` for the full rationale.
|
|
148
|
+
describe('canvas inbox watcher — durable cursor (at-least-once)', () => {
|
|
149
|
+
test('mid-turn handoff, crash before settle: on-disk cursor NOT advanced, fresh watcher re-delivers', async () => {
|
|
150
|
+
freshNode('node-cursor-crash');
|
|
151
|
+
const pi1 = makeFakePi();
|
|
152
|
+
disposers.push(registerCanvasInboxWatcher(pi1));
|
|
153
|
+
// Streaming (mid-turn) so this delivers via followUp, not the idle path.
|
|
154
|
+
pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
155
|
+
await wait(TICK_MS + 100);
|
|
156
|
+
appendInbox('node-cursor-crash', { from: 'child-1', tier: 'normal', kind: 'update', label: 'still working' });
|
|
157
|
+
await wait(SETTLE_MS);
|
|
158
|
+
assert.equal(pi1.injected.length, 1, 'delivered mid-turn as a followUp');
|
|
159
|
+
assert.equal(pi1.injected[0].deliverAs, 'followUp');
|
|
160
|
+
// The durability boundary: no settle has fired, so the on-disk cursor must
|
|
161
|
+
// still be untouched — this is the assertion that proves the fix (a
|
|
162
|
+
// commit-at-buffer-time / commit-on-send variant would have committed here).
|
|
163
|
+
assert.equal(readCursor('node-cursor-crash'), undefined, 'on-disk cursor not committed before a settle');
|
|
164
|
+
// Simulate the watcher dying without a graceful session_shutdown — never
|
|
165
|
+
// settle OR fire session_shutdown on pi1. The broker's SIGTERM handler DOES
|
|
166
|
+
// run a JS handler (disposeAndExit: session.dispose() + process.exit), but
|
|
167
|
+
// that path never emits this extension's session_shutdown, so its safety-net
|
|
168
|
+
// commit does not run on a kill. Registering a fresh watcher clears pi1's
|
|
169
|
+
// timer (the module-level liveTimer guard) without running its teardown,
|
|
170
|
+
// modeling crash-then-revive rather than a clean stop.
|
|
171
|
+
const pi2 = makeFakePi();
|
|
172
|
+
disposers.push(registerCanvasInboxWatcher(pi2));
|
|
173
|
+
await wait(SETTLE_MS);
|
|
174
|
+
assert.equal(pi2.injected.length, 1, 'a fresh watcher re-reads and re-delivers the same uncommitted entry');
|
|
175
|
+
assert.match(pi2.injected[0].content, /still working/);
|
|
176
|
+
});
|
|
177
|
+
test('mid-turn handoff + agent_settled: cursor committed, fresh watcher does NOT redeliver', async () => {
|
|
178
|
+
freshNode('node-cursor-committed');
|
|
179
|
+
const pi1 = makeFakePi();
|
|
180
|
+
disposers.push(registerCanvasInboxWatcher(pi1));
|
|
181
|
+
pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
182
|
+
await wait(TICK_MS + 100);
|
|
183
|
+
const entry = appendInbox('node-cursor-committed', { from: 'child-2', tier: 'normal', kind: 'update', label: 'still working' });
|
|
184
|
+
await wait(SETTLE_MS);
|
|
185
|
+
assert.equal(pi1.injected.length, 1);
|
|
186
|
+
assert.equal(readCursor('node-cursor-committed'), undefined, 'not yet committed before the settle');
|
|
187
|
+
// The turn settles with pi's queue drained (the followUp was consumed) —
|
|
188
|
+
// THAT commits the cursor.
|
|
189
|
+
settle(pi1);
|
|
190
|
+
assert.equal(readCursor('node-cursor-committed'), entry.ts, 'cursor committed to disk at agent_settled');
|
|
191
|
+
const pi2 = makeFakePi();
|
|
192
|
+
disposers.push(registerCanvasInboxWatcher(pi2));
|
|
193
|
+
await wait(SETTLE_MS);
|
|
194
|
+
assert.equal(pi2.injected.length, 0, 'a fresh watcher does not redeliver an already-committed entry');
|
|
195
|
+
});
|
|
196
|
+
test('idle-path delivery: NOT committed on the void send, committed at agent_settled', async () => {
|
|
197
|
+
freshNode('node-cursor-idle');
|
|
198
|
+
const pi = makeFakePi();
|
|
199
|
+
disposers.push(registerCanvasInboxWatcher(pi));
|
|
200
|
+
// No agent_start fired → watcher treats the node as idle.
|
|
201
|
+
await wait(TICK_MS + 100);
|
|
202
|
+
const entry = appendInbox('node-cursor-idle', { from: 'child-3', tier: 'normal', kind: 'update', label: 'idle update' });
|
|
203
|
+
await wait(SETTLE_MS);
|
|
204
|
+
assert.equal(pi.injected.length, 1);
|
|
205
|
+
assert.equal(pi.injected[0].deliverAs, undefined, 'idle delivery has no deliverAs');
|
|
206
|
+
// The idle send triggers a turn, but its void return is a handoff, not proof
|
|
207
|
+
// of acceptance — the cursor stays put until the turn settles. (One rule: no
|
|
208
|
+
// immediate idle-path commit.)
|
|
209
|
+
assert.equal(readCursor('node-cursor-idle'), undefined, 'idle send does not commit on the fire-and-forget return');
|
|
210
|
+
settle(pi);
|
|
211
|
+
assert.equal(readCursor('node-cursor-idle'), entry.ts, 'idle-path delivery commits at the turn it started settling');
|
|
212
|
+
});
|
|
213
|
+
test('synchronous send throw then agent_settled: durable cursor NOT advanced past the re-buffered entry, fresh watcher re-delivers', async () => {
|
|
214
|
+
freshNode('node-cursor-failsend');
|
|
215
|
+
const pi1 = makeFakePi();
|
|
216
|
+
// Persistent synchronous send failure: the entry is read + buffered, flush
|
|
217
|
+
// throws, it is re-buffered and never handed off.
|
|
218
|
+
pi1.sendUserMessage = () => { throw new Error('send always fails'); };
|
|
219
|
+
disposers.push(registerCanvasInboxWatcher(pi1));
|
|
220
|
+
pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
221
|
+
await wait(TICK_MS + 100);
|
|
222
|
+
appendInbox('node-cursor-failsend', { from: 'child-1', tier: 'normal', kind: 'update', label: 'never delivered' });
|
|
223
|
+
await wait(SETTLE_MS);
|
|
224
|
+
assert.equal(pi1.injected.length, 0, 'the failing send delivered nothing');
|
|
225
|
+
// agent_settled must NOT commit past the still-re-buffered (never-handed-off) entry.
|
|
226
|
+
settle(pi1);
|
|
227
|
+
assert.equal(readCursor('node-cursor-failsend'), undefined, 'durable cursor not advanced past a re-buffered, never-delivered entry');
|
|
228
|
+
const pi2 = makeFakePi();
|
|
229
|
+
disposers.push(registerCanvasInboxWatcher(pi2));
|
|
230
|
+
await wait(SETTLE_MS);
|
|
231
|
+
assert.equal(pi2.injected.length, 1, 'a fresh watcher re-reads and re-delivers the never-committed entry');
|
|
232
|
+
assert.match(pi2.injected[0].content, /never delivered/);
|
|
233
|
+
});
|
|
234
|
+
test('critical abort then abort-generated settle: pending critical entry NOT committed, fresh watcher re-delivers', async () => {
|
|
235
|
+
freshNode('node-cursor-critical');
|
|
236
|
+
const pi1 = makeFakePi();
|
|
237
|
+
let aborted = 0;
|
|
238
|
+
disposers.push(registerCanvasInboxWatcher(pi1));
|
|
239
|
+
// Mid-turn streaming, with an abort() seam on the ctx (a critical entry
|
|
240
|
+
// preempts via ctx.abort()).
|
|
241
|
+
pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false, abort: () => { aborted += 1; } });
|
|
242
|
+
await wait(TICK_MS + 100);
|
|
243
|
+
appendInbox('node-cursor-critical', { from: 'child-1', tier: 'critical', kind: 'update', label: 'critical work' });
|
|
244
|
+
await wait(SETTLE_MS);
|
|
245
|
+
assert.ok(aborted >= 1, 'the critical entry aborted the live turn');
|
|
246
|
+
assert.equal(pi1.injected.length, 0, 'the critical path re-buffers, it does not deliver mid-stream');
|
|
247
|
+
// The abort-generated agent_end/settle must not commit the pending critical
|
|
248
|
+
// entry (it is re-buffered, never handed off).
|
|
249
|
+
pi1.fire('agent_end', { type: 'agent_end', messages: [] }, { isIdle: () => true });
|
|
250
|
+
settle(pi1);
|
|
251
|
+
assert.equal(readCursor('node-cursor-critical'), undefined, 'abort-generated settle does not commit the pending critical entry before its retry');
|
|
252
|
+
const pi2 = makeFakePi();
|
|
253
|
+
disposers.push(registerCanvasInboxWatcher(pi2));
|
|
254
|
+
await wait(SETTLE_MS);
|
|
255
|
+
assert.equal(pi2.injected.length, 1, 'a fresh watcher re-delivers the critical entry');
|
|
256
|
+
assert.match(pi2.injected[0].content, /critical work/);
|
|
257
|
+
});
|
|
258
|
+
test('batch A settles while batch B still debounces: commit stops at A, fresh watcher re-delivers B', async () => {
|
|
259
|
+
freshNode('node-cursor-batchB');
|
|
260
|
+
// Wide debounce for THIS watcher so B stays buffered-but-unflushed when we
|
|
261
|
+
// settle A; the watcher captures the cadence at registration, so restore the
|
|
262
|
+
// file default immediately after for the fresh (pi2) watcher.
|
|
263
|
+
process.env['CRTR_WATCHER_DEBOUNCE_MS'] = '200';
|
|
264
|
+
const pi1 = makeFakePi();
|
|
265
|
+
disposers.push(registerCanvasInboxWatcher(pi1));
|
|
266
|
+
process.env['CRTR_WATCHER_DEBOUNCE_MS'] = String(DEBOUNCE_MS);
|
|
267
|
+
pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
268
|
+
await wait(TICK_MS + 60);
|
|
269
|
+
const a = appendInbox('node-cursor-batchB', { from: 'child-a', tier: 'normal', kind: 'update', label: 'batch A' });
|
|
270
|
+
await wait(280); // > 200ms debounce → A flushes and is delivered mid-turn (handoff)
|
|
271
|
+
assert.equal(pi1.injected.length, 1, 'batch A delivered mid-turn');
|
|
272
|
+
assert.equal(pi1.injected[0].deliverAs, 'followUp');
|
|
273
|
+
const b = appendInbox('node-cursor-batchB', { from: 'child-b', tier: 'normal', kind: 'update', label: 'batch B' });
|
|
274
|
+
await wait(70); // B read into the buffer but < 200ms debounce → not yet flushed
|
|
275
|
+
// A's turn settles while B is still debouncing in the buffer. The commit must
|
|
276
|
+
// clamp below the still-buffered B and land on A only.
|
|
277
|
+
settle(pi1);
|
|
278
|
+
assert.equal(readCursor('node-cursor-batchB'), a.ts, 'commit lands on handed-off batch A');
|
|
279
|
+
assert.notEqual(readCursor('node-cursor-batchB'), b.ts, 'commit does not cross the still-buffered batch B');
|
|
280
|
+
const pi2 = makeFakePi();
|
|
281
|
+
disposers.push(registerCanvasInboxWatcher(pi2));
|
|
282
|
+
await wait(SETTLE_MS + 40);
|
|
283
|
+
assert.equal(pi2.injected.length, 1, 'a fresh watcher re-reads from A and re-delivers B');
|
|
284
|
+
assert.match(pi2.injected[0].content, /batch B/);
|
|
285
|
+
assert.doesNotMatch(pi2.injected[0].content, /batch A/, 'A is already committed, only B is re-delivered');
|
|
286
|
+
});
|
|
287
|
+
// Defensive coarse-hold guard, driven at the API boundary. In the installed pi
|
|
288
|
+
// 0.80.6 `AgentSession` a handed-off followUp is drained within the same
|
|
289
|
+
// prompt cycle before the sole extension-facing settle, so the normal
|
|
290
|
+
// production settle sees `hasPendingMessages() === false`. This test does NOT
|
|
291
|
+
// reproduce a production failure→pending-settle→later-settle lifecycle; it
|
|
292
|
+
// pins the guard's contract directly: WHEN a settle's context reports pending
|
|
293
|
+
// sends (hasPendingMessages() = true), committing would durably consume a
|
|
294
|
+
// message not yet delivered, so the commit is HELD (never at agent_end, never
|
|
295
|
+
// at the pending settle); a crash in that window re-delivers; and once a later
|
|
296
|
+
// settle reports a drained queue (hasPendingMessages() = false) the cursor
|
|
297
|
+
// commits exactly once.
|
|
298
|
+
test('settle reporting pending sends: HELD by the defensive guard, then committed once the queue drains', async () => {
|
|
299
|
+
freshNode('node-cursor-failhold');
|
|
300
|
+
const pi1 = makeFakePi();
|
|
301
|
+
disposers.push(registerCanvasInboxWatcher(pi1));
|
|
302
|
+
pi1.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
303
|
+
await wait(TICK_MS + 100);
|
|
304
|
+
const entry = appendInbox('node-cursor-failhold', { from: 'child-1', tier: 'normal', kind: 'update', label: 'queued across a failure' });
|
|
305
|
+
await wait(SETTLE_MS);
|
|
306
|
+
assert.equal(pi1.injected.length, 1, 'delivered mid-turn as a followUp');
|
|
307
|
+
assert.equal(pi1.injected[0].deliverAs, 'followUp');
|
|
308
|
+
// A failure/aborted agent_end is never a commit point regardless of queue
|
|
309
|
+
// state, so it must NOT commit.
|
|
310
|
+
pi1.fire('agent_end', { type: 'agent_end', messages: [] }, { isIdle: () => false });
|
|
311
|
+
assert.equal(readCursor('node-cursor-failhold'), undefined, 'a failure agent_end does not commit');
|
|
312
|
+
// Drive the guard directly: a settle whose context reports pending sends
|
|
313
|
+
// (hasPendingMessages = true). The commit is HELD — committing now would
|
|
314
|
+
// durably consume a message not yet delivered.
|
|
315
|
+
settle(pi1, { pending: true });
|
|
316
|
+
assert.equal(readCursor('node-cursor-failhold'), undefined, 'settle reporting pending sends does NOT commit (held)');
|
|
317
|
+
// A crash in this exact held window re-delivers — nothing was committed.
|
|
318
|
+
const pi2 = makeFakePi();
|
|
319
|
+
disposers.push(registerCanvasInboxWatcher(pi2));
|
|
320
|
+
await wait(SETTLE_MS);
|
|
321
|
+
assert.equal(pi2.injected.length, 1, 'crash while held re-delivers');
|
|
322
|
+
assert.match(pi2.injected[0].content, /queued across a failure/);
|
|
323
|
+
// The next settle reports a drained queue (hasPendingMessages = false) →
|
|
324
|
+
// commit lands exactly once.
|
|
325
|
+
settle(pi2);
|
|
326
|
+
assert.equal(readCursor('node-cursor-failhold'), entry.ts, 'the settle after pi drains the queue commits the cursor');
|
|
327
|
+
});
|
|
328
|
+
// Situational custom sends do not enter pi's user steer/followUp queue, so
|
|
329
|
+
// hasPendingMessages() never reflects them: a situational handoff commits at
|
|
330
|
+
// the next drained settle (a situational send that async-rejected is the
|
|
331
|
+
// accepted silent-loss window, not held).
|
|
332
|
+
test('situational custom send: committed at the next drained agent_settled', async () => {
|
|
333
|
+
freshNode('node-cursor-situational');
|
|
334
|
+
appendSituationalContext('node-cursor-situational', 'ambient checkout state');
|
|
335
|
+
const pi1 = makeFakePi();
|
|
336
|
+
disposers.push(registerCanvasInboxWatcher(pi1));
|
|
337
|
+
// Idle so the situational-only entry delivers as a hidden custom message.
|
|
338
|
+
await wait(TICK_MS + 100);
|
|
339
|
+
const entry = appendInbox('node-cursor-situational', {
|
|
340
|
+
from: null, tier: 'normal', kind: 'message', label: '(ambient context updated)',
|
|
341
|
+
data: { situational: true, situationalOnly: true },
|
|
342
|
+
});
|
|
343
|
+
await wait(SETTLE_MS);
|
|
344
|
+
assert.equal(pi1.sentMessages.length, 1, 'the hidden custom message was sent');
|
|
345
|
+
assert.equal(readCursor('node-cursor-situational'), undefined, 'not committed before the settle');
|
|
346
|
+
settle(pi1);
|
|
347
|
+
assert.equal(readCursor('node-cursor-situational'), entry.ts, 'the situational handoff commits at the drained settle');
|
|
348
|
+
});
|
|
349
|
+
});
|
|
@@ -15,12 +15,13 @@ import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher
|
|
|
15
15
|
import { appendInbox } from '../feed/inbox.js';
|
|
16
16
|
import { appendSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../runtime/situational-context.js';
|
|
17
17
|
// Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
|
|
18
|
-
// CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the
|
|
19
|
-
// seconds
|
|
20
|
-
// debounce window
|
|
18
|
+
// CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the watcher delivers in tens of
|
|
19
|
+
// milliseconds, not seconds — exactly the resolve+seed tick, read tick, and
|
|
20
|
+
// debounce window it runs against the real 800/1000 cadence. Tests then poll for
|
|
21
|
+
// the delivery with waitFor() rather than sleeping a fixed settle window, so a
|
|
22
|
+
// starved interval tick on a loaded CI runner can't race the assertion.
|
|
21
23
|
const TICK_MS = 20;
|
|
22
24
|
const DEBOUNCE_MS = 25;
|
|
23
|
-
const SETTLE_MS = TICK_MS * 2 + DEBOUNCE_MS + 30;
|
|
24
25
|
let origHome;
|
|
25
26
|
let origNode;
|
|
26
27
|
const homes = [];
|
|
@@ -45,6 +46,23 @@ function makeFakePi() {
|
|
|
45
46
|
};
|
|
46
47
|
}
|
|
47
48
|
const wait = (ms) => new Promise((r) => setTimeout(r, ms));
|
|
49
|
+
// Poll `predicate` until it holds or `timeoutMs` elapses, then return either
|
|
50
|
+
// way. The watcher delivers on its own setInterval cadence, which competes for
|
|
51
|
+
// the event loop with this test's timers; a fixed sleep before asserting is
|
|
52
|
+
// unreliable on an oversubscribed CI runner (--test-concurrency=4), where an
|
|
53
|
+
// interval tick is starved past the debounce window and the assertion runs
|
|
54
|
+
// before the flush — the observed `0 !== 1` flake. Polling returns the instant
|
|
55
|
+
// delivery lands (fast in the common case) and tolerates scheduling jitter with
|
|
56
|
+
// a generous ceiling; returning on timeout lets the following asserts produce a
|
|
57
|
+
// precise message instead of a generic wait error.
|
|
58
|
+
async function waitFor(predicate, timeoutMs = 3000, stepMs = 10) {
|
|
59
|
+
const deadline = Date.now() + timeoutMs;
|
|
60
|
+
while (Date.now() < deadline) {
|
|
61
|
+
if (predicate())
|
|
62
|
+
return;
|
|
63
|
+
await wait(stepMs);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
48
66
|
let origTick;
|
|
49
67
|
let origDebounce;
|
|
50
68
|
before(() => {
|
|
@@ -92,7 +110,7 @@ describe('canvas inbox watcher — finished-node delivery', () => {
|
|
|
92
110
|
pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
93
111
|
await wait(TICK_MS + 100);
|
|
94
112
|
appendInbox('node-final', { from: 'child-1', tier: 'normal', kind: 'final', label: 'all done' });
|
|
95
|
-
await
|
|
113
|
+
await waitFor(() => pi.injected.length >= 1);
|
|
96
114
|
assert.equal(pi.injected.length, 1, 'one coalesced injection');
|
|
97
115
|
assert.equal(pi.injected[0].deliverAs, 'steer', 'a finished node steers, not follows up');
|
|
98
116
|
});
|
|
@@ -103,7 +121,7 @@ describe('canvas inbox watcher — finished-node delivery', () => {
|
|
|
103
121
|
pi.fire('agent_start', { type: 'agent_start' }, { isIdle: () => false });
|
|
104
122
|
await wait(TICK_MS + 100);
|
|
105
123
|
appendInbox('node-update', { from: 'child-2', tier: 'normal', kind: 'update', label: 'still working' });
|
|
106
|
-
await
|
|
124
|
+
await waitFor(() => pi.injected.length >= 1);
|
|
107
125
|
assert.equal(pi.injected.length, 1);
|
|
108
126
|
assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
|
|
109
127
|
});
|
|
@@ -121,7 +139,10 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
|
|
|
121
139
|
label: '(ambient context updated)',
|
|
122
140
|
data: { situational: true, situationalOnly: true },
|
|
123
141
|
});
|
|
124
|
-
|
|
142
|
+
// A situational-only flush sets sentMessages (never injected) in a single
|
|
143
|
+
// synchronous flush(), so waiting on the hidden send is sufficient to also
|
|
144
|
+
// settle the injected.length === 0 assertion below.
|
|
145
|
+
await waitFor(() => pi.sentMessages.length >= 1);
|
|
125
146
|
assert.equal(pi.injected.length, 0, 'a situational-only entry never triggers a visible digest');
|
|
126
147
|
assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
|
|
127
148
|
assert.match(pi.sentMessages[0].content, /<situational-context>/);
|
|
@@ -144,7 +165,9 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
|
|
|
144
165
|
label: 'visible update',
|
|
145
166
|
data: { body: 'the visible message body', situational: true },
|
|
146
167
|
});
|
|
147
|
-
|
|
168
|
+
// The hidden send and the visible digest both fire in one synchronous
|
|
169
|
+
// flush(); the injection is the later of the two, so wait on it.
|
|
170
|
+
await waitFor(() => pi.injected.length >= 1 && pi.sentMessages.length >= 1);
|
|
148
171
|
assert.equal(pi.injected.length, 1, 'the visible body still reaches the digest');
|
|
149
172
|
assert.match(pi.injected[0].content, /the visible message body/);
|
|
150
173
|
assert.doesNotMatch(pi.injected[0].content, /hidden ambient checkout state/, 'ambient text never rides the visible digest');
|
|
@@ -177,7 +200,9 @@ describe('canvas inbox watcher — hidden situational-context delivery', () => {
|
|
|
177
200
|
label: '(ambient context updated)',
|
|
178
201
|
data: { situational: true, situationalOnly: true },
|
|
179
202
|
});
|
|
180
|
-
|
|
203
|
+
// First send throws (re-queued), so a later flush retries: wait for the
|
|
204
|
+
// successful second delivery rather than a fixed multi-settle sleep.
|
|
205
|
+
await waitFor(() => calls >= 2 && pi.sentMessages.length >= 1);
|
|
181
206
|
assert.ok(calls >= 2, 'sendMessage retried after the first failure instead of being swallowed once');
|
|
182
207
|
assert.equal(pi.injected.length, 0, 'still never reaches the visible digest');
|
|
183
208
|
assert.equal(pi.sentMessages.length, 1, 'eventually delivered exactly once after the retry succeeds');
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|