@crouton-kit/crouter 0.3.61 → 0.3.63
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/builtin-views/canvas/core.mjs +2 -1
- package/dist/clients/attach/__tests__/context-message.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/context-message.test.js +46 -0
- package/dist/clients/attach/__tests__/transport-relay.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/transport-relay.test.js +176 -0
- package/dist/clients/attach/attach-cmd.js +747 -743
- package/dist/clients/attach/canvas-panels.d.ts +4 -4
- package/dist/clients/attach/canvas-panels.js +108 -37
- package/dist/clients/attach/chat-view.js +11 -0
- package/dist/clients/attach/context-message.d.ts +3 -1
- package/dist/clients/attach/context-message.js +30 -21
- package/dist/clients/attach/graph-overlay.d.ts +8 -1
- package/dist/clients/attach/graph-overlay.js +201 -22
- package/dist/clients/attach/transport-relay.d.ts +51 -0
- package/dist/clients/attach/transport-relay.js +196 -0
- package/dist/clients/attach/transport-socket.d.ts +29 -0
- package/dist/clients/attach/transport-socket.js +164 -0
- package/dist/clients/attach/transport.d.ts +32 -0
- package/dist/clients/attach/transport.js +26 -0
- package/dist/clients/attach/view-socket.d.ts +40 -44
- package/dist/clients/attach/view-socket.js +94 -197
- package/dist/commands/memory/delete.js +3 -3
- package/dist/commands/memory/origin.js +1 -1
- package/dist/commands/memory/read.js +3 -3
- package/dist/commands/memory/shared.d.ts +8 -6
- package/dist/commands/memory/shared.js +23 -9
- package/dist/commands/memory/write.js +3 -3
- package/dist/commands/node-worktree.js +9 -9
- package/dist/commands/node.js +71 -23
- package/dist/commands/sys/doctor.js +1 -0
- package/dist/core/__tests__/broker-fork-seam.test.js +17 -2
- package/dist/core/__tests__/broker-snapshot-history.test.js +12 -2
- package/dist/core/__tests__/canvas-inbox-watcher.test.js +79 -0
- package/dist/core/__tests__/context-intro.test.js +11 -0
- package/dist/core/__tests__/fixtures/fake-engine.d.ts +9 -4
- package/dist/core/__tests__/fixtures/fake-engine.js +12 -4
- package/dist/core/__tests__/full/broker-crash-teardown.test.js +17 -3
- package/dist/core/__tests__/kickoff.test.js +21 -0
- package/dist/core/__tests__/spawn-root.test.js +60 -0
- package/dist/core/__tests__/worktree.test.js +24 -1
- package/dist/core/canvas/browse/app.d.ts +2 -0
- package/dist/core/canvas/browse/app.js +83 -48
- package/dist/core/canvas/db.js +3 -2
- package/dist/core/canvas/index.d.ts +1 -0
- package/dist/core/canvas/index.js +1 -0
- package/dist/core/canvas/nav-model.d.ts +5 -3
- package/dist/core/canvas/nav-model.js +7 -6
- package/dist/core/canvas/paths.d.ts +8 -0
- package/dist/core/canvas/paths.js +10 -0
- package/dist/core/canvas/render.d.ts +6 -0
- package/dist/core/canvas/render.js +117 -20
- package/dist/core/canvas/source.d.ts +26 -0
- package/dist/core/canvas/source.js +29 -0
- package/dist/core/canvas/status-glyph.js +1 -0
- package/dist/core/canvas/types.d.ts +4 -3
- package/dist/core/config.d.ts +18 -2
- package/dist/core/config.js +88 -10
- package/dist/core/fault-classifier.d.ts +2 -2
- package/dist/core/memory-resolver.d.ts +9 -6
- package/dist/core/memory-resolver.js +28 -4
- package/dist/core/profiles/__tests__/root-profile.test.d.ts +1 -0
- package/dist/core/profiles/__tests__/root-profile.test.js +69 -0
- package/dist/core/profiles/manifest.d.ts +2 -0
- package/dist/core/profiles/manifest.js +40 -7
- package/dist/core/profiles/select.d.ts +4 -4
- package/dist/core/profiles/select.js +13 -13
- package/dist/core/runtime/__tests__/spawn-env.test.d.ts +1 -0
- package/dist/core/runtime/__tests__/spawn-env.test.js +268 -0
- package/dist/core/runtime/bearings.d.ts +5 -4
- package/dist/core/runtime/bearings.js +12 -6
- package/dist/core/runtime/broker.js +59 -7
- package/dist/core/runtime/fault-recovery.js +4 -0
- package/dist/core/runtime/front-door.js +1 -1
- package/dist/core/runtime/host.js +43 -3
- package/dist/core/runtime/kickoff.js +6 -0
- package/dist/core/runtime/launch.d.ts +6 -1
- package/dist/core/runtime/launch.js +7 -1
- package/dist/core/runtime/nodes.d.ts +7 -6
- package/dist/core/runtime/nodes.js +3 -3
- package/dist/core/runtime/situational-context.d.ts +12 -0
- package/dist/core/runtime/situational-context.js +48 -0
- package/dist/core/runtime/spawn-env.d.ts +27 -0
- package/dist/core/runtime/spawn-env.js +169 -0
- package/dist/core/runtime/spawn.d.ts +16 -7
- package/dist/core/runtime/spawn.js +8 -2
- package/dist/core/substrate/session-cache.js +7 -1
- package/dist/core/substrate/subject.d.ts +1 -1
- package/dist/core/view/remote-canvas-target.d.ts +3 -0
- package/dist/core/view/remote-canvas-target.js +25 -0
- package/dist/core/worktree.d.ts +4 -4
- package/dist/core/worktree.js +41 -19
- package/dist/daemon/crtrd.js +32 -0
- package/dist/daemon/predicate-eval.js +12 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.js +4 -0
- package/dist/pi-extensions/canvas-bash-valve.d.ts +2 -0
- package/dist/pi-extensions/canvas-bash-valve.js +238 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +12 -0
- package/dist/pi-extensions/canvas-inbox-watcher.js +41 -5
- package/dist/types.d.ts +32 -0
- package/dist/types.js +7 -0
- package/dist/web-client/assets/index-BnmSLNLa.css +2 -0
- package/dist/web-client/assets/{index-BQufdMhB.js → index-BvzxXXGU.js} +18 -18
- package/dist/web-client/index.html +2 -2
- package/dist/web-client/sw.js +1 -1
- package/docs/public-api.md +5 -3
- package/package.json +2 -2
- package/dist/web-client/assets/fraunces-latin-ext-wght-normal-Ca2vKHc0.woff2 +0 -0
- package/dist/web-client/assets/fraunces-latin-wght-normal-ukD16Tqj.woff2 +0 -0
- package/dist/web-client/assets/fraunces-vietnamese-wght-normal-CnvboYUG.woff2 +0 -0
- package/dist/web-client/assets/index-FjfN-jvN.css +0 -2
- package/dist/web-client/assets/instrument-sans-latin-ext-wght-normal-B5bTHO_g.woff2 +0 -0
- package/dist/web-client/assets/instrument-sans-latin-wght-normal-BbzFLZTg.woff2 +0 -0
- package/dist/web-client/workbox-98f7a950.js +0 -1
|
@@ -29,24 +29,24 @@ function mapWorktreeError(err) {
|
|
|
29
29
|
const worktreeClose = defineLeaf({
|
|
30
30
|
name: 'close',
|
|
31
31
|
description: 'land and close the current node-managed worktree',
|
|
32
|
-
whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto
|
|
32
|
+
whenToUse: 'you are inside the node that owns an open managed worktree and are ready to rebase it onto local main, fast-forward local main onto it, and (best-effort) remove the worktree and delete the branch before finishing',
|
|
33
33
|
help: {
|
|
34
34
|
name: 'node worktree close',
|
|
35
35
|
summary: 'land and close the current node-managed worktree',
|
|
36
36
|
params: [],
|
|
37
37
|
output: [
|
|
38
38
|
{ name: 'node_id', type: 'string', required: true, constraint: 'The caller node id whose managed worktree was closed.' },
|
|
39
|
-
{ name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into main.' },
|
|
40
|
-
{ name: '
|
|
39
|
+
{ name: 'branch', type: 'string', required: true, constraint: 'The worktree branch that was fast-forwarded into local main.' },
|
|
40
|
+
{ name: 'landed_sha', type: 'string', required: true, constraint: 'The commit SHA that local main was fast-forwarded onto.' },
|
|
41
41
|
{ name: 'worktree_path', type: 'string', required: true, constraint: 'The managed worktree path that was closed (best-effort removed; see worktree_removed).' },
|
|
42
|
-
{ name: 'worktree_removed', type: 'boolean', required: true, constraint: 'True when the worktree directory was removed; false means the
|
|
42
|
+
{ name: 'worktree_removed', type: 'boolean', required: true, constraint: 'True when the worktree directory was removed; false means the landing still happened (the close is done) but automatic removal failed and needs a manual follow-up.' },
|
|
43
43
|
{ name: 'worktree_remove_error', type: 'string', required: false, constraint: 'Present only when worktree_removed is false: the manual cleanup command to run.' },
|
|
44
|
-
{ name: 'branch_deleted', type: 'boolean', required: true, constraint: 'True when the local branch was deleted; false means the
|
|
44
|
+
{ name: 'branch_deleted', type: 'boolean', required: true, constraint: 'True when the local branch was deleted; false means the landing still happened (the close is done) but the local branch cleanup failed and needs a manual follow-up.' },
|
|
45
45
|
{ name: 'branch_delete_error', type: 'string', required: false, constraint: 'Present only when branch_deleted is false: the manual cleanup command to run.' },
|
|
46
46
|
],
|
|
47
47
|
outputKind: 'object',
|
|
48
48
|
effects: [
|
|
49
|
-
'
|
|
49
|
+
'Rebases the worktree onto local main, fast-forwards local main onto it (never pushes to origin), marks the node-managed worktree closed, then attempts (best-effort) to remove the worktree and delete the local branch.',
|
|
50
50
|
],
|
|
51
51
|
},
|
|
52
52
|
run: async () => {
|
|
@@ -63,9 +63,9 @@ const worktreeClose = defineLeaf({
|
|
|
63
63
|
},
|
|
64
64
|
render: (r) => {
|
|
65
65
|
const branch = String(r['branch'] ?? '');
|
|
66
|
-
const sha = String(r['
|
|
66
|
+
const sha = String(r['landed_sha'] ?? '');
|
|
67
67
|
const path = String(r['worktree_path'] ?? '');
|
|
68
|
-
let base = `Managed worktree landed —
|
|
68
|
+
let base = `Managed worktree landed — local main fast-forwarded to ${branch} at ${sha}.`;
|
|
69
69
|
const notes = [];
|
|
70
70
|
if (r['worktree_removed'] === false) {
|
|
71
71
|
notes.push(String(r['worktree_remove_error'] ?? `worktree checkout at ${path} could not be removed automatically.`));
|
|
@@ -84,7 +84,7 @@ const worktreeClose = defineLeaf({
|
|
|
84
84
|
export const nodeWorktreeBranch = defineBranch({
|
|
85
85
|
name: 'worktree',
|
|
86
86
|
description: 'manage a node-owned git worktree — close the one born by `node new --worktree`',
|
|
87
|
-
whenToUse: 'you are inside a node that was spawned with `node new --worktree` and are ready to land it: rebase onto
|
|
87
|
+
whenToUse: 'you are inside a node that was spawned with `node new --worktree` and are ready to land it: rebase onto local main, fast-forward local main onto it, and close the worktree. Symmetric with `node new --worktree`, which births it',
|
|
88
88
|
help: {
|
|
89
89
|
name: 'node worktree',
|
|
90
90
|
summary: 'manage a node-owned git worktree — birth is `node new --worktree`; this branch closes it',
|
package/dist/commands/node.js
CHANGED
|
@@ -32,10 +32,12 @@ import { WorktreeError } from '../core/worktree.js';
|
|
|
32
32
|
import { closeNode } from '../core/runtime/close.js';
|
|
33
33
|
import { isBrokerLive, setModelLive, persistDormantModel } from '../core/runtime/model-swap.js';
|
|
34
34
|
import { appendInbox } from '../core/feed/inbox.js';
|
|
35
|
+
import { appendSituationalContext } from '../core/runtime/situational-context.js';
|
|
35
36
|
import { readMergedLaunchConfig } from '../core/config.js';
|
|
36
37
|
import { listProfiles } from '../core/profiles/manifest.js';
|
|
37
38
|
import { stateBlock } from '../core/help.js';
|
|
38
|
-
import { getNode, updateNode, listNodes, subscribe, unsubscribe,
|
|
39
|
+
import { getNode, updateNode, listNodes, subscribe, unsubscribe, contextDir, reportsDir, nodeArtifacts, readContextTokens, armTrigger, listTriggers, cancelTrigger, TriggerArmError, fullName, } from '../core/canvas/index.js';
|
|
40
|
+
import { localCanvasSource } from '../core/canvas/source.js';
|
|
39
41
|
// Past this much context, an ORCHESTRATOR that spawns a managed child is better
|
|
40
42
|
// off yielding than holding its fat window open for the child's result: the
|
|
41
43
|
// child's finish revives it fresh against its roadmap, so a clean window absorbs
|
|
@@ -132,10 +134,11 @@ const nodeNew = defineLeaf({
|
|
|
132
134
|
{ kind: 'flag', name: 'name', type: 'string', required: false, constraint: 'Display name (tmux window + resume picker). Defaults to the kind.' },
|
|
133
135
|
{ kind: 'flag', name: 'parent', type: 'string', required: false, constraint: 'Parent node id. Defaults to the calling node (CRTR_NODE_ID).' },
|
|
134
136
|
{ kind: 'flag', name: 'root', type: 'bool', required: false, constraint: 'Spawn an INDEPENDENT root instead of a managed child: no parent (top-level on the canvas), NO subscription back to you (you are NOT woken by it), resident lifecycle. It records spawned_by=you for provenance and is brought forefront so it can be driven directly. Use for a node you hand off and do not manage (e.g. a sub-orchestrator a human will discuss with). Mutually exclusive with --worktree.' },
|
|
135
|
-
{ kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on `
|
|
137
|
+
{ kind: 'flag', name: 'worktree', type: 'bool', required: false, constraint: 'Use when the child will COMMIT to the repo while you or other agents may also be editing it — the worktree isolates its work so nothing collides; skip it for read-only work or when the child is the sole writer. Creates a crouter-managed git worktree for the spawned child, keyed by the child node id, at `~/.crouter/canvas/worktrees/<node-id>/` on branch `crtr/<node-id>` based on local `main`; the child\'s cwd is pinned there, and it lands the work serially onto local main later with `crtr node worktree close`. Mutually exclusive with --root.' },
|
|
136
138
|
{ kind: 'flag', name: 'fork-from', type: 'string', required: false, constraint: 'FORK the new node from an existing pi conversation instead of starting it fresh: pass a node id (forks from that node\'s session), an absolute session `.jsonl` path, or a partial pi session uuid. pi copies that whole history into a NEW session for the child (the source is untouched), then the prompt is delivered as the next message — i.e. the child wakes up as a continuation of that conversation. Use to branch exploratory work off a node that already built up the context you need, instead of re-deriving it. One-shot at birth: the fork resumes its own session thereafter.' },
|
|
137
139
|
{ kind: 'flag', name: 'model', type: 'string', required: false, constraint: 'Override the model the node runs on: exact `provider/id` (e.g. openai-codex/gpt-5.5), a `provider/tier` (anthropic|openai + ultra|strong|medium|light, e.g. openai/ultra — pin a specific model family, useful for spawning cross-family children), a bare capability TIER (ultra|strong|medium|light), or a family alias (opus|sonnet|haiku). Must resolve to a concrete model OFFLINE — unlike `node config --model`, no live broker exists yet at spawn to free-text substring-search a registry. Omit to use the kind\'s persona default (advisor=ultra, explore=light, most other builtins=strong). The override is durable — it survives revives and polymorphs (promote/demote).' },
|
|
138
|
-
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (
|
|
140
|
+
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Select the profile this node runs under — an exact profile id or a unique manifest name (see the <profiles> list below, or `crtr profile list`). Omit to INHERIT the calling node\'s current profile (which may be none, e.g. a historical null-profile parent). A shell/no-spawner root with no profile coverage instead runs the startup selector and can persist the stable root profile. --root does NOT reset this — it only means top-level, still inherits/uses the selected profile unless this overrides it.' },
|
|
141
|
+
{ kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Hidden ambient context — current situation (e.g. which applet/page spawned this), NOT the visible prompt. Appended as a `<situational-context>` sibling block in the injected bearings, never as visible chat. Preserved unchanged into a deferred --at/--every/--when node_birth recipe.' },
|
|
139
142
|
{ kind: 'flag', name: 'output-schema', type: 'string', required: false, constraint: 'Path to a JSON Schema file, or inline JSON beginning with `{`. The spawned node gets a `submit` tool shaped by this schema, must call it to finish, and the submitted JSON is written to the final report and context/result.json. Rejected with trigger flags in this version.' },
|
|
140
143
|
{ kind: 'flag', name: 'at', type: 'string', required: false, constraint: 'DEFER the birth to a one-shot clock trigger at this future time instead of spawning now — a duration ("5m","1h30m"), a zoned ISO ("2026-06-07T09:00:00Z"), or a bare ISO ("2026-06-07T09:00", host-local or in --tz). Required unless --every is given (the cadence then sets the first fire). Mutually exclusive with --when.' },
|
|
141
144
|
{ kind: 'flag', name: 'every', type: 'string', required: false, constraint: 'SPAWN-CRON: re-birth a fresh node on this cadence — a duration ("6h","30m") or a 5-field cron / @alias ("0 9 * * *","@daily"). Combine with --at to anchor the first fire, then recur normally. Min cadence 60s. With --when, this instead sets the PREDICATE EVALUATION cadence (required), not a repeating birth.' },
|
|
@@ -161,7 +164,7 @@ const nodeNew = defineLeaf({
|
|
|
161
164
|
dynamicState: () => [kindsStateBlock(), profilesStateBlock()].join('\n'),
|
|
162
165
|
outputKind: 'object',
|
|
163
166
|
effects: [
|
|
164
|
-
'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on
|
|
167
|
+
'Spawning now: creates a node under ~/.crouter/canvas/nodes/<id>/ and indexes it in canvas.db. Default (managed child): parent auto-subscribes (active) and is woken on the child\'s pushes. With --root: no subscription — records a spawned_by edge for provenance only. With --worktree: also creates a managed git worktree at ~/.crouter/canvas/worktrees/<node-id>/ on branch crtr/<node-id> based on local main; the child closes it later with `crtr node worktree close` before finishing. With --output-schema: writes nodes/<id>/output-schema.json in terminal mode; the node must call submit, which writes context/result.json and pushes final. Launches the node\'s engine as a detached broker (the only host); a managed child opens NO viewer, a --root opens one in your current tmux session.',
|
|
165
168
|
'Armed (--at/--every/--when): inserts one detached `new`/`node_birth` triggers row (node_id NULL, owner=you, parent/cwd resolved now); NO node and NO window exist until fire time. The daemon spawns from the stored recipe at fire time, re-deriving the launch spec live.',
|
|
166
169
|
],
|
|
167
170
|
},
|
|
@@ -189,6 +192,8 @@ const nodeNew = defineLeaf({
|
|
|
189
192
|
}
|
|
190
193
|
const model = modelSpec;
|
|
191
194
|
const profile = input['profile'];
|
|
195
|
+
const situationalContextRaw = input['situationalContext']?.trim();
|
|
196
|
+
const situationalContext = situationalContextRaw !== undefined && situationalContextRaw !== '' ? situationalContextRaw : undefined;
|
|
192
197
|
const outputSchema = parseOutputSchemaValue(input['outputSchema']);
|
|
193
198
|
const at = input['at']?.trim();
|
|
194
199
|
const every = input['every']?.trim();
|
|
@@ -215,7 +220,7 @@ const nodeNew = defineLeaf({
|
|
|
215
220
|
}
|
|
216
221
|
if (!triggered) {
|
|
217
222
|
try {
|
|
218
|
-
const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, worktree, forkFrom, model, profile });
|
|
223
|
+
const res = await spawnChild({ kind, mode, cwd, name, prompt, parent, root, worktree, forkFrom, model, profile, situationalContext });
|
|
219
224
|
if (outputSchema !== null)
|
|
220
225
|
writeOutputSchema(res.node.node_id, 'terminal', outputSchema);
|
|
221
226
|
return {
|
|
@@ -263,6 +268,7 @@ const nodeNew = defineLeaf({
|
|
|
263
268
|
...(forkFrom !== undefined ? { forkFrom } : {}),
|
|
264
269
|
...(model !== undefined ? { model } : {}),
|
|
265
270
|
...(profile !== undefined ? { profile } : {}),
|
|
271
|
+
...(situationalContext !== undefined ? { situationalContext } : {}),
|
|
266
272
|
};
|
|
267
273
|
const id = `tr-${newNodeId()}`;
|
|
268
274
|
if (hasWhen) {
|
|
@@ -404,14 +410,15 @@ const nodeList = defineLeaf({
|
|
|
404
410
|
const hangingOnly = input['hanging'] === true;
|
|
405
411
|
// Graph scope (--under): self + subscription descendants. Validate the anchor
|
|
406
412
|
// so a typo'd id is a structured error, not a silently-empty roster.
|
|
413
|
+
const canvasSource = localCanvasSource;
|
|
407
414
|
let scope;
|
|
408
415
|
if (under !== undefined && under !== '') {
|
|
409
|
-
if (getNode(under) === null) {
|
|
416
|
+
if (await canvasSource.getNode(under) === null) {
|
|
410
417
|
throw new InputError({ error: 'not_found', message: `no node: ${under}`, next: 'List nodes with `crtr node inspect list`, then pass a real id to --under.' });
|
|
411
418
|
}
|
|
412
|
-
scope = new Set([under, ...view(under)]);
|
|
419
|
+
scope = new Set([under, ...(await canvasSource.view(under))]);
|
|
413
420
|
}
|
|
414
|
-
const rows = listNodes(status !== undefined ? { status } : undefined).filter((row) => {
|
|
421
|
+
const rows = (await canvasSource.listNodes(status !== undefined ? { status } : undefined)).filter((row) => {
|
|
415
422
|
if (scope !== undefined && !scope.has(row.node_id))
|
|
416
423
|
return false;
|
|
417
424
|
if (kinds !== undefined && !kinds.includes(row.kind))
|
|
@@ -439,8 +446,8 @@ const nodeList = defineLeaf({
|
|
|
439
446
|
// ---------------------------------------------------------------------------
|
|
440
447
|
// Enrich a bare subscription edge with the neighbor's name/kind/status so a
|
|
441
448
|
// debugger reads who a report/manager IS without a second `show` per id.
|
|
442
|
-
function
|
|
443
|
-
const n = getNode(ref.node_id);
|
|
449
|
+
async function enrichNeighborFromSource(source, ref) {
|
|
450
|
+
const n = await source.getNode(ref.node_id);
|
|
444
451
|
return {
|
|
445
452
|
node_id: ref.node_id,
|
|
446
453
|
name: n ? fullName(n) : '(missing)',
|
|
@@ -478,12 +485,13 @@ const nodeShow = defineLeaf({
|
|
|
478
485
|
},
|
|
479
486
|
run: async (input) => {
|
|
480
487
|
const id = input['node'];
|
|
481
|
-
const
|
|
488
|
+
const canvasSource = localCanvasSource;
|
|
489
|
+
const node = await canvasSource.getNode(id);
|
|
482
490
|
if (node === null) {
|
|
483
491
|
throw new InputError({ error: 'not_found', message: `no node: ${id}`, next: 'List nodes with `crtr node inspect list`.' });
|
|
484
492
|
}
|
|
485
|
-
const reports = subscriptionsOf(id).map(
|
|
486
|
-
const managers = subscribersOf(id).map(
|
|
493
|
+
const reports = await Promise.all((await canvasSource.subscriptionsOf(id)).map((ref) => enrichNeighborFromSource(canvasSource, ref)));
|
|
494
|
+
const managers = await Promise.all((await canvasSource.subscribersOf(id)).map((ref) => enrichNeighborFromSource(canvasSource, ref)));
|
|
487
495
|
const arts = nodeArtifacts(id, ['report', 'doc', 'roadmap']);
|
|
488
496
|
const artifacts = {
|
|
489
497
|
report: arts.filter((a) => a.source === 'report').length,
|
|
@@ -1138,6 +1146,7 @@ const nodeMsg = defineLeaf({
|
|
|
1138
1146
|
{ kind: 'flag', name: 'until', type: 'string', required: false, constraint: 'Self-only: bind a deadline that decides your next action — you wake on the first inbox message, or at this time if none arrives (whichever fires first cancels the other), and the body is the policy to follow in the no-message case. One-shot, urgent tier, ≤ 1 per node (a new --until replaces any prior). Mutually exclusive with --at, --every, --when, --fresh, --tier, and --timeout. Requires --self and a body.' },
|
|
1139
1147
|
{ kind: 'flag', name: 'timeout', type: 'string', required: false, constraint: 'Wall-clock bound for a --when predicate — a duration or absolute time after which the trigger is consumed and the owner gets one urgent notice. Required with --when; rejected otherwise.' },
|
|
1140
1148
|
{ kind: 'flag', name: 'tz', type: 'string', required: false, constraint: 'IANA zone for a calendar --every, a bare-ISO --at, or a bare-ISO --until (default: host-local). Valid only alongside --at/--every/--until; rejected with --when, and rejected when acting immediately (no trigger flags) since none of those have anything to parse it against.' },
|
|
1149
|
+
{ kind: 'flag', name: 'situational-context', type: 'string', required: false, constraint: 'Hidden ambient context — current situation, NOT the visible message body. Upserted onto the target\'s node-owned sidecar and delivered as a `<situational-context>` sibling block, never as visible chat/digest. Valid with a body, with --fresh, or alone (then no body is required). Immediate delivery only — rejected with --at, --every, --when, or --until.' },
|
|
1141
1150
|
],
|
|
1142
1151
|
output: [
|
|
1143
1152
|
{ name: 'mode', type: 'string', required: true, constraint: '"inbox_message", "fresh_revive", or "deadline_message" — what this message enacts.' },
|
|
@@ -1181,7 +1190,12 @@ const nodeMsg = defineLeaf({
|
|
|
1181
1190
|
const hasAt = at !== undefined && at !== '';
|
|
1182
1191
|
const hasEvery = every !== undefined && every !== '';
|
|
1183
1192
|
const outputSchema = parseOutputSchemaValue(input['outputSchema']);
|
|
1193
|
+
const situationalContextRaw = input['situationalContext']?.trim();
|
|
1194
|
+
const hasSituational = situationalContextRaw !== undefined && situationalContextRaw !== '';
|
|
1184
1195
|
// --- Validation per the approved flag matrix ---
|
|
1196
|
+
if (hasSituational && (hasAt || hasEvery || hasWhen || hasUntil)) {
|
|
1197
|
+
throw new InputError({ error: 'situational_context_immediate_only', message: '--situational-context is only valid for immediate delivery (no --at, --every, --when, or --until)', field: 'situational-context', next: 'Drop the trigger flag(s), or drop --situational-context.' });
|
|
1198
|
+
}
|
|
1185
1199
|
if (hasUntil && !isSelf) {
|
|
1186
1200
|
throw new InputError({ error: 'until_self_only', message: '--until is self-only', field: 'until', next: 'Use --self, or drop --until.' });
|
|
1187
1201
|
}
|
|
@@ -1374,6 +1388,10 @@ const nodeMsg = defineLeaf({
|
|
|
1374
1388
|
next: 'Seed a goal/roadmap first, or drop --fresh so the woken context carries a message.',
|
|
1375
1389
|
});
|
|
1376
1390
|
}
|
|
1391
|
+
// Update the sidecar BEFORE the revive so buildReviveKickoff's disk read
|
|
1392
|
+
// picks up this update on the fresh kickoff it is about to assemble.
|
|
1393
|
+
if (hasSituational)
|
|
1394
|
+
appendSituationalContext(targetId, situationalContextRaw);
|
|
1377
1395
|
reviveNode(targetId, { resume: false });
|
|
1378
1396
|
return {
|
|
1379
1397
|
mode: 'fresh_revive',
|
|
@@ -1383,8 +1401,8 @@ const nodeMsg = defineLeaf({
|
|
|
1383
1401
|
guidance: `Fresh-revived ${isSelf ? 'yourself' : targetId} now — it resumes a clean window re-reading its roadmap/disk.`,
|
|
1384
1402
|
};
|
|
1385
1403
|
}
|
|
1386
|
-
if (!hasBody && outputSchema === null) {
|
|
1387
|
-
throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when / --output-schema)', field: 'body', next: 'Pass the message after the target flags or on stdin,
|
|
1404
|
+
if (!hasBody && !hasSituational && outputSchema === null) {
|
|
1405
|
+
throw new InputError({ error: 'empty_body', message: 'a message body is required (or --fresh / --when / --output-schema / --situational-context)', field: 'body', next: 'Pass the message after the target flags or on stdin, pass --output-schema to grant a one-off structured-output request, or pass --situational-context alone for a hidden-only update.' });
|
|
1388
1406
|
}
|
|
1389
1407
|
const targetMeta = getNode(targetId);
|
|
1390
1408
|
if (targetMeta === null) {
|
|
@@ -1392,15 +1410,42 @@ const nodeMsg = defineLeaf({
|
|
|
1392
1410
|
}
|
|
1393
1411
|
if (outputSchema !== null)
|
|
1394
1412
|
writeOutputSchema(targetId, 'oneoff', outputSchema);
|
|
1413
|
+
// Update the sidecar BEFORE appending the inbox entry that will wake the
|
|
1414
|
+
// watcher's flush() — it reads the sidecar fresh on delivery.
|
|
1415
|
+
if (hasSituational)
|
|
1416
|
+
appendSituationalContext(targetId, situationalContextRaw);
|
|
1395
1417
|
const tier = (hasTier ? tierRaw : 'normal');
|
|
1396
1418
|
const from = process.env['CRTR_NODE_ID'] ?? 'human';
|
|
1397
|
-
|
|
1398
|
-
|
|
1399
|
-
|
|
1400
|
-
|
|
1401
|
-
|
|
1402
|
-
|
|
1403
|
-
|
|
1419
|
+
if (hasBody || outputSchema !== null) {
|
|
1420
|
+
const messageBody = hasBody
|
|
1421
|
+
? body
|
|
1422
|
+
: 'A one-off structured-output request has been granted. Call the `submit` tool with a result matching the requested schema before you stop; after submit succeeds, continue normally.';
|
|
1423
|
+
// A body over the preview bound spills to a ref file; appendInbox clips the
|
|
1424
|
+
// inline preview and the receiver dereferences the ref for the full text,
|
|
1425
|
+
// same as any other report. `situational: true` tells the inbox watcher's
|
|
1426
|
+
// flush() to ALSO deliver the hidden <situational-context> custom message
|
|
1427
|
+
// ahead of this visible digest — the ambient TEXT itself never rides here.
|
|
1428
|
+
appendInbox(targetId, {
|
|
1429
|
+
from,
|
|
1430
|
+
tier,
|
|
1431
|
+
kind: 'message',
|
|
1432
|
+
label: messageBody.split('\n')[0].slice(0, 120),
|
|
1433
|
+
data: { body: messageBody, ...(hasSituational ? { situational: true } : {}) },
|
|
1434
|
+
});
|
|
1435
|
+
}
|
|
1436
|
+
else {
|
|
1437
|
+
// Situational context alone: a hidden-only wake marker. `situationalOnly`
|
|
1438
|
+
// tells the inbox watcher to exclude this entry from the visible digest
|
|
1439
|
+
// entirely — it exists purely to trigger flush() into delivering the hidden
|
|
1440
|
+
// custom message, never as a chat line.
|
|
1441
|
+
appendInbox(targetId, {
|
|
1442
|
+
from,
|
|
1443
|
+
tier,
|
|
1444
|
+
kind: 'message',
|
|
1445
|
+
label: '(ambient context updated)',
|
|
1446
|
+
data: { situational: true, situationalOnly: true },
|
|
1447
|
+
});
|
|
1448
|
+
}
|
|
1404
1449
|
// A direct message wakes any node: if the target has no live window
|
|
1405
1450
|
// (done/dead/idle-released), revive it so its inbox-watcher delivers this.
|
|
1406
1451
|
let woke = false;
|
|
@@ -1413,7 +1458,10 @@ const nodeMsg = defineLeaf({
|
|
|
1413
1458
|
/* best-effort wake */
|
|
1414
1459
|
}
|
|
1415
1460
|
}
|
|
1416
|
-
|
|
1461
|
+
const guidance = !hasBody && hasSituational
|
|
1462
|
+
? (woke ? 'Situational context delivered as hidden context — the dormant target was revived to receive it; no visible message was sent.' : 'Situational context delivered as hidden context; no visible message was sent.')
|
|
1463
|
+
: (woke ? 'Delivered — the dormant target was revived to receive it.' : 'Delivered.');
|
|
1464
|
+
return { mode: 'inbox_message', armed: false, target, delivered: true, revived: woke, guidance };
|
|
1417
1465
|
},
|
|
1418
1466
|
render: (r) => {
|
|
1419
1467
|
if (r['armed'] === true) {
|
|
@@ -72,6 +72,7 @@ function faultRemediation(fault) {
|
|
|
72
72
|
protocol: 'fix the protocol mismatch',
|
|
73
73
|
other: 'fix the underlying issue',
|
|
74
74
|
wedged: 'read `crtr memory read wedged-child-on-runaway-bash` — if a runaway subprocess is to blame, kill it (not the node); if the broker had no subprocess, the daemon already SIGTERM-kicked it to resume on its own (recheck node status; nudge it if it came back idle)',
|
|
75
|
+
'model-not-found': 'pass a valid `--model provider/id` or configure credentials for an authenticated provider, then revive the node (`crtr node lifecycle revive <id>`)',
|
|
75
76
|
};
|
|
76
77
|
const progress = fault.retry.disposition === 'auto'
|
|
77
78
|
? fault.retry.by === 'client'
|
|
@@ -31,9 +31,19 @@ import { test, before, after } from 'node:test';
|
|
|
31
31
|
import assert from 'node:assert/strict';
|
|
32
32
|
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
|
33
33
|
import { tmpdir } from 'node:os';
|
|
34
|
-
import { join } from 'node:path';
|
|
34
|
+
import { dirname, join } from 'node:path';
|
|
35
|
+
import { fileURLToPath } from 'node:url';
|
|
35
36
|
import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
|
|
36
37
|
import { buildBrokerSession } from '../runtime/broker.js';
|
|
38
|
+
// M-9 (broker.ts fault-and-die model gate): buildBrokerSession with no cfg.model
|
|
39
|
+
// now calls the REAL SDK's modelRegistry.getAvailable() and faults if it's empty
|
|
40
|
+
// — which it always is on a host/CI runner with no authenticated provider. This
|
|
41
|
+
// test drives the fork branch only, so it registers the SAME hermetic
|
|
42
|
+
// fake-but-authenticated model the C3 wiring tests use, rather than depending on
|
|
43
|
+
// ambient host credentials (which don't exist in CI — see broker-sdk-wiring's
|
|
44
|
+
// c3-custom-provider-ext.ts fixture).
|
|
45
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
46
|
+
const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
|
|
37
47
|
// The REAL engine — bypasses the CRTR_BROKER_ENGINE fake entirely (the whole
|
|
38
48
|
// point: the fake omits forkFrom, so only the real SDK can prove the branch).
|
|
39
49
|
const realEngine = {
|
|
@@ -70,7 +80,12 @@ test('D2 broker fork branch: buildBrokerSession(cfg.forkFrom) yields a NEW id +
|
|
|
70
80
|
// the same recipe a `node new --fork-from <id>` boot produces. The OLD broker
|
|
71
81
|
// THREW in this branch ("--fork is not supported by the headless broker"), so
|
|
72
82
|
// if broker.ts:~1106 reverts to that throw, this rejects and the test goes RED.
|
|
73
|
-
const cfg = {
|
|
83
|
+
const cfg = {
|
|
84
|
+
cwd: dir,
|
|
85
|
+
extensionPaths: [C3_EXT],
|
|
86
|
+
forkFrom: srcFile,
|
|
87
|
+
model: 'c3prov/c3model',
|
|
88
|
+
};
|
|
74
89
|
let session;
|
|
75
90
|
let resuming;
|
|
76
91
|
await assert.doesNotReject(async () => {
|
|
@@ -27,9 +27,18 @@ import { test } from 'node:test';
|
|
|
27
27
|
import assert from 'node:assert/strict';
|
|
28
28
|
import { mkdtempSync, rmSync } from 'node:fs';
|
|
29
29
|
import { tmpdir } from 'node:os';
|
|
30
|
-
import { join } from 'node:path';
|
|
30
|
+
import { dirname, join } from 'node:path';
|
|
31
|
+
import { fileURLToPath } from 'node:url';
|
|
31
32
|
import { createAgentSessionServices, createAgentSessionFromServices, SessionManager, VERSION, } from '../runtime/broker-sdk.js';
|
|
32
33
|
import { buildBrokerSession, snapshotMessages } from '../runtime/broker.js';
|
|
34
|
+
// M-9 (broker.ts fault-and-die model gate): a boot with no cfg.model now calls
|
|
35
|
+
// the REAL SDK's modelRegistry.getAvailable() and faults if it's empty, which it
|
|
36
|
+
// always is on a host/CI runner with no authenticated provider. This test only
|
|
37
|
+
// exercises snapshot/history reconstruction, so it registers the same hermetic
|
|
38
|
+
// fake-but-authenticated model the C3 wiring tests use instead of depending on
|
|
39
|
+
// ambient host credentials (see broker-sdk-wiring's c3-custom-provider-ext.ts).
|
|
40
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
41
|
+
const C3_EXT = join(HERE, 'fixtures', 'c3-custom-provider-ext.ts');
|
|
33
42
|
const realEngine = {
|
|
34
43
|
createAgentSessionServices,
|
|
35
44
|
createAgentSessionFromServices,
|
|
@@ -73,8 +82,9 @@ test('broker welcome snapshot serves the persisted history, not pi\u2019s sliced
|
|
|
73
82
|
// 2. Revive: the broker resumes the closed session via the SERVICES path.
|
|
74
83
|
const { session } = await buildBrokerSession(realEngine, {
|
|
75
84
|
cwd,
|
|
76
|
-
extensionPaths: [],
|
|
85
|
+
extensionPaths: [C3_EXT],
|
|
77
86
|
resumeSessionPath: sessionFile,
|
|
87
|
+
model: 'c3prov/c3model',
|
|
78
88
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
79
89
|
});
|
|
80
90
|
try {
|
|
@@ -13,6 +13,7 @@ import { tmpdir } from 'node:os';
|
|
|
13
13
|
import { join } from 'node:path';
|
|
14
14
|
import registerCanvasInboxWatcher from '../../pi-extensions/canvas-inbox-watcher.js';
|
|
15
15
|
import { appendInbox } from '../feed/inbox.js';
|
|
16
|
+
import { appendSituationalContext, SITUATIONAL_CONTEXT_CUSTOM_TYPE } from '../runtime/situational-context.js';
|
|
16
17
|
// Drive the watcher's injectable cadence seam (CRTR_WATCHER_TICK_MS /
|
|
17
18
|
// CRTR_WATCHER_DEBOUNCE_MS) at a fast tempo so the test sleeps milliseconds, not
|
|
18
19
|
// seconds. SETTLE_MS still allows a resolve+seed tick, a read tick, and the
|
|
@@ -36,8 +37,10 @@ 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
|
}
|
|
@@ -105,3 +108,79 @@ describe('canvas inbox watcher — finished-node delivery', () => {
|
|
|
105
108
|
assert.equal(pi.injected[0].deliverAs, 'followUp', 'a normal update is not urgent → followUp');
|
|
106
109
|
});
|
|
107
110
|
});
|
|
111
|
+
describe('canvas inbox watcher — hidden situational-context delivery', () => {
|
|
112
|
+
test('a situational-only entry (no visible body) never reaches the visible digest, only the hidden sendMessage', async () => {
|
|
113
|
+
freshNode('node-situational-only');
|
|
114
|
+
appendSituationalContext('node-situational-only', 'the user is mid-checkout on the store applet');
|
|
115
|
+
const pi = makeFakePi();
|
|
116
|
+
disposers.push(registerCanvasInboxWatcher(pi));
|
|
117
|
+
appendInbox('node-situational-only', {
|
|
118
|
+
from: null,
|
|
119
|
+
tier: 'normal',
|
|
120
|
+
kind: 'message',
|
|
121
|
+
label: '(ambient context updated)',
|
|
122
|
+
data: { situational: true, situationalOnly: true },
|
|
123
|
+
});
|
|
124
|
+
await wait(SETTLE_MS);
|
|
125
|
+
assert.equal(pi.injected.length, 0, 'a situational-only entry never triggers a visible digest');
|
|
126
|
+
assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
|
|
127
|
+
assert.match(pi.sentMessages[0].content, /<situational-context>/);
|
|
128
|
+
assert.match(pi.sentMessages[0].content, /the user is mid-checkout on the store applet/);
|
|
129
|
+
// Pins the seam the attach viewer keys its collapsed-rendering dispatch off
|
|
130
|
+
// (src/clients/attach/chat-view.ts): a regression here (e.g. someone routing
|
|
131
|
+
// the situational update through a different/generic customType) would make
|
|
132
|
+
// the viewer fall through to the prominent, uncollapsed CustomMessageComponent.
|
|
133
|
+
assert.equal(pi.sentMessages[0].customType, SITUATIONAL_CONTEXT_CUSTOM_TYPE);
|
|
134
|
+
});
|
|
135
|
+
test('a combined entry (visible body + situational flag) puts the body in the digest and the ambient text ONLY in the hidden sendMessage', async () => {
|
|
136
|
+
freshNode('node-situational-combined');
|
|
137
|
+
appendSituationalContext('node-situational-combined', 'the hidden ambient checkout state');
|
|
138
|
+
const pi = makeFakePi();
|
|
139
|
+
disposers.push(registerCanvasInboxWatcher(pi));
|
|
140
|
+
appendInbox('node-situational-combined', {
|
|
141
|
+
from: 'child-3',
|
|
142
|
+
tier: 'normal',
|
|
143
|
+
kind: 'message',
|
|
144
|
+
label: 'visible update',
|
|
145
|
+
data: { body: 'the visible message body', situational: true },
|
|
146
|
+
});
|
|
147
|
+
await wait(SETTLE_MS);
|
|
148
|
+
assert.equal(pi.injected.length, 1, 'the visible body still reaches the digest');
|
|
149
|
+
assert.match(pi.injected[0].content, /the visible message body/);
|
|
150
|
+
assert.doesNotMatch(pi.injected[0].content, /hidden ambient checkout state/, 'ambient text never rides the visible digest');
|
|
151
|
+
assert.equal(pi.sentMessages.length, 1, 'exactly one hidden custom message sent');
|
|
152
|
+
assert.match(pi.sentMessages[0].content, /<situational-context>/);
|
|
153
|
+
assert.match(pi.sentMessages[0].content, /the hidden ambient checkout state/);
|
|
154
|
+
});
|
|
155
|
+
test('a situational-only entry survives a transient sendMessage failure — retried, not dropped', async () => {
|
|
156
|
+
// Durability regression: the cursor advances past a situational-only entry
|
|
157
|
+
// BEFORE flush() runs, so a swallowed sendMessage failure with no re-queue
|
|
158
|
+
// would drop the hidden wake permanently (never re-read from the inbox,
|
|
159
|
+
// never retried) — unlike the visible digest path, which already re-queues
|
|
160
|
+
// on failure. This pins the same durability contract for the hidden path.
|
|
161
|
+
freshNode('node-situational-retry');
|
|
162
|
+
appendSituationalContext('node-situational-retry', 'retry me please');
|
|
163
|
+
const pi = makeFakePi();
|
|
164
|
+
let calls = 0;
|
|
165
|
+
const realSendMessage = pi.sendMessage.bind(pi);
|
|
166
|
+
pi.sendMessage = (message, options) => {
|
|
167
|
+
calls += 1;
|
|
168
|
+
if (calls === 1)
|
|
169
|
+
throw new Error('transient failure');
|
|
170
|
+
realSendMessage(message, options);
|
|
171
|
+
};
|
|
172
|
+
disposers.push(registerCanvasInboxWatcher(pi));
|
|
173
|
+
appendInbox('node-situational-retry', {
|
|
174
|
+
from: null,
|
|
175
|
+
tier: 'normal',
|
|
176
|
+
kind: 'message',
|
|
177
|
+
label: '(ambient context updated)',
|
|
178
|
+
data: { situational: true, situationalOnly: true },
|
|
179
|
+
});
|
|
180
|
+
await wait(SETTLE_MS * 3);
|
|
181
|
+
assert.ok(calls >= 2, 'sendMessage retried after the first failure instead of being swallowed once');
|
|
182
|
+
assert.equal(pi.injected.length, 0, 'still never reaches the visible digest');
|
|
183
|
+
assert.equal(pi.sentMessages.length, 1, 'eventually delivered exactly once after the retry succeeds');
|
|
184
|
+
assert.match(pi.sentMessages[0].content, /retry me please/);
|
|
185
|
+
});
|
|
186
|
+
});
|
|
@@ -18,6 +18,7 @@ import { spawnNode } from '../runtime/nodes.js';
|
|
|
18
18
|
import { promote } from '../runtime/promote.js';
|
|
19
19
|
import { personaDrift } from '../runtime/persona.js';
|
|
20
20
|
import { memoryDir } from '../runtime/memory.js';
|
|
21
|
+
import { appendSituationalContext, situationalContextBlock } from '../runtime/situational-context.js';
|
|
21
22
|
import registerCanvasContextIntro, { buildContextIntro, renderContextMessage, CONTEXT_INTRO_CUSTOM_TYPE, } from '../../pi-extensions/canvas-context-intro.js';
|
|
22
23
|
let home;
|
|
23
24
|
before(() => {
|
|
@@ -69,6 +70,16 @@ test('worker bearings: base framing + <knowledge> block, NO across-cycles framin
|
|
|
69
70
|
assert.doesNotMatch(block, /refresh cycles/, 'no across-cycles framing for a terminal worker');
|
|
70
71
|
assert.match(block, /<\/crtr-bearings>/);
|
|
71
72
|
});
|
|
73
|
+
test('situational context rides the bearings as a sibling <situational-context> block; empty when unset', () => {
|
|
74
|
+
const meta = spawnNode({ kind: 'general', cwd: '/tmp/work', parent: null });
|
|
75
|
+
assert.equal(situationalContextBlock(meta.node_id), '', 'no block until something is set');
|
|
76
|
+
assert.doesNotMatch(buildContextIntro(meta.node_id), /<situational-context>/, 'bearings carry no block when unset');
|
|
77
|
+
appendSituationalContext(meta.node_id, 'user is looking at the billing applet');
|
|
78
|
+
const block = buildContextIntro(meta.node_id);
|
|
79
|
+
assert.match(block, /<situational-context>/, 'block present once situational context is set');
|
|
80
|
+
assert.match(block, /user is looking at the billing applet/, 'block carries the ambient text');
|
|
81
|
+
assert.ok(!/<situational-context>[\s\S]*<\/crtr-bearings>/.test(block), 'the block is a sibling of <crtr-bearings>, not nested inside it');
|
|
82
|
+
});
|
|
72
83
|
test('node-local doc identity honors explicit frontmatter `name`, not the physical filename', () => {
|
|
73
84
|
// node-local docs follow the one substrate identity rule (resolveDocName):
|
|
74
85
|
// an explicit `name:` renders and dedups under that name, not the filename.
|
|
@@ -173,13 +173,18 @@ declare class FakeSession {
|
|
|
173
173
|
private dispatch;
|
|
174
174
|
private runDialog;
|
|
175
175
|
}
|
|
176
|
+
interface FakeModel {
|
|
177
|
+
provider: string;
|
|
178
|
+
id: string;
|
|
179
|
+
}
|
|
176
180
|
interface FakeServices {
|
|
177
181
|
resourceLoader: ResourceLoader;
|
|
178
182
|
modelRegistry: {
|
|
179
|
-
find: (provider: string, id: string) =>
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
+
find: (provider: string, id: string) => FakeModel | undefined;
|
|
184
|
+
getAvailable: () => FakeModel[];
|
|
185
|
+
getAll: () => FakeModel[];
|
|
186
|
+
hasConfiguredAuth: (model: FakeModel) => boolean;
|
|
187
|
+
refresh: () => void;
|
|
183
188
|
};
|
|
184
189
|
}
|
|
185
190
|
export declare function createAgentSessionServices(options: {
|
|
@@ -757,6 +757,7 @@ class FakeSession {
|
|
|
757
757
|
}
|
|
758
758
|
}
|
|
759
759
|
}
|
|
760
|
+
const FAKE_AVAILABLE_MODEL = { provider: 'fake', id: 'fake-model' };
|
|
760
761
|
export async function createAgentSessionServices(options) {
|
|
761
762
|
const loader = new DefaultResourceLoader({
|
|
762
763
|
cwd: options.cwd,
|
|
@@ -767,10 +768,17 @@ export async function createAgentSessionServices(options) {
|
|
|
767
768
|
await loader.reload();
|
|
768
769
|
return {
|
|
769
770
|
resourceLoader: loader,
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
771
|
+
modelRegistry: {
|
|
772
|
+
// A minimal model stub for any spec — the set_model regression test drives
|
|
773
|
+
// the broker's findModelSpec → session.setModel path through it.
|
|
774
|
+
find: (provider, id) => ({ provider, id }),
|
|
775
|
+
// Always-authenticated single fake model — the M-9 no-boot-model branch
|
|
776
|
+
// (broker.ts) picks `available[0]` and must never see an empty list here.
|
|
777
|
+
getAvailable: () => [FAKE_AVAILABLE_MODEL],
|
|
778
|
+
getAll: () => [FAKE_AVAILABLE_MODEL],
|
|
779
|
+
hasConfiguredAuth: () => true,
|
|
780
|
+
refresh: () => { },
|
|
781
|
+
},
|
|
774
782
|
};
|
|
775
783
|
}
|
|
776
784
|
export async function createAgentSessionFromServices(options) {
|
|
@@ -72,14 +72,28 @@ test('CRASH → grace-revive RESUME (one-writer), then clean teardown on close',
|
|
|
72
72
|
// --- Items 4 + 6: kill the broker out from under the daemon (a crash). Drive a
|
|
73
73
|
// FIXED clock so the grace window is exercised deterministically. ---
|
|
74
74
|
process.kill(oldPid, 'SIGKILL');
|
|
75
|
+
// ONE-WRITER: the old engine pid is dead BEFORE any revive can launch. Assert
|
|
76
|
+
// this via the waitFor alone — a raw isPidAlive RE-CHECK immediately after
|
|
77
|
+
// would re-probe the same numeric pid a moment later, and on a heavily
|
|
78
|
+
// forking CI runner (test-concurrency=4 x full-tier broker spawns) the OS can
|
|
79
|
+
// recycle a just-freed low pid to an unrelated fresh process inside that gap,
|
|
80
|
+
// reporting "alive" for a pid that never resurrected (proven by a real
|
|
81
|
+
// Actions run: crouter-development/prove-ci-fixes-in-actions). The waitFor's
|
|
82
|
+
// successful resolution already IS the proof of deadness at this point.
|
|
75
83
|
await h.waitFor(() => !isPidAlive(oldPid), { label: 'crashed broker pid is dead' });
|
|
76
|
-
// ONE-WRITER: the old engine pid is dead BEFORE any revive can launch.
|
|
77
|
-
assert.equal(isPidAlive(oldPid), false, 'crashed pid dead before the daemon revives');
|
|
78
84
|
const NOW = 5_000_000;
|
|
79
85
|
await h.tick(NOW); // pid dead, intent null → handleBrokerLiveness → handleLiveWindow marks pending
|
|
80
86
|
assert.equal(h.bootCount(id), boots, 'inside the grace window → NOT yet revived');
|
|
81
87
|
await h.tick(NOW + REVIVE_GRACE_MS + 1); // grace elapsed → reviveNode(resume:true)
|
|
82
|
-
|
|
88
|
+
// This is the file's SECOND real broker boot (crash-revive), on an
|
|
89
|
+
// oversubscribed CI runner sharing the host with the other full-tier files'
|
|
90
|
+
// own broker spawns. Locally this boot lands in ~16s total for the whole
|
|
91
|
+
// test; real Actions runs proved the harness's normal 30s awaitBoot default
|
|
92
|
+
// insufficient, and on a genuinely noisy runner even 60s (measured: one real
|
|
93
|
+
// run's boot took ~65–75s) — crouter-development/prove-ci-fixes-in-actions.
|
|
94
|
+
// Give it a wide, still-bounded ceiling rather than tuning down the shared
|
|
95
|
+
// harness default for every other (cheaper, single-boot) full-tier test.
|
|
96
|
+
const boot2 = await h.awaitBoot(id, { minCount: boots + 1, timeoutMs: 120_000 });
|
|
83
97
|
assert.equal(boot2.resuming, true, 'grace-revive RESUMES the saved .jsonl');
|
|
84
98
|
const newPid = h.node(id).pi_pid;
|
|
85
99
|
assert.ok(newPid != null && isPidAlive(newPid), 'the revived broker pid is alive');
|
|
@@ -15,6 +15,8 @@ import { reportsDir } from '../canvas/paths.js';
|
|
|
15
15
|
import { closeDb } from '../canvas/db.js';
|
|
16
16
|
import { drainBearings, buildReviveKickoff, writeYieldMessage, yieldMessagePath, } from '../runtime/kickoff.js';
|
|
17
17
|
import { appendInbox, readCursor } from '../feed/inbox.js';
|
|
18
|
+
import { appendSituationalContext } from '../runtime/situational-context.js';
|
|
19
|
+
import { buildContextIntro } from '../../pi-extensions/canvas-context-intro.js';
|
|
18
20
|
let home;
|
|
19
21
|
function node(id) {
|
|
20
22
|
return {
|
|
@@ -103,6 +105,25 @@ test('a fresh revive is pointed at its subscriptions\' on-disk report history (c
|
|
|
103
105
|
assert.ok(/node inspect artifacts <node-id> --type report/.test(msg), 'points at full per-node expansion');
|
|
104
106
|
assert.ok(/node inspect artifacts <node-id> --type inbox/.test(msg), 'points at the cursor-independent full-history replay');
|
|
105
107
|
});
|
|
108
|
+
test('a fresh revive kickoff never carries situational context — it rides the hidden bearings seam instead', () => {
|
|
109
|
+
// Hiddenness regression: situational context must stay OUT of the visible
|
|
110
|
+
// fresh/cycling revive kickoff prompt. It is still delivered on this path, but
|
|
111
|
+
// hidden — canvas-context-intro's session_start handler re-fires on every
|
|
112
|
+
// fresh/cycling revive (a new pi process boot rooting a fresh branch) and
|
|
113
|
+
// re-injects buildContextIntro(), which already carries the sidecar's
|
|
114
|
+
// <situational-context> block as a sibling of <crtr-bearings> (see
|
|
115
|
+
// context-intro.test.ts for that seam's own coverage). buildReviveKickoff must
|
|
116
|
+
// never duplicate it into the visible prompt.
|
|
117
|
+
const id = 'n3';
|
|
118
|
+
const meta = createNode(node(id));
|
|
119
|
+
appendSituationalContext(id, 'user is mid-checkout on the store applet');
|
|
120
|
+
const kickoff = buildReviveKickoff(meta, drainBearings(meta));
|
|
121
|
+
assert.doesNotMatch(kickoff, /<situational-context>/, 'visible kickoff never carries the ambient block');
|
|
122
|
+
assert.doesNotMatch(kickoff, /mid-checkout on the store applet/, 'visible kickoff never carries the ambient text');
|
|
123
|
+
const hidden = buildContextIntro(id);
|
|
124
|
+
assert.match(hidden, /<situational-context>/, 'delivered instead via the hidden bearings/context-intro seam');
|
|
125
|
+
assert.match(hidden, /mid-checkout on the store applet/, 'hidden delivery carries the ambient text');
|
|
126
|
+
});
|
|
106
127
|
test('buildReviveKickoff is pure — building twice eats nothing', () => {
|
|
107
128
|
const id = 'n2';
|
|
108
129
|
const meta = createNode(node(id));
|