@north-light/crouter 0.3.234 → 0.3.236
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/api/dto/nodes.d.ts +15 -3
- package/dist/clients/attach/viewer.js +398 -398
- package/dist/commands/__tests__/node-new.test.d.ts +1 -0
- package/dist/commands/__tests__/node-new.test.js +58 -0
- package/dist/commands/attention.js +1 -1
- package/dist/commands/cron.js +5 -5
- package/dist/commands/human.js +1 -1
- package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.d.ts +1 -0
- package/dist/commands/memory/__tests__/memory-list-and-lint-ignore.test.js +96 -0
- package/dist/commands/memory/delete.js +1 -1
- package/dist/commands/memory/edit.js +6 -3
- package/dist/commands/memory/list.d.ts +4 -0
- package/dist/commands/memory/list.js +20 -14
- package/dist/commands/memory/move.js +1 -1
- package/dist/commands/memory/shared.d.ts +1 -0
- package/dist/commands/memory/shared.js +13 -1
- package/dist/commands/memory/write.js +4 -1
- package/dist/commands/node/create.js +3 -0
- package/dist/commands/node/inspect.js +11 -4
- package/dist/commands/node/lifecycle.js +2 -1
- package/dist/commands/node/message.js +8 -4
- package/dist/commands/node/subscription.js +5 -1
- package/dist/commands/node/wait.js +5 -1
- package/dist/commands/pkg/plugin-manage.js +1 -1
- package/dist/commands/pkg.js +1 -0
- package/dist/commands/profile/rename.js +1 -1
- package/dist/commands/sys/config.js +1 -0
- package/dist/commands/sys.js +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.d.ts +1 -0
- package/dist/core/__tests__/canvas-inbox-watcher-naming.test.js +258 -0
- package/dist/core/__tests__/cli-help-contract.test.d.ts +1 -0
- package/dist/core/__tests__/cli-help-contract.test.js +150 -0
- package/dist/core/__tests__/helpers/harness.js +1 -0
- package/dist/core/__tests__/integration/command-plugins.test.js +21 -0
- package/dist/core/__tests__/seam/dormancy-release.test.js +70 -1
- package/dist/core/__tests__/watchdog-abort-arms-retry.test.js +11 -6
- package/dist/core/canvas/canvas.js +11 -1
- package/dist/core/canvas/meta-file.js +18 -27
- package/dist/core/canvas/remote-canvas-source.d.ts +8 -0
- package/dist/core/canvas/remote-canvas-source.js +9 -1
- package/dist/core/canvas/types.d.ts +2 -2
- package/dist/core/command-manifests/registry.d.ts +2 -0
- package/dist/core/command-plugins/compose.js +4 -2
- package/dist/core/command-plugins/discovery.d.ts +1 -0
- package/dist/core/command-plugins/discovery.js +1 -1
- package/dist/core/command.js +2 -2
- package/dist/core/fault-classifier.js +8 -0
- package/dist/core/pagination.d.ts +2 -0
- package/dist/core/pagination.js +2 -1
- package/dist/core/runtime/broker/fault-retry.js +6 -0
- package/dist/core/runtime/broker/rebind.js +50 -0
- package/dist/core/runtime/launch-target.js +1 -1
- package/dist/core/runtime/launch.d.ts +2 -2
- package/dist/core/runtime/launch.js +17 -4
- package/dist/core/runtime/spawn.d.ts +5 -13
- package/dist/core/runtime/spawn.js +4 -10
- package/dist/core/runtime/stop-guard.js +28 -16
- package/dist/core/substrate/frontmatter-validation.d.ts +1 -0
- package/dist/core/substrate/frontmatter-validation.js +1 -1
- package/dist/daemon/api/__tests__/error-seam.test.js +27 -0
- package/dist/daemon/api/handlers/messages.js +12 -12
- package/dist/daemon/api/handlers/nodes.js +13 -4
- package/dist/daemon/api/handlers/worktree.js +6 -22
- package/dist/daemon/api/map.d.ts +4 -2
- package/dist/daemon/api/map.js +22 -2
- package/dist/daemon/reconcilers/broker-supervision.js +1 -1
- package/dist/pi-extensions/canvas-bash-valve.js +4 -3
- package/dist/pi-extensions/canvas-inbox-watcher.js +12 -0
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -14,7 +14,7 @@ after(() => {
|
|
|
14
14
|
rmSync(home, { recursive: true, force: true });
|
|
15
15
|
});
|
|
16
16
|
test('only a watchdog-stamped aborted settlement records an automatic connection fault', () => {
|
|
17
|
-
const settleAbort = (nodeId, stagedWatchdogAbort, stagedRefreshAbort, stopReason = 'aborted') => {
|
|
17
|
+
const settleAbort = (nodeId, stagedWatchdogAbort, stagedRefreshAbort, stopReason = 'aborted', errorText) => {
|
|
18
18
|
const session = {
|
|
19
19
|
sessionManager: { getLeafId: () => 'leaf-entry' },
|
|
20
20
|
model: undefined,
|
|
@@ -27,7 +27,7 @@ test('only a watchdog-stamped aborted settlement records an automatic connection
|
|
|
27
27
|
messages: [{
|
|
28
28
|
role: 'assistant',
|
|
29
29
|
stopReason,
|
|
30
|
-
errorMessage: stopReason === 'error' ? 'This operation was aborted' : 'Request was aborted',
|
|
30
|
+
errorMessage: errorText ?? (stopReason === 'error' ? 'This operation was aborted' : 'Request was aborted'),
|
|
31
31
|
}],
|
|
32
32
|
},
|
|
33
33
|
stagedWatchdogAbort,
|
|
@@ -53,14 +53,19 @@ test('only a watchdog-stamped aborted settlement records an automatic connection
|
|
|
53
53
|
settleAbort('human-node', false, false);
|
|
54
54
|
assert.equal(readFault('human-node'), null, 'an otherwise-identical human abort must not arm recovery');
|
|
55
55
|
// Pi 0.84 currently serializes a host abort from `node yield` as an `error`
|
|
56
|
-
// settlement.
|
|
56
|
+
// settlement.
|
|
57
57
|
const refresh = settleAbort('refresh-node', false, true, 'error');
|
|
58
58
|
assert.equal(readFault('refresh-node'), null, 'a refresh-aborted error settlement must not enter provider fault recovery');
|
|
59
59
|
assert.equal(refresh.generation.stagedRefreshAbort, false, 'settlement consumes the one-shot refresh provenance');
|
|
60
|
-
|
|
60
|
+
// #683 — an abort that lands between requests settles as an error carrying
|
|
61
|
+
// AbortError text. It stopped because something asked it to; a fatal fault
|
|
62
|
+
// here parks the node and fans a false permanent-failure notice.
|
|
63
|
+
settleAbort('aborted-node', false, false, 'error');
|
|
64
|
+
assert.equal(readFault('aborted-node'), null, 'an abort-shaped error settlement must not record a provider fault');
|
|
65
|
+
settleAbort('provider-node', false, false, 'error', 'connection reset by peer');
|
|
61
66
|
const providerFault = readFault('provider-node');
|
|
62
|
-
assert.equal(providerFault?.kind, '
|
|
63
|
-
assert.
|
|
67
|
+
assert.equal(providerFault?.kind, 'connection', 'a real provider error still classifies');
|
|
68
|
+
assert.equal(providerFault?.retry.disposition, 'auto', 'a real provider error still records its fault');
|
|
64
69
|
});
|
|
65
70
|
test('a failed retry settlement carries the episode forward instead of restarting it', () => {
|
|
66
71
|
// The episode marker a daemon retry records before re-prompting: original
|
|
@@ -164,8 +164,18 @@ function runtimeFromRow(nodeId) {
|
|
|
164
164
|
* row (identity + runtime from the incoming meta). Returns the hydrated view. */
|
|
165
165
|
export function createNode(meta) {
|
|
166
166
|
ensureHome();
|
|
167
|
+
const scaffolded = !existsSync(nodeDir(meta.node_id));
|
|
167
168
|
ensureNodeDirs(meta.node_id);
|
|
168
|
-
|
|
169
|
+
try {
|
|
170
|
+
writeMeta(meta);
|
|
171
|
+
}
|
|
172
|
+
catch (error) {
|
|
173
|
+
// No row exists yet, so a dir left here is invisible to every surface and to
|
|
174
|
+
// ghost-row reaping. Only unwind a dir this call created.
|
|
175
|
+
if (scaffolded)
|
|
176
|
+
rmSync(nodeDir(meta.node_id), { recursive: true, force: true });
|
|
177
|
+
throw error;
|
|
178
|
+
}
|
|
169
179
|
seedRow(meta);
|
|
170
180
|
return getNode(meta.node_id);
|
|
171
181
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// Durable node-meta file primitives shared by normal canvas writes and forward migrations.
|
|
2
2
|
import { randomUUID } from 'node:crypto';
|
|
3
|
-
import { closeSync, existsSync, fsyncSync,
|
|
3
|
+
import { closeSync, existsSync, fsyncSync, openSync, readFileSync, renameSync, rmSync, writeSync } from 'node:fs';
|
|
4
4
|
import { dirname } from 'node:path';
|
|
5
|
+
import { withExclusiveDirectoryLock } from '../exclusive-lock.js';
|
|
5
6
|
function parseObject(path, strict) {
|
|
6
7
|
if (!existsSync(path))
|
|
7
8
|
return null;
|
|
@@ -36,40 +37,30 @@ export function readMetaObject(path) {
|
|
|
36
37
|
* re-entrant — the operation must use {@link writeMetaObjectLocked}, never the
|
|
37
38
|
* lock-taking {@link writeJsonFileDurably}. */
|
|
38
39
|
export function withMetaLock(path, operation) {
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
try {
|
|
44
|
-
mkdirSync(lock);
|
|
45
|
-
break;
|
|
46
|
-
}
|
|
47
|
-
catch (error) {
|
|
48
|
-
if (error.code !== 'EEXIST' || Date.now() >= deadline)
|
|
49
|
-
throw error;
|
|
50
|
-
Atomics.wait(wait, 0, 0, 10);
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
try {
|
|
54
|
-
return operation();
|
|
55
|
-
}
|
|
56
|
-
finally {
|
|
57
|
-
rmSync(lock, { recursive: true, force: true });
|
|
58
|
-
}
|
|
40
|
+
return withExclusiveDirectoryLock(`${path}.lock`, operation, {
|
|
41
|
+
timeoutMs: 5_000,
|
|
42
|
+
timeoutError: () => new Error(`timed out waiting to write node metadata: ${path}`),
|
|
43
|
+
});
|
|
59
44
|
}
|
|
60
45
|
/** Collision-free durable write (fsync file + parent dir), assuming the caller
|
|
61
46
|
* already holds {@link withMetaLock} for `path`. */
|
|
62
47
|
export function writeMetaObjectLocked(path, value) {
|
|
63
48
|
const tmp = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
64
|
-
const fd = openSync(tmp, 'w');
|
|
65
49
|
try {
|
|
66
|
-
|
|
67
|
-
|
|
50
|
+
const fd = openSync(tmp, 'w');
|
|
51
|
+
try {
|
|
52
|
+
writeSync(fd, JSON.stringify(value, null, 2));
|
|
53
|
+
fsyncSync(fd);
|
|
54
|
+
}
|
|
55
|
+
finally {
|
|
56
|
+
closeSync(fd);
|
|
57
|
+
}
|
|
58
|
+
renameSync(tmp, path);
|
|
68
59
|
}
|
|
69
|
-
|
|
70
|
-
|
|
60
|
+
catch (error) {
|
|
61
|
+
rmSync(tmp, { force: true });
|
|
62
|
+
throw error;
|
|
71
63
|
}
|
|
72
|
-
renameSync(tmp, path);
|
|
73
64
|
const dirFd = openSync(dirname(path), 'r');
|
|
74
65
|
try {
|
|
75
66
|
fsyncSync(dirFd);
|
|
@@ -31,6 +31,14 @@ export interface RemoteShowResponse {
|
|
|
31
31
|
session_file?: string | null;
|
|
32
32
|
};
|
|
33
33
|
follow_up: string;
|
|
34
|
+
fault?: {
|
|
35
|
+
link: string;
|
|
36
|
+
kind: string;
|
|
37
|
+
retry: {
|
|
38
|
+
disposition: string;
|
|
39
|
+
};
|
|
40
|
+
since: string;
|
|
41
|
+
} | null;
|
|
34
42
|
}
|
|
35
43
|
export declare class RemoteCanvasSource implements CanvasSource {
|
|
36
44
|
private readonly previewEndpoint;
|
|
@@ -47,6 +47,14 @@ function isValidShowResponse(x) {
|
|
|
47
47
|
return false;
|
|
48
48
|
if (typeof x['follow_up'] !== 'string')
|
|
49
49
|
return false;
|
|
50
|
+
const fault = x['fault'];
|
|
51
|
+
if (fault !== undefined && fault !== null) {
|
|
52
|
+
if (!isPlainObject(fault) || typeof fault['link'] !== 'string' || typeof fault['kind'] !== 'string' || typeof fault['since'] !== 'string')
|
|
53
|
+
return false;
|
|
54
|
+
const retry = fault['retry'];
|
|
55
|
+
if (!isPlainObject(retry) || typeof retry['disposition'] !== 'string')
|
|
56
|
+
return false;
|
|
57
|
+
}
|
|
50
58
|
return true;
|
|
51
59
|
}
|
|
52
60
|
/** A `reports`/`managers` entry (`RemoteShowResponse`) — validated per-element
|
|
@@ -121,7 +129,7 @@ export class RemoteCanvasSource {
|
|
|
121
129
|
// node/inspect.ts's remote `show` passthrough render — see only well-formed
|
|
122
130
|
// neighbor entries, never a malformed one they'd dereference unguarded.
|
|
123
131
|
const res = isValidShowResponse(raw)
|
|
124
|
-
? { ...raw, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
|
|
132
|
+
? { ...raw, fault: raw.fault ?? null, reports: raw.reports.filter(isValidNeighborEntry), managers: raw.managers.filter(isValidNeighborEntry) }
|
|
125
133
|
: null;
|
|
126
134
|
this.nodeCache.set(id, { value: res, at: Date.now() });
|
|
127
135
|
return res;
|
|
@@ -11,8 +11,8 @@
|
|
|
11
11
|
* only `dead` is a fault. */
|
|
12
12
|
export type NodeStatus = 'active' | 'idle' | 'done' | 'dead' | 'canceled';
|
|
13
13
|
/** Does stopping finalize the node? terminal = worker (finalizes on push --final);
|
|
14
|
-
* resident = interactable
|
|
15
|
-
*
|
|
14
|
+
* resident = an interactable conversation that parks and remains wakeable
|
|
15
|
+
* without being forced to submit a final. */
|
|
16
16
|
export type Lifecycle = 'terminal' | 'resident';
|
|
17
17
|
/** base = hands-on worker; orchestrator = delegating manager. Bespoke per kind. */
|
|
18
18
|
export type Mode = 'base' | 'orchestrator';
|
|
@@ -24,6 +24,8 @@ export type LeafAdapter = (leaf: DeclLeafBase, commandPath: readonly string[]) =
|
|
|
24
24
|
export interface CommandContribution {
|
|
25
25
|
contributor: CommandContributorRef;
|
|
26
26
|
node: DeclBranch<DeclLeafBase>;
|
|
27
|
+
/** Root name from the manifest, before collision qualification. */
|
|
28
|
+
manifestName: string;
|
|
27
29
|
adaptLeaf: LeafAdapter;
|
|
28
30
|
}
|
|
29
31
|
/** Per-contributor collision issue. */
|
|
@@ -6,15 +6,17 @@ export function adaptPluginContributions(contributions) {
|
|
|
6
6
|
contributions: contributions.map((contribution) => ({
|
|
7
7
|
contributor: { name: contribution.plugin.name, scope: contribution.plugin.scope, sourceMarketplace: contribution.plugin.sourceMarketplace },
|
|
8
8
|
node: contribution.node,
|
|
9
|
+
manifestName: contribution.manifestName,
|
|
9
10
|
adaptLeaf: (leaf, commandPath) => {
|
|
10
11
|
const transport = contribution.transport;
|
|
12
|
+
const manifestCommandPath = [contribution.manifestName, ...commandPath.slice(1)];
|
|
11
13
|
if (transport.kind === 'exec') {
|
|
12
14
|
const executable = transport.executable;
|
|
13
15
|
if (executable === undefined)
|
|
14
16
|
throw new Error(`exec plugin "${contribution.plugin.name}" has an executable leaf without transport.executable`);
|
|
15
17
|
return {
|
|
16
18
|
outputKind: 'object',
|
|
17
|
-
run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath:
|
|
19
|
+
run: async (input) => executeExternalLeaf({ plugin: contribution.plugin, executable, commandPath: manifestCommandPath, output: leaf.output }, input),
|
|
18
20
|
};
|
|
19
21
|
}
|
|
20
22
|
const httpLeaf = leaf;
|
|
@@ -24,7 +26,7 @@ export function adaptPluginContributions(contributions) {
|
|
|
24
26
|
registration: { name: contribution.plugin.name, endpoint: transport.endpoint, ...(transport.authEnv !== undefined ? { authEnv: transport.authEnv } : {}) },
|
|
25
27
|
...(contribution.manifest?.baseUrl !== undefined ? { baseUrl: contribution.manifest.baseUrl } : {}),
|
|
26
28
|
...(contribution.manifest?.timeouts !== undefined ? { timeouts: contribution.manifest.timeouts } : {}),
|
|
27
|
-
params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath:
|
|
29
|
+
params: httpLeaf.params, rest: httpLeaf.rest, output: httpLeaf.output, commandPath: manifestCommandPath,
|
|
28
30
|
}, input, context),
|
|
29
31
|
};
|
|
30
32
|
},
|
|
@@ -130,7 +130,7 @@ export function validatePluginCommands(plugin, reservedNames = new Set(), coreCo
|
|
|
130
130
|
: transport;
|
|
131
131
|
if (resolvedTransport === undefined)
|
|
132
132
|
return { plugin, manifestPath, transport, contributions: [], issues };
|
|
133
|
-
return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest })), issues };
|
|
133
|
+
return { plugin, manifestPath, transport: resolvedTransport, manifest: validation.manifest, contributions: validation.manifest.roots.map((node) => ({ plugin, transport: resolvedTransport, node, manifest: validation.manifest, manifestName: node.name })), issues };
|
|
134
134
|
}
|
|
135
135
|
export function discoverPluginCommandCandidates(startDir = process.cwd(), profileId) {
|
|
136
136
|
return effectiveCommandPlugins(startDir, profileId).map((plugin) => validatePluginCommands(plugin));
|
package/dist/core/command.js
CHANGED
|
@@ -138,9 +138,9 @@ export function defineRoot(opts) {
|
|
|
138
138
|
/** Validate and return child names for an unknown-path error. */
|
|
139
139
|
function childNames(node) {
|
|
140
140
|
if (node.kind === 'root')
|
|
141
|
-
return node.subtrees.map((s) => s.name);
|
|
141
|
+
return node.subtrees.filter((s) => s.tier !== 'hidden').map((s) => s.name);
|
|
142
142
|
if (node.kind === 'branch')
|
|
143
|
-
return node.children.map((c) => c.name);
|
|
143
|
+
return node.children.filter((c) => c.tier !== 'hidden').map((c) => c.name);
|
|
144
144
|
return [];
|
|
145
145
|
}
|
|
146
146
|
/** Walk argv tokens to the deepest matched node.
|
|
@@ -42,6 +42,10 @@ function classifyProviderText(message) {
|
|
|
42
42
|
return 'context-overflow';
|
|
43
43
|
if (/websocket.*(?:closed.*\b(?:1002|1003|1007|1008|1009|1010)\b|protocol error|policy violation|unsupported data|invalid (?:frame|payload)|message too big|mandatory extension)/i.test(m))
|
|
44
44
|
return 'protocol';
|
|
45
|
+
// proper-lockfile contention on a credential file: another process holds the
|
|
46
|
+
// mutex right now, so the same request wins once it releases.
|
|
47
|
+
if (/lock file is already being held/i.test(m))
|
|
48
|
+
return 'overloaded';
|
|
45
49
|
if (/overloaded|\b5\d\d\b|internal server error|api.?error|capacity|server.{0,3}busy|temporarily unavailable/i.test(m))
|
|
46
50
|
return 'overloaded';
|
|
47
51
|
if (/connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout|\bterminated\b|other side closed|premature close|websocket\s+(?:was\s+)?closed|websocket stream closed before response\.completed|websocket\s+error|websocket.*interrupt/i.test(m))
|
|
@@ -55,6 +59,10 @@ function classifyErrno(raw) {
|
|
|
55
59
|
if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOENT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/i.test(code)) {
|
|
56
60
|
return 'connection';
|
|
57
61
|
}
|
|
62
|
+
// A held filesystem mutex, not a broken link: back off on time rather than
|
|
63
|
+
// behind the online probe a connection fault waits on.
|
|
64
|
+
if (/^ELOCKED$/i.test(code))
|
|
65
|
+
return 'overloaded';
|
|
58
66
|
return null;
|
|
59
67
|
}
|
|
60
68
|
function classifyHttp(raw) {
|
|
@@ -16,6 +16,8 @@ export interface PaginateOpts<T> {
|
|
|
16
16
|
/** Returns the stable sort key for an item. Must match the sort order of the
|
|
17
17
|
* input array — ascending by this key. */
|
|
18
18
|
keyOf: (item: T) => string;
|
|
19
|
+
/** Compares sort keys when their order is not raw code-unit order. */
|
|
20
|
+
compareKeys?: (left: string, right: string) => number;
|
|
19
21
|
/** 'count' → compute and return `total`; 'omit' → return `null`. */
|
|
20
22
|
total: 'count' | 'omit';
|
|
21
23
|
}
|
package/dist/core/pagination.js
CHANGED
|
@@ -67,9 +67,10 @@ export function paginate(allItemsSortedAscByKey, params, opts) {
|
|
|
67
67
|
let startIndex = 0;
|
|
68
68
|
if (params.cursor !== undefined) {
|
|
69
69
|
const cursorKey = decodeCursor(params.cursor); // throws CrtrError on bad cursor
|
|
70
|
+
const compareKeys = opts.compareKeys ?? ((left, right) => left < right ? -1 : left > right ? 1 : 0);
|
|
70
71
|
// Find the first item whose key is strictly greater than cursorKey.
|
|
71
72
|
// Linear scan; callers with large datasets should pre-filter upstream.
|
|
72
|
-
startIndex = allItemsSortedAscByKey.findIndex((item) => opts.keyOf(item) >
|
|
73
|
+
startIndex = allItemsSortedAscByKey.findIndex((item) => compareKeys(opts.keyOf(item), cursorKey) > 0);
|
|
73
74
|
if (startIndex === -1) {
|
|
74
75
|
// All items are at or before the cursor key — list is exhausted.
|
|
75
76
|
startIndex = allItemsSortedAscByKey.length;
|
|
@@ -9,6 +9,7 @@ import { emitEvent } from '../../events/emit.js';
|
|
|
9
9
|
import { CONNECTION_FAULT_RECOVERY_BODY, PROVIDER_FAULT_RECOVERY_BODY, formatCard, formatModelFallbackRecovery } from '../../../shared/generated-context.js';
|
|
10
10
|
import { markFatalOnExhaust, nextFaultRetry, policyFor } from '../fault-recovery.js';
|
|
11
11
|
import { parseModelSpec } from '../model-selection.js';
|
|
12
|
+
import { endedByAbort } from '../session-cycles.js';
|
|
12
13
|
import { resolveProviderCandidates } from '../../subscription-state.js';
|
|
13
14
|
const MAX_TIMEOUT_MS = 2_147_483_647;
|
|
14
15
|
/** Owns provider fallback and daemon-driven fault retry state for one broker session. */
|
|
@@ -90,6 +91,11 @@ export class FaultRetry {
|
|
|
90
91
|
if (refreshAbort)
|
|
91
92
|
return;
|
|
92
93
|
const raw = typeof last.errorMessage === 'string' ? last.errorMessage : 'engine error (no errorMessage recorded)';
|
|
94
|
+
// An abort landing between requests reaches pi's catch as a bare AbortError
|
|
95
|
+
// and settles as an error rather than `aborted`. Something asked that turn
|
|
96
|
+
// to stop, so there is no provider fault to record.
|
|
97
|
+
if (endedByAbort({ stopReason: 'error', errorMessage: raw }))
|
|
98
|
+
return;
|
|
93
99
|
const uncompactedOverflow = isContextOverflow(last, settledSession.model?.contextWindow);
|
|
94
100
|
const classified = classify('pi→provider', last);
|
|
95
101
|
const coolingDeadline = extractCoolingDeadline(last);
|
|
@@ -8,6 +8,55 @@ import { abortSessionFromExtension } from './extension-abort.js';
|
|
|
8
8
|
const MAX_ADMITTED_REPLACEMENTS = 2;
|
|
9
9
|
const REPLACEMENT_DEADLINE_MS = 30_000;
|
|
10
10
|
const REPLACEMENT_DEADLINE_EXCEEDED = Symbol('replacement-deadline-exceeded');
|
|
11
|
+
const TRAILING_THINKING_PLACEHOLDER = '(interrupted)';
|
|
12
|
+
const TRAILING_THINKING_GUARD = Symbol.for('crtr.trailing-thinking-guard');
|
|
13
|
+
/** The last block an Anthropic request will actually carry: the serializer
|
|
14
|
+
* drops whitespace-only text and signature-less empty thinking. */
|
|
15
|
+
function lastSerializedBlock(content) {
|
|
16
|
+
for (let i = content.length - 1; i >= 0; i--) {
|
|
17
|
+
const block = content[i];
|
|
18
|
+
if (block === undefined)
|
|
19
|
+
continue;
|
|
20
|
+
if (block.type === 'text') {
|
|
21
|
+
if (block.text.trim().length === 0)
|
|
22
|
+
continue;
|
|
23
|
+
return block;
|
|
24
|
+
}
|
|
25
|
+
if (block.type === 'thinking') {
|
|
26
|
+
const signature = block.thinkingSignature;
|
|
27
|
+
const hasSignature = typeof signature === 'string' && signature.trim().length > 0;
|
|
28
|
+
if (block.redacted !== true && block.thinking.trim().length === 0 && !hasSignature)
|
|
29
|
+
continue;
|
|
30
|
+
return block;
|
|
31
|
+
}
|
|
32
|
+
return block;
|
|
33
|
+
}
|
|
34
|
+
return undefined;
|
|
35
|
+
}
|
|
36
|
+
function guardTrailingThinking(messages) {
|
|
37
|
+
return messages.map((message) => {
|
|
38
|
+
if (message.role !== 'assistant')
|
|
39
|
+
return message;
|
|
40
|
+
if (lastSerializedBlock(message.content)?.type !== 'thinking')
|
|
41
|
+
return message;
|
|
42
|
+
return { ...message, content: [...message.content, { type: 'text', text: TRAILING_THINKING_PLACEHOLDER }] };
|
|
43
|
+
});
|
|
44
|
+
}
|
|
45
|
+
/** Crouter aborts live streams (watchdog, extension abort, viewer esc), which
|
|
46
|
+
* persists assistant turns that end on a thinking block. Anthropic rejects a
|
|
47
|
+
* request whose final assistant block is `thinking`, and pi-ai's serializer
|
|
48
|
+
* has no guard for it, so every later turn re-fails on embedded history. */
|
|
49
|
+
function installTrailingThinkingGuard(agent) {
|
|
50
|
+
const inner = agent.convertToLlm;
|
|
51
|
+
if (inner[TRAILING_THINKING_GUARD] === true)
|
|
52
|
+
return;
|
|
53
|
+
const wrapped = (messages) => {
|
|
54
|
+
const converted = inner(messages);
|
|
55
|
+
return converted instanceof Promise ? converted.then(guardTrailingThinking) : guardTrailingThinking(converted);
|
|
56
|
+
};
|
|
57
|
+
wrapped[TRAILING_THINKING_GUARD] = true;
|
|
58
|
+
agent.convertToLlm = wrapped;
|
|
59
|
+
}
|
|
11
60
|
/** Owns session subscription replacement, its serialization lane, and replacement admission policy. */
|
|
12
61
|
export class RebindDriver {
|
|
13
62
|
deps;
|
|
@@ -86,6 +135,7 @@ export class RebindDriver {
|
|
|
86
135
|
});
|
|
87
136
|
agent.steeringMode = 'all';
|
|
88
137
|
agent.followUpMode = 'all';
|
|
138
|
+
installTrailingThinkingGuard(agent);
|
|
89
139
|
}
|
|
90
140
|
if (this.deps.cfg.editorName !== undefined && this.deps.cfg.editorName !== '') {
|
|
91
141
|
try {
|
|
@@ -32,7 +32,7 @@ export async function resolveLaunchTarget(req, options = {}) {
|
|
|
32
32
|
await assertLaunchKind(kind, cwd, profileId);
|
|
33
33
|
const mode = (req.mode ?? 'base');
|
|
34
34
|
const launch = (await buildLaunchSpecAsync(kind, mode, {
|
|
35
|
-
lifecycle: req.root === true ?
|
|
35
|
+
lifecycle: req.root_lifecycle ?? (req.root === true ? 'resident' : 'terminal'),
|
|
36
36
|
hasManager: req.root !== true,
|
|
37
37
|
...(req.model !== undefined ? { model: req.model } : {}),
|
|
38
38
|
cwd,
|
|
@@ -15,8 +15,8 @@ export type { PortableModelSelection } from './model-selection.js';
|
|
|
15
15
|
* stays unpinned. */
|
|
16
16
|
export declare function isProviderPinnedModelToken(model: string): boolean;
|
|
17
17
|
/** True when `model` is a PORTABLE ladder token — a bare strength/alias
|
|
18
|
-
* (`ultra`, `normal`, `opus`, …)
|
|
19
|
-
* i.e. a form `normalizeModel` resolves through the ladders rather than
|
|
18
|
+
* (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
|
|
19
|
+
* an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
|
|
20
20
|
* passing through as a concrete id. Portable tokens are safe to PERSIST as
|
|
21
21
|
* defaults (`persistDefaultKindModel`): they re-resolve against whatever
|
|
22
22
|
* ladder the running build ships, so they never freeze a stale concrete
|
|
@@ -85,10 +85,14 @@ function findLadderCell(normalized, ladders, providers) {
|
|
|
85
85
|
* strong/medium/light, ALWAYS anthropic (ignores the env/config default).
|
|
86
86
|
* 4. Anything else → pass through unchanged. */
|
|
87
87
|
function modelTokenStrength(model) {
|
|
88
|
-
const
|
|
88
|
+
const slash = model.indexOf('/');
|
|
89
|
+
const provider = slash === -1 ? undefined : model.slice(0, slash);
|
|
90
|
+
const rest = slash === -1 ? model : model.slice(slash + 1);
|
|
89
91
|
const strength = STRENGTH_ALIASES[rest];
|
|
90
92
|
if (strength !== undefined)
|
|
91
93
|
return strength;
|
|
94
|
+
if (provider !== undefined && provider !== 'anthropic')
|
|
95
|
+
return null;
|
|
92
96
|
if (rest === 'opus')
|
|
93
97
|
return 'strong';
|
|
94
98
|
if (rest === 'sonnet')
|
|
@@ -133,8 +137,8 @@ export function isProviderPinnedModelToken(model) {
|
|
|
133
137
|
return model.includes('/') && !isPortableModelToken(model);
|
|
134
138
|
}
|
|
135
139
|
/** True when `model` is a PORTABLE ladder token — a bare strength/alias
|
|
136
|
-
* (`ultra`, `normal`, `opus`, …)
|
|
137
|
-
* i.e. a form `normalizeModel` resolves through the ladders rather than
|
|
140
|
+
* (`ultra`, `normal`, `opus`, …), an `anthropic|openai / strength` pair, or
|
|
141
|
+
* an anthropic family alias — i.e. a form `normalizeModel` resolves through the ladders rather than
|
|
138
142
|
* passing through as a concrete id. Portable tokens are safe to PERSIST as
|
|
139
143
|
* defaults (`persistDefaultKindModel`): they re-resolve against whatever
|
|
140
144
|
* ladder the running build ships, so they never freeze a stale concrete
|
|
@@ -144,7 +148,8 @@ export function isPortableModelToken(model) {
|
|
|
144
148
|
const slash = model.indexOf('/');
|
|
145
149
|
const provider = model.slice(0, slash);
|
|
146
150
|
const rest = model.slice(slash + 1);
|
|
147
|
-
return (provider === 'anthropic' || provider === 'openai') && STRENGTH_ALIASES[rest] !== undefined
|
|
151
|
+
return (provider === 'anthropic' || provider === 'openai') && (STRENGTH_ALIASES[rest] !== undefined
|
|
152
|
+
|| (provider === 'anthropic' && (rest === 'opus' || rest === 'sonnet' || rest === 'haiku')));
|
|
148
153
|
}
|
|
149
154
|
return STRENGTH_ALIASES[model] !== undefined || model === 'opus' || model === 'sonnet' || model === 'haiku';
|
|
150
155
|
}
|
|
@@ -168,6 +173,14 @@ export function normalizeModel(model, ladders = modelLadders()) {
|
|
|
168
173
|
if (strength !== undefined && (provider === 'anthropic' || provider === 'openai')) {
|
|
169
174
|
return ladderFor(provider, ladders)[strength];
|
|
170
175
|
}
|
|
176
|
+
if (provider === 'anthropic') {
|
|
177
|
+
if (rest === 'opus')
|
|
178
|
+
return ladders.anthropic.strong;
|
|
179
|
+
if (rest === 'sonnet')
|
|
180
|
+
return ladders.anthropic.medium;
|
|
181
|
+
if (rest === 'haiku')
|
|
182
|
+
return ladders.anthropic.light;
|
|
183
|
+
}
|
|
171
184
|
// Concrete `provider/id` (optionally `...:thinking`) — pass through.
|
|
172
185
|
return model;
|
|
173
186
|
}
|
|
@@ -15,19 +15,11 @@ export interface SpawnChildOpts {
|
|
|
15
15
|
* subscription back to the spawner, spawned_by=spawner. Resident and brought
|
|
16
16
|
* forefront by default — see `rootLifecycle` for the unattended variant. */
|
|
17
17
|
root?: boolean;
|
|
18
|
-
/**
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
* `'terminal'` is the UNATTENDED root: still parentless (nobody subscribes,
|
|
25
|
-
* so nothing receives its push) but it still owes a final, so it reaps when
|
|
26
|
-
* its work is done — and it is NEVER brought forefront. This is what
|
|
27
|
-
* machinery firing with no human present must birth: the cron executor
|
|
28
|
-
* spawns one when its cron's creating node is gone. A resident root there
|
|
29
|
-
* would accrete one permanently-live node per fire (the count-based prune
|
|
30
|
-
* never deletes live nodes) and steal the human's tmux focus every time. */
|
|
18
|
+
/** Lifecycle for the new node. Roots default to resident and managed
|
|
19
|
+
* children default to terminal. A resident child remains an interactable,
|
|
20
|
+
* wakeable conversation without owing a final; a terminal child remains a
|
|
21
|
+
* bounded worker that must finalize. For roots, terminal keeps the
|
|
22
|
+
* unattended no-viewer behavior. */
|
|
31
23
|
rootLifecycle?: Lifecycle;
|
|
32
24
|
/** Direct root creation waits until the broker accepts viewers before it
|
|
33
25
|
* returns. Cron-launched births use `launch`: the daemon must never wait in
|
|
@@ -209,16 +209,10 @@ export async function spawnChildPrepared(opts, beforeBrokerLaunch) {
|
|
|
209
209
|
const root = opts.root === true;
|
|
210
210
|
const spawner = resolveSpawner(opts.parent, ctx.nodeId ?? null, root);
|
|
211
211
|
const mode = opts.mode ?? 'base';
|
|
212
|
-
//
|
|
213
|
-
//
|
|
214
|
-
//
|
|
215
|
-
|
|
216
|
-
// (delegates + holds a roadmap, but still reports up), NOT resident.
|
|
217
|
-
//
|
|
218
|
-
// `rootLifecycle: 'terminal'` is the one deliberate exception: a parentless
|
|
219
|
-
// node that STILL owes a final (see the option's docblock). It is unattended
|
|
220
|
-
// by construction, so `attendedRoot` below also suppresses viewer placement.
|
|
221
|
-
const lifecycle = root ? (opts.rootLifecycle ?? 'resident') : 'terminal';
|
|
212
|
+
// Roots are resident by default; managed children are terminal workers by
|
|
213
|
+
// default. The one lifecycle option deliberately applies to both shapes so a
|
|
214
|
+
// child can be born as a standing, wakeable conversation too.
|
|
215
|
+
const lifecycle = opts.rootLifecycle ?? (root ? 'resident' : 'terminal');
|
|
222
216
|
const attendedRoot = root && lifecycle === 'resident';
|
|
223
217
|
// Born WITHOUT a name — the canvas-goal-capture extension names it async from
|
|
224
218
|
// its first message (the kickoff task) inside its own pi process, so spawn
|
|
@@ -17,8 +17,10 @@
|
|
|
17
17
|
// • finished/asked → it pushed --final (done) or called `crtr ask` this turn.
|
|
18
18
|
// Also fine.
|
|
19
19
|
// • structured → a TERMINAL node has a pending structured-output request.
|
|
20
|
-
//
|
|
21
|
-
//
|
|
20
|
+
// A valid request reprompts with the schema unless the node
|
|
21
|
+
// also holds a wake source, which it may sleep on first;
|
|
22
|
+
// an invalid request reprompts with an error and requires
|
|
23
|
+
// manual cleanup.
|
|
22
24
|
// • otherwise → a TERMINAL node with nothing live to wait for and no
|
|
23
25
|
// final pushed. Re-prompt it to finish or escalate.
|
|
24
26
|
import { hasActiveLiveSubscription, hasLiveMessageWait, hasPendingCancelOnWakeCron, getNode, contextDir } from '../canvas/index.js';
|
|
@@ -39,6 +41,19 @@ function formatInvalidStructuredOutputReprompt(error) {
|
|
|
39
41
|
`After resolving the file, retry the submission. ` +
|
|
40
42
|
`If this is unexpected, escalate to the user using \`crtr human send\`.`);
|
|
41
43
|
}
|
|
44
|
+
/** The wake sources that legitimize a terminal node's dormancy: a pending
|
|
45
|
+
* cancel-on-wake deadline, an active subscription to a live publisher, an
|
|
46
|
+
* explicit live controller wait, or a live background bash job whose completion
|
|
47
|
+
* sends an inbox message. Parentage alone is deliberately absent. */
|
|
48
|
+
function wakeCapableWait(nodeId, backgroundJobsRunning) {
|
|
49
|
+
if (hasPendingCancelOnWakeCron(nodeId))
|
|
50
|
+
return 'scheduled';
|
|
51
|
+
if (hasActiveLiveSubscription(nodeId)
|
|
52
|
+
|| hasLiveMessageWait(nodeId)
|
|
53
|
+
|| backgroundJobsRunning)
|
|
54
|
+
return 'awaiting';
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
42
57
|
/** Decide what to do when a node stops. crtrd supplies every current-canvas
|
|
43
58
|
* fact; the broker contributes only whether its Pi-owned background job registry
|
|
44
59
|
* is still running work. */
|
|
@@ -49,9 +64,14 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
|
|
|
49
64
|
return { action: 'allow', reason: 'escalated' };
|
|
50
65
|
// A pending structured-output request overrides every stored-state dormancy
|
|
51
66
|
// path: a post-final one-off follow-up still owes `submit` before it may stop.
|
|
52
|
-
//
|
|
67
|
+
// A wake source is the exception — the request outlives the sleep, so
|
|
68
|
+
// reprompting instead would busy-wait the node against its own child, deadline,
|
|
69
|
+
// or job. Invalid requests surface an explicit error and require manual cleanup.
|
|
53
70
|
const outputState = readOutputRequest(nodeId);
|
|
54
71
|
if (outputState.state === 'valid') {
|
|
72
|
+
const pendingWait = wakeCapableWait(nodeId, backgroundJobsRunning);
|
|
73
|
+
if (pendingWait !== null)
|
|
74
|
+
return { action: 'allow', reason: pendingWait };
|
|
55
75
|
return {
|
|
56
76
|
action: 'reprompt',
|
|
57
77
|
reason: 'stalled',
|
|
@@ -82,19 +102,11 @@ export function evaluateStop(nodeId, signals, backgroundJobsRunning = activeBack
|
|
|
82
102
|
if (node !== null && node.lifecycle === 'resident') {
|
|
83
103
|
return { action: 'allow', reason: 'dormant' };
|
|
84
104
|
}
|
|
85
|
-
// A terminal node
|
|
86
|
-
//
|
|
87
|
-
|
|
88
|
-
if (
|
|
89
|
-
return { action: 'allow', reason:
|
|
90
|
-
// A terminal node holding something live to wake it is legitimately awaiting.
|
|
91
|
-
// Parentage alone is deliberately absent: only an active subscription, an
|
|
92
|
-
// explicit controller wait, or a live background bash job whose completion
|
|
93
|
-
// sends an inbox message can suppress the stall guard.
|
|
94
|
-
if (hasActiveLiveSubscription(nodeId)
|
|
95
|
-
|| hasLiveMessageWait(nodeId)
|
|
96
|
-
|| backgroundJobsRunning)
|
|
97
|
-
return { action: 'allow', reason: 'awaiting' };
|
|
105
|
+
// A terminal node holding a wake source is legitimately waiting: it should
|
|
106
|
+
// idle-release rather than busy-wait holding its broker.
|
|
107
|
+
const pendingWait = wakeCapableWait(nodeId, backgroundJobsRunning);
|
|
108
|
+
if (pendingWait !== null)
|
|
109
|
+
return { action: 'allow', reason: pendingWait };
|
|
98
110
|
// A terminal node with nothing live and no final pushed has stalled.
|
|
99
111
|
return {
|
|
100
112
|
action: 'reprompt',
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { type SurfaceEntry } from './schema.js';
|
|
2
|
+
export declare const SUPPRESSIBLE_RULES: readonly string[];
|
|
2
3
|
/** Strict validation of one raw routing entry. Runtime parsing deliberately
|
|
3
4
|
* tolerates malformed docs; this authoring/preflight contract does not. */
|
|
4
5
|
export declare function lintSubstrateSurfaces(value: unknown): string | null;
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { isDocKind, parseSubstrateFrontmatter, SURFACE_EVENTS, SURFACE_RUNGS } from './schema.js';
|
|
2
2
|
const RETIRED_FIELDS = ['system-prompt-visibility', 'file-read-visibility', 'applies-to', 'read-when'];
|
|
3
3
|
const SURFACE_ENTRY_KEYS = ['on', 'match', 'match-frontmatter', 'gate', 'at'];
|
|
4
|
-
const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
|
|
4
|
+
export const SUPPRESSIBLE_RULES = ['length', 'broad-memory-read'];
|
|
5
5
|
/** Strict validation of one raw routing entry. Runtime parsing deliberately
|
|
6
6
|
* tolerates malformed docs; this authoring/preflight contract does not. */
|
|
7
7
|
export function lintSubstrateSurfaces(value) {
|