@north-light/crouter 0.3.272 → 0.3.274
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-pi-packages/pi-crtr-extensions/extensions/provider-rotation.d.ts +11 -0
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js +35 -31
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +35 -27
- package/dist/clients/attach/viewer.js +601 -601
- package/dist/commands/canvas-browse.js +1 -1
- package/dist/commands/cron.js +9 -8
- package/dist/commands/node/lifecycle.js +1 -1
- package/dist/core/__tests__/fault-classifier.test.js +47 -0
- package/dist/core/command-plugins/transport/exec-invoke.js +1 -1
- package/dist/core/fault-classifier.d.ts +0 -15
- package/dist/core/fault-classifier.js +25 -90
- package/dist/core/provider-error-recognition.d.ts +27 -0
- package/dist/core/provider-error-recognition.js +74 -0
- package/dist/core/runtime/broker-extension-render.js +4 -1
- package/package.json +1 -1
- package/runtime.lock.json +2 -2
|
@@ -64,7 +64,7 @@ export const browseLeaf = defineLeaf({
|
|
|
64
64
|
profileId,
|
|
65
65
|
source,
|
|
66
66
|
viewedNodeIds: async () => new Set((await cliClient().listFocuses()).map((focus) => focus.node_id)),
|
|
67
|
-
closeNode: (id) => cliClient().closeNode(id, { finish: true, caller_pid: process.ppid }).then((r) => ({ closed: r.closed })),
|
|
67
|
+
closeNode: (id) => cliClient().closeNode(id, process.ppid > 0 ? { finish: true, caller_pid: process.ppid } : { finish: true }).then((r) => ({ closed: r.closed })),
|
|
68
68
|
});
|
|
69
69
|
},
|
|
70
70
|
});
|
package/dist/commands/cron.js
CHANGED
|
@@ -63,12 +63,11 @@ function parseRunTimeout(raw) {
|
|
|
63
63
|
}
|
|
64
64
|
return seconds;
|
|
65
65
|
}
|
|
66
|
-
/** The
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
const p = envProfileId();
|
|
66
|
+
/** The profile scope for a cron route: an explicit selector overrides the
|
|
67
|
+
* calling agent's profile. Outside a profile there is no scope to narrow to,
|
|
68
|
+
* so the caller sees the whole canvas home. */
|
|
69
|
+
function scopeQuery(profile) {
|
|
70
|
+
const p = typeof profile === 'string' && profile.trim() !== '' ? canonicalProfileId(profile) : envProfileId();
|
|
72
71
|
return p !== undefined && p.trim() !== '' ? { profile: p } : undefined;
|
|
73
72
|
}
|
|
74
73
|
/** Resolve the chosen-else-inherited cwd, and refuse one that is not a
|
|
@@ -270,7 +269,7 @@ const addLeaf = defineLeaf({
|
|
|
270
269
|
render: (r) => {
|
|
271
270
|
const recur = String(r['recur']);
|
|
272
271
|
const cadence = recur === 'none' ? 'one-shot (row deleted after the run)' : recur;
|
|
273
|
-
return `Cron armed — ${String(r['cron_id'])}.\n\n- next fire: ${String(r['fire_at'])}\n- cadence: ${cadence}\n\nIt runs daemon-side; nothing more to do here. Inspect it with \`crtr cron show ${String(r['cron_id'])}\`; cancel
|
|
272
|
+
return `Cron armed — ${String(r['cron_id'])}.\n\n- next fire: ${String(r['fire_at'])}\n- cadence: ${cadence}\n\nIt runs daemon-side; nothing more to do here. Inspect it with \`crtr cron show ${String(r['cron_id'])}\`; cancel with \`crtr cron cancel ${String(r['cron_id'])}\` from its profile, or pass \`--profile <profile>\` from another profile.`;
|
|
274
273
|
},
|
|
275
274
|
});
|
|
276
275
|
// cron list
|
|
@@ -494,9 +493,11 @@ const cancelLeaf = defineLeaf({
|
|
|
494
493
|
summary: 'remove one cron',
|
|
495
494
|
params: [
|
|
496
495
|
{ kind: 'positional', name: 'cron-id', required: true, constraint: 'The cron to remove (from `cron list`, or $CRTR_CRON_ID inside a run).' },
|
|
496
|
+
{ kind: 'flag', name: 'profile', type: 'string', required: false, constraint: 'Profile scope to cancel within. Defaults to the calling node\'s profile; pass the profile id or manifest name for a cron armed under another profile.' },
|
|
497
497
|
],
|
|
498
498
|
output: [],
|
|
499
499
|
outputKind: 'object',
|
|
500
|
+
dynamicState: () => profilesStateBlock(),
|
|
500
501
|
effects: ['Deletes the cron row and its run history; kills any in-flight run. A no-op if already gone.'],
|
|
501
502
|
},
|
|
502
503
|
run: async (input) => {
|
|
@@ -504,7 +505,7 @@ const cancelLeaf = defineLeaf({
|
|
|
504
505
|
const runId = process.env['CRTR_RUN_ID'];
|
|
505
506
|
await cliClient()
|
|
506
507
|
.cancelCron(id, {
|
|
507
|
-
...scopeQuery(),
|
|
508
|
+
...scopeQuery(input['profile']),
|
|
508
509
|
...(runId === undefined || runId === '' ? {} : { run_id: runId }),
|
|
509
510
|
})
|
|
510
511
|
.catch((err) => rethrowAsCliError(err, 'Check the id against `crtr cron list`.'));
|
|
@@ -100,7 +100,7 @@ const nodeClose = defineLeaf({
|
|
|
100
100
|
throw new InputError({ error: 'no_node', message: 'no node found in this pane to close', next: 'Pass --node <id>, or run from inside the agent\'s pane.' });
|
|
101
101
|
}
|
|
102
102
|
const res = await cliClient()
|
|
103
|
-
.closeNode(id, { caller_pid: process.ppid })
|
|
103
|
+
.closeNode(id, process.ppid > 0 ? { caller_pid: process.ppid } : {})
|
|
104
104
|
.catch((err) => rethrowAsCliError(err, 'List nodes with `crtr node inspect list`.'));
|
|
105
105
|
return { node_id: res.root, count: res.closed.length, closed_ids: res.closed, spared: res.spared };
|
|
106
106
|
},
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import { test } from 'node:test';
|
|
10
10
|
import assert from 'node:assert/strict';
|
|
11
11
|
import { classify } from '../fault-classifier.js';
|
|
12
|
+
import { __classifyProviderErrorForTest } from '../../builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.js';
|
|
12
13
|
test('invalid_grant text classifies as fatal auth (issue #94)', () => {
|
|
13
14
|
const c = classify('pi→provider', 'Anthropic token refresh failed: invalid_grant');
|
|
14
15
|
assert.equal(c.kind, 'auth');
|
|
@@ -89,3 +90,49 @@ test('"terminated" matches as a whole word only (no false positive on substrings
|
|
|
89
90
|
// guard the word-boundary choice explicitly.
|
|
90
91
|
assert.notEqual(classify('pi→provider', 'preterminated').kind, 'connection');
|
|
91
92
|
});
|
|
93
|
+
test('shared recognition preserves each existing policy eligibility, except bare quota', () => {
|
|
94
|
+
const assertPair = (raw, faultKind, rotationKind, observed = {}) => {
|
|
95
|
+
assert.equal(classify('pi→provider', raw).kind, faultKind, `fault: ${JSON.stringify(raw)}`);
|
|
96
|
+
const error = typeof raw === 'string' ? { message: raw } : raw;
|
|
97
|
+
assert.equal(__classifyProviderErrorForTest(error, observed).kind, rotationKind, `rotation: ${JSON.stringify(raw)}`);
|
|
98
|
+
};
|
|
99
|
+
for (const [status, faultKind, rotationKind] of [
|
|
100
|
+
[401, 'auth', 'fatal'], [403, 'auth', 'fatal'], [429, 'rate-limit', 'rate_limit'],
|
|
101
|
+
[500, 'overloaded', 'transient'], [502, 'overloaded', 'transient'], [503, 'overloaded', 'transient'], [504, 'overloaded', 'transient'],
|
|
102
|
+
[529, 'overloaded', 'fatal'], [501, 'overloaded', 'fatal'], [400, 'protocol', 'fatal'], [413, 'protocol', 'fatal'],
|
|
103
|
+
])
|
|
104
|
+
assertPair({ status }, faultKind, rotationKind);
|
|
105
|
+
assertPair({ status: 503 }, 'overloaded', 'rate_limit', { retryAfterMs: 1 });
|
|
106
|
+
for (const code of ['ECONNREFUSED', 'ECONNRESET', 'ETIMEDOUT', 'ENOENT', 'ENOTFOUND', 'EAI_AGAIN', 'EPIPE', 'EHOSTUNREACH', 'ENETUNREACH']) {
|
|
107
|
+
assertPair({ code }, 'connection', 'fatal');
|
|
108
|
+
}
|
|
109
|
+
assertPair({ code: 'ELOCKED' }, 'overloaded', 'fatal');
|
|
110
|
+
assertPair({ code: 1008 }, 'protocol', 'fatal');
|
|
111
|
+
assertPair({ code: 1006 }, 'connection', 'fatal');
|
|
112
|
+
assertPair({ code: 1011, reason: 'no running broker' }, 'connection', 'fatal');
|
|
113
|
+
assertPair({ code: 1011, reason: 'unrelated failure' }, 'other', 'fatal');
|
|
114
|
+
for (const text of ['rate limit', '429', 'too many requests', 'usage limit', 'usage_not_included', 'usagelimiterror', 'insufficient_quota', 'quota exceeded']) {
|
|
115
|
+
assertPair(text, 'rate-limit', 'rate_limit');
|
|
116
|
+
}
|
|
117
|
+
assertPair('quota', 'other', 'fatal');
|
|
118
|
+
assertPair('ordinary usage discussion', 'other', 'fatal');
|
|
119
|
+
for (const text of ['connection', 'network', 'fetch failed', 'econnreset', 'econnrefused', 'enotfound', 'etimedout', 'timed out', 'timeout', 'WebSocket closed', 'WebSocket stream closed before response.completed', 'WebSocket error', 'WebSocket interruption', 'socket hang', 'overloaded', 'temporarily unavailable']) {
|
|
120
|
+
const faultKind = text === 'overloaded' || text === 'temporarily unavailable' ? 'overloaded' : 'connection';
|
|
121
|
+
assertPair(text, faultKind, 'transient');
|
|
122
|
+
}
|
|
123
|
+
for (const text of ['socket', 'service unavailable'])
|
|
124
|
+
assertPair(text, 'other', 'transient');
|
|
125
|
+
for (const text of ['network error: rate limit exceeded', 'service unavailable: too many requests'])
|
|
126
|
+
assertPair(text, 'rate-limit', 'transient');
|
|
127
|
+
for (const text of ['413 Payload Too Large', 'invalid_grant', 'context overflow recovery failed']) {
|
|
128
|
+
const faultKind = text.includes('413') ? 'protocol' : text.includes('invalid_grant') ? 'auth' : 'context-overflow';
|
|
129
|
+
assertPair(text, faultKind, 'fatal');
|
|
130
|
+
}
|
|
131
|
+
assertPair('WebSocket closed: 1008 policy violation', 'protocol', 'transient');
|
|
132
|
+
for (const text of ['lock file is already being held', 'Internal server error', 'APIError', '500', 'capacity', 'server busy']) {
|
|
133
|
+
assertPair(text, 'overloaded', 'fatal');
|
|
134
|
+
}
|
|
135
|
+
for (const text of ['EPIPE', 'EAI_AGAIN', 'EHOSTUNREACH', 'ENETUNREACH', 'terminated', 'other side closed', 'premature close']) {
|
|
136
|
+
assertPair(text, 'connection', 'fatal');
|
|
137
|
+
}
|
|
138
|
+
});
|
|
@@ -21,7 +21,7 @@ const NEXT = 'Run `crtr pkg plugin show` or `crtr sys doctor` to inspect the plu
|
|
|
21
21
|
* Returns null if valid; otherwise returns a { field, receivedType } error.
|
|
22
22
|
* Transport-neutral: used by exec and HTTP transport adapters. */
|
|
23
23
|
export function validateDeclaredResult(output, result) {
|
|
24
|
-
return validateFields(output, result,
|
|
24
|
+
return validateFields(output, result, true);
|
|
25
25
|
}
|
|
26
26
|
/** Core leaf declarations may explicitly require a nullable field. */
|
|
27
27
|
export function validateCoreDeclaredResult(output, result) {
|
|
@@ -25,19 +25,4 @@ export interface FaultClassification {
|
|
|
25
25
|
disposition: FaultRetryDisposition;
|
|
26
26
|
}
|
|
27
27
|
export declare function errorClassFromFault(fault: Pick<Fault, 'kind' | 'retry'>): ErrorClass;
|
|
28
|
-
export interface FaultErrnoLike {
|
|
29
|
-
code?: unknown;
|
|
30
|
-
errno?: unknown;
|
|
31
|
-
message?: unknown;
|
|
32
|
-
}
|
|
33
|
-
export interface FaultHttpLike {
|
|
34
|
-
status?: unknown;
|
|
35
|
-
statusText?: unknown;
|
|
36
|
-
ok?: unknown;
|
|
37
|
-
message?: unknown;
|
|
38
|
-
}
|
|
39
|
-
export interface FaultWsCloseLike {
|
|
40
|
-
code?: unknown;
|
|
41
|
-
reason?: unknown;
|
|
42
|
-
}
|
|
43
28
|
export declare function classify(link: FaultLink, raw: unknown): FaultClassification;
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { hasManagedProviderCoolingDiagnostic } from './runtime/managed-provider-cooling.js';
|
|
2
|
+
import { recognizeProviderError } from './provider-error-recognition.js';
|
|
2
3
|
import { isRecord } from '../shared/predicates.js';
|
|
3
4
|
const errorClassByFaultKind = {
|
|
4
5
|
'rate-limit': 'rate_limit',
|
|
@@ -17,85 +18,21 @@ export function errorClassFromFault(fault) {
|
|
|
17
18
|
retry_disposition: fault.retry.disposition,
|
|
18
19
|
};
|
|
19
20
|
}
|
|
20
|
-
function
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
// prose ("500 ... request entity too large") and arms an unwinnable retry.
|
|
35
|
-
if (/\b413\b|payload too large|entity too large|request too large|entity\.too\.large|body exceeds/i.test(m))
|
|
36
|
-
return 'protocol';
|
|
37
|
-
if (/invalid_grant|refresh token|unauthori[sz]ed|invalid.?api.?key|authentication failed|\b401\b|\b403\b/i.test(m))
|
|
38
|
-
return 'auth';
|
|
39
|
-
// pi compacts and retries a raw provider overflow. Its failed-recovery result
|
|
40
|
-
// is the point at which the session cannot become viable without a new launch.
|
|
41
|
-
if (/context overflow recovery failed/i.test(m))
|
|
42
|
-
return 'context-overflow';
|
|
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
|
-
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';
|
|
49
|
-
if (/overloaded|\b5\d\d\b|internal server error|api.?error|capacity|server.{0,3}busy|temporarily unavailable/i.test(m))
|
|
50
|
-
return 'overloaded';
|
|
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))
|
|
52
|
-
return 'connection';
|
|
53
|
-
return 'other';
|
|
54
|
-
}
|
|
55
|
-
function classifyErrno(raw) {
|
|
56
|
-
const code = text(raw.code) || text(raw.errno);
|
|
57
|
-
if (code === '')
|
|
58
|
-
return null;
|
|
59
|
-
if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOENT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/i.test(code)) {
|
|
60
|
-
return 'connection';
|
|
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';
|
|
66
|
-
return null;
|
|
67
|
-
}
|
|
68
|
-
function classifyHttp(raw) {
|
|
69
|
-
const status = typeof raw.status === 'number' ? raw.status : null;
|
|
70
|
-
if (status === null)
|
|
71
|
-
return null;
|
|
72
|
-
if (status === 401 || status === 403)
|
|
73
|
-
return 'auth';
|
|
74
|
-
if (status === 429)
|
|
75
|
-
return 'rate-limit';
|
|
76
|
-
if (status === 503 || status === 529)
|
|
77
|
-
return 'overloaded';
|
|
78
|
-
if (status >= 500 && status <= 599)
|
|
79
|
-
return 'overloaded';
|
|
80
|
-
if (status >= 400 && status <= 499)
|
|
81
|
-
return 'protocol';
|
|
82
|
-
return null;
|
|
83
|
-
}
|
|
84
|
-
function classifyWsClose(raw) {
|
|
85
|
-
const code = typeof raw.code === 'number' ? raw.code : null;
|
|
86
|
-
if (code === null)
|
|
87
|
-
return null;
|
|
88
|
-
if (code === 1008)
|
|
89
|
-
return 'protocol';
|
|
90
|
-
if (code === 1006)
|
|
91
|
-
return 'connection';
|
|
92
|
-
if (code === 1011) {
|
|
93
|
-
const reason = text(raw.reason);
|
|
94
|
-
if (/no running broker/i.test(reason))
|
|
95
|
-
return 'connection';
|
|
96
|
-
return classifyProviderText(reason) === 'connection' ? 'connection' : 'other';
|
|
21
|
+
function faultKindFromProviderSignal(signal) {
|
|
22
|
+
switch (signal.kind) {
|
|
23
|
+
case 'rate-limit': return 'rate-limit';
|
|
24
|
+
case 'auth': return 'auth';
|
|
25
|
+
case 'protocol': return 'protocol';
|
|
26
|
+
case 'context-overflow': return 'context-overflow';
|
|
27
|
+
case 'overloaded':
|
|
28
|
+
case 'overloaded-only':
|
|
29
|
+
case 'transient-http': return 'overloaded';
|
|
30
|
+
case 'connection':
|
|
31
|
+
case 'connection-only': return 'connection';
|
|
32
|
+
case 'socket':
|
|
33
|
+
case 'service-unavailable':
|
|
34
|
+
case 'other': return 'other';
|
|
97
35
|
}
|
|
98
|
-
return null;
|
|
99
36
|
}
|
|
100
37
|
function classifyDisposition(link, kind) {
|
|
101
38
|
if (kind === 'auth' || kind === 'protocol' || kind === 'context-overflow')
|
|
@@ -140,17 +77,15 @@ export function classify(link, raw) {
|
|
|
140
77
|
if (link === 'pi→provider' && hasManagedProviderCoolingDiagnostic(raw)) {
|
|
141
78
|
return { kind: 'rate-limit', disposition: 'auto' };
|
|
142
79
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
}
|
|
154
|
-
const kind = classifyProviderText(rawText(raw));
|
|
80
|
+
const value = isRecord(raw) ? raw : {};
|
|
81
|
+
const signal = recognizeProviderError({
|
|
82
|
+
code: value['code'],
|
|
83
|
+
errno: value['errno'],
|
|
84
|
+
status: value['status'],
|
|
85
|
+
wsCloseCode: value['code'],
|
|
86
|
+
wsCloseReason: value['reason'],
|
|
87
|
+
message: rawText(raw),
|
|
88
|
+
});
|
|
89
|
+
const kind = faultKindFromProviderSignal(signal);
|
|
155
90
|
return { kind, disposition: classifyDisposition(link, kind) };
|
|
156
91
|
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
export type ProviderErrorSignalKind = 'rate-limit' | 'auth' | 'protocol' | 'context-overflow' | 'overloaded' | 'overloaded-only' | 'transient-http' | 'connection' | 'connection-only' | 'socket' | 'service-unavailable' | 'other';
|
|
2
|
+
export type ProviderErrorMatchedOn = 'errno' | 'status' | 'websocket-close' | 'text' | 'none';
|
|
3
|
+
export interface ProviderErrorTextMatch {
|
|
4
|
+
kind: ProviderErrorSignalKind;
|
|
5
|
+
match: string;
|
|
6
|
+
}
|
|
7
|
+
export interface ProviderErrorSignal {
|
|
8
|
+
kind: ProviderErrorSignalKind;
|
|
9
|
+
matchedOn: ProviderErrorMatchedOn;
|
|
10
|
+
status?: number;
|
|
11
|
+
retryAfterMs?: number;
|
|
12
|
+
match?: string;
|
|
13
|
+
textKind?: ProviderErrorSignalKind;
|
|
14
|
+
textMatch?: string;
|
|
15
|
+
textMatches?: readonly ProviderErrorTextMatch[];
|
|
16
|
+
}
|
|
17
|
+
export interface ProviderErrorRecognitionInput {
|
|
18
|
+
status?: unknown;
|
|
19
|
+
retryAfterMs?: number;
|
|
20
|
+
code?: unknown;
|
|
21
|
+
errno?: unknown;
|
|
22
|
+
wsCloseCode?: unknown;
|
|
23
|
+
wsCloseReason?: unknown;
|
|
24
|
+
message?: string;
|
|
25
|
+
}
|
|
26
|
+
/** Recognizes provider failure facts. Consumers apply their own retry policy. */
|
|
27
|
+
export declare function recognizeProviderError(input: ProviderErrorRecognitionInput): ProviderErrorSignal;
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
function text(value) {
|
|
2
|
+
return typeof value === 'string' ? value : '';
|
|
3
|
+
}
|
|
4
|
+
function signal(kind, matchedOn, options = {}) {
|
|
5
|
+
return { kind, matchedOn, ...options };
|
|
6
|
+
}
|
|
7
|
+
function withTextMatch(signal, textSignal) {
|
|
8
|
+
return textSignal.matchedOn === 'text'
|
|
9
|
+
? { ...signal, textKind: textSignal.kind, textMatch: textSignal.match, textMatches: textSignal.textMatches }
|
|
10
|
+
: signal;
|
|
11
|
+
}
|
|
12
|
+
const TEXT_PATTERNS = [
|
|
13
|
+
['rate-limit', /rate.?limit|\b429\b|too many requests|usage[ _]limit|usage_not_included|usagelimiterror|insufficient_quota|quota exceeded/i],
|
|
14
|
+
['protocol', /\b413\b|payload too large|entity too large|request too large|entity\.too\.large|body exceeds/i],
|
|
15
|
+
['auth', /invalid_grant|refresh token|unauthori[sz]ed|invalid.?api.?key|authentication failed|\b401\b|\b403\b/i],
|
|
16
|
+
['context-overflow', /context overflow recovery failed/i],
|
|
17
|
+
['protocol', /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],
|
|
18
|
+
['overloaded-only', /lock file is already being held/i],
|
|
19
|
+
['overloaded', /overloaded|temporarily unavailable/i],
|
|
20
|
+
['service-unavailable', /service.?unavailable/i],
|
|
21
|
+
['overloaded-only', /internal server error|api.?error|\b5\d\d\b|capacity|server.{0,3}busy/i],
|
|
22
|
+
['connection', /connection|econnreset|etimedout|enotfound|econnrefused|network|fetch failed|socket hang|timed? out|timeout|websocket\s+(?:was\s+)?closed|websocket stream closed before response\.completed|websocket\s+error|websocket.*interrupt/i],
|
|
23
|
+
['socket', /socket/i],
|
|
24
|
+
['connection-only', /eai_again|epipe|ehostunreach|enetunreach|\bterminated\b|other side closed|premature close/i],
|
|
25
|
+
];
|
|
26
|
+
function recognizeText(message) {
|
|
27
|
+
// The order preserves the canonical taxonomy: size wins over wrapped 5xx prose,
|
|
28
|
+
// and auth wins over a generic API-error wrapper. Every match remains available
|
|
29
|
+
// because rotation deliberately gives transient text priority over rate-limit text.
|
|
30
|
+
const matches = TEXT_PATTERNS.flatMap(([kind, pattern]) => {
|
|
31
|
+
const match = message.match(pattern);
|
|
32
|
+
return match ? [{ kind, match: match[0] }] : [];
|
|
33
|
+
});
|
|
34
|
+
const first = matches[0];
|
|
35
|
+
return first === undefined ? signal('other', 'none') : { ...signal(first.kind, 'text', { match: first.match }), textMatches: matches };
|
|
36
|
+
}
|
|
37
|
+
/** Recognizes provider failure facts. Consumers apply their own retry policy. */
|
|
38
|
+
export function recognizeProviderError(input) {
|
|
39
|
+
const retryAfterMs = input.retryAfterMs;
|
|
40
|
+
const textSignal = recognizeText(input.message ?? '');
|
|
41
|
+
const errno = text(input.code) || text(input.errno);
|
|
42
|
+
if (/^(ECONNREFUSED|ECONNRESET|ETIMEDOUT|ENOENT|ENOTFOUND|EAI_AGAIN|EPIPE|EHOSTUNREACH|ENETUNREACH)$/i.test(errno)) {
|
|
43
|
+
return withTextMatch(signal('connection', 'errno', { retryAfterMs }), textSignal);
|
|
44
|
+
}
|
|
45
|
+
if (/^ELOCKED$/i.test(errno))
|
|
46
|
+
return withTextMatch(signal('overloaded', 'errno', { retryAfterMs }), textSignal);
|
|
47
|
+
const status = typeof input.status === 'number' ? input.status : undefined;
|
|
48
|
+
if (status !== undefined) {
|
|
49
|
+
if (status === 401 || status === 403)
|
|
50
|
+
return withTextMatch(signal('auth', 'status', { status, retryAfterMs }), textSignal);
|
|
51
|
+
if (status === 429)
|
|
52
|
+
return withTextMatch(signal('rate-limit', 'status', { status, retryAfterMs }), textSignal);
|
|
53
|
+
if (status === 500 || status === 502 || status === 503 || status === 504)
|
|
54
|
+
return withTextMatch(signal('transient-http', 'status', { status, retryAfterMs }), textSignal);
|
|
55
|
+
if (status >= 500 && status <= 599)
|
|
56
|
+
return withTextMatch(signal('overloaded', 'status', { status, retryAfterMs }), textSignal);
|
|
57
|
+
if (status >= 400 && status <= 499)
|
|
58
|
+
return withTextMatch(signal('protocol', 'status', { status, retryAfterMs }), textSignal);
|
|
59
|
+
}
|
|
60
|
+
const wsCloseCode = typeof input.wsCloseCode === 'number' ? input.wsCloseCode : undefined;
|
|
61
|
+
if (wsCloseCode === 1008)
|
|
62
|
+
return withTextMatch(signal('protocol', 'websocket-close', { retryAfterMs }), textSignal);
|
|
63
|
+
if (wsCloseCode === 1006)
|
|
64
|
+
return withTextMatch(signal('connection', 'websocket-close', { retryAfterMs }), textSignal);
|
|
65
|
+
if (wsCloseCode === 1011) {
|
|
66
|
+
const reason = text(input.wsCloseReason);
|
|
67
|
+
const reasonSignal = recognizeText(reason);
|
|
68
|
+
if (/no running broker/i.test(reason) || reasonSignal.kind === 'connection' || reasonSignal.kind === 'connection-only') {
|
|
69
|
+
return withTextMatch(signal('connection', 'websocket-close', { retryAfterMs }), textSignal);
|
|
70
|
+
}
|
|
71
|
+
return withTextMatch(signal('other', 'websocket-close', { retryAfterMs }), textSignal);
|
|
72
|
+
}
|
|
73
|
+
return retryAfterMs === undefined ? textSignal : { ...textSignal, retryAfterMs };
|
|
74
|
+
}
|
|
@@ -88,7 +88,10 @@ export function buildContextBearingsFromState(state, projectContext, situational
|
|
|
88
88
|
}
|
|
89
89
|
export function buildForkBearingsFromState(state, situational = '') {
|
|
90
90
|
const { node, fork_source: source } = state;
|
|
91
|
-
const
|
|
91
|
+
const sourceMandate = source?.status === 'active' || source?.status === 'idle'
|
|
92
|
+
? `Source node ${source.node_id} ("${fullName(source)}") is still live and still holds the mandate from the copied conversation; do not resume that work here.`
|
|
93
|
+
: 'The source node’s earlier graph position, lifecycle, children, schedules, worktree, situational context, and context-directory path do not carry over. Shared conversation, project, and operating context remain reference material.';
|
|
94
|
+
const bearings = ['<crtr-bearings>', identity(state), '', sourceMandate, '', '## Your node now', `Lifecycle: ${node.lifecycle}.`];
|
|
92
95
|
const map = graphMap(state);
|
|
93
96
|
bearings.push(...(map === '' ? ['Canvas: independent root with no parent or inherited graph edges.'] : ['', map]));
|
|
94
97
|
bearings.push(`Context directory: \`${contextDir(node.node_id)}\` (\`$CRTR_CONTEXT_DIR\`). Use this path in place of every source-node context path above.`);
|
package/package.json
CHANGED
package/runtime.lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@north-light/crouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.274",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@north-light/crouter",
|
|
9
|
-
"version": "0.3.
|
|
9
|
+
"version": "0.3.274",
|
|
10
10
|
"hasInstallScript": true,
|
|
11
11
|
"license": "MIT",
|
|
12
12
|
"dependencies": {
|