@commonlyai/cli 0.1.67 → 0.1.69
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/package.json +1 -1
- package/src/commands/agent.js +40 -25
- package/src/lib/adapters/claude.js +23 -4
- package/src/lib/adapters/codex.js +32 -5
- package/src/lib/claim-outcome.js +120 -0
- package/src/lib/enforcement.js +30 -2
- package/src/lib/upstream-refusal.js +157 -8
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -39,6 +39,7 @@ import {
|
|
|
39
39
|
formatPodFocusFrame,
|
|
40
40
|
readPodFocus,
|
|
41
41
|
} from '../lib/pod-focus.js';
|
|
42
|
+
import { claimReleaseFor, ackResultFor } from '../lib/claim-outcome.js';
|
|
42
43
|
import { detectBwrap } from '../lib/sandbox/bwrap.js';
|
|
43
44
|
import { resolvePublicSandboxMode } from '../lib/sandbox/mode.js';
|
|
44
45
|
import { detectSeatbelt } from '../lib/sandbox/seatbelt.js';
|
|
@@ -1045,6 +1046,7 @@ export const performRun = ({
|
|
|
1045
1046
|
// goes in a sibling field instead of being spelled into the reason.
|
|
1046
1047
|
return {
|
|
1047
1048
|
outcome: 'no_action',
|
|
1049
|
+
refused: { reason: 'cascade-cap' },
|
|
1048
1050
|
reason: 'cascade-cap',
|
|
1049
1051
|
details: {
|
|
1050
1052
|
// The id of what was dropped. `reason` and `streak` say a refusal
|
|
@@ -1139,16 +1141,19 @@ export const performRun = ({
|
|
|
1139
1141
|
} finally {
|
|
1140
1142
|
// A silent, normally completed human wake is an explicit decline, not
|
|
1141
1143
|
// a successful answer. Tell the kernel so it can hand the message to
|
|
1142
|
-
// exactly one remaining original listener.
|
|
1143
|
-
//
|
|
1144
|
-
//
|
|
1145
|
-
//
|
|
1146
|
-
|
|
1147
|
-
|
|
1148
|
-
|
|
1149
|
-
|
|
1150
|
-
|
|
1151
|
-
|
|
1144
|
+
// exactly one remaining original listener. A refusal — the upstream
|
|
1145
|
+
// route down, or the server refusing the post — is NAMED (TASK-099): it
|
|
1146
|
+
// carries its class so the kernel's record can tell "answered" from
|
|
1147
|
+
// "never ran", and on a human wake the kernel hands it on exactly as it
|
|
1148
|
+
// does a decline, because a failure local to THIS seat must not make a
|
|
1149
|
+
// human's message disappear. A thrown spawn keeps the legacy release, so
|
|
1150
|
+
// normal at-least-once redelivery still happens after an infrastructure
|
|
1151
|
+
// failure. One decision point, shared with the batch path — see
|
|
1152
|
+
// lib/claim-outcome.js.
|
|
1153
|
+
const release = claimReleaseFor(event, turnResult);
|
|
1154
|
+
await claimKeeper?.release(release.outcome, {
|
|
1155
|
+
reason: release.reason, status: release.status,
|
|
1156
|
+
});
|
|
1152
1157
|
}
|
|
1153
1158
|
};
|
|
1154
1159
|
|
|
@@ -1397,6 +1402,7 @@ export const performRun = ({
|
|
|
1397
1402
|
if (deliveryRefusal) {
|
|
1398
1403
|
return {
|
|
1399
1404
|
outcome: 'no_action',
|
|
1405
|
+
refused: { reason: 'delivery-refused' },
|
|
1400
1406
|
reason: deliveryRefusal.reason,
|
|
1401
1407
|
details: {
|
|
1402
1408
|
mode: deliveryRefusal.mode,
|
|
@@ -1409,14 +1415,22 @@ export const performRun = ({
|
|
|
1409
1415
|
},
|
|
1410
1416
|
};
|
|
1411
1417
|
}
|
|
1412
|
-
// A refusal is not a decline
|
|
1413
|
-
// chose not to answer" and hands a human
|
|
1414
|
-
// nothing about the agent's choice happened
|
|
1415
|
-
//
|
|
1416
|
-
//
|
|
1418
|
+
// A refusal is not a decline, and it is not a close either. `no_action`
|
|
1419
|
+
// with no reason means "the agent chose not to answer" and hands a human
|
|
1420
|
+
// wake to one remaining listener; nothing about the agent's choice happened
|
|
1421
|
+
// here — the model never ran — so the release says WHICH class of refusal
|
|
1422
|
+
// it was (TASK-099). The kernel routes from there: on a human wake the
|
|
1423
|
+
// message is handed to one remaining listener exactly as a decline is
|
|
1424
|
+
// (this seat's upstream route being down says nothing about theirs), and on
|
|
1425
|
+
// any other wake it is terminal. The free-text detail stays in the seat
|
|
1426
|
+
// log and the event ack; the kernel records the class.
|
|
1417
1427
|
if (!delivered && upstream) {
|
|
1418
1428
|
return {
|
|
1419
1429
|
outcome: 'no_action',
|
|
1430
|
+
refused: {
|
|
1431
|
+
reason: 'upstream-refused',
|
|
1432
|
+
...(Number.isInteger(upstream.status) ? { status: upstream.status } : {}),
|
|
1433
|
+
},
|
|
1420
1434
|
reason: `upstream-refused-${upstream.status}`,
|
|
1421
1435
|
details: {
|
|
1422
1436
|
status: upstream.status,
|
|
@@ -1461,6 +1475,7 @@ export const performRun = ({
|
|
|
1461
1475
|
|
|
1462
1476
|
const batchAdmissionResult = (event, admission) => ({
|
|
1463
1477
|
outcome: 'no_action',
|
|
1478
|
+
refused: { reason: 'cascade-cap' },
|
|
1464
1479
|
reason: 'cascade-cap',
|
|
1465
1480
|
details: {
|
|
1466
1481
|
messageId: event?.payload?.messageId || null,
|
|
@@ -1646,14 +1661,14 @@ export const performRun = ({
|
|
|
1646
1661
|
.filter((entry) => entry.claimKeeper)
|
|
1647
1662
|
.map(async (entry) => {
|
|
1648
1663
|
// Preserve ADR-018 D6.1 per binding message: a silent human
|
|
1649
|
-
// broadcast may be handed to one remaining listener,
|
|
1650
|
-
// other completed batch item
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
:
|
|
1656
|
-
|
|
1664
|
+
// broadcast may be handed to one remaining listener, a refusal
|
|
1665
|
+
// closes with its reason, and every other completed batch item
|
|
1666
|
+
// closes normally. Same decision point as the single-event path.
|
|
1667
|
+
// Same decision point as the single-event path.
|
|
1668
|
+
const release = claimReleaseFor(entry.event, turnResult);
|
|
1669
|
+
await entry.claimKeeper.release(release.outcome, {
|
|
1670
|
+
reason: release.reason, status: release.status,
|
|
1671
|
+
});
|
|
1657
1672
|
}));
|
|
1658
1673
|
}
|
|
1659
1674
|
};
|
|
@@ -1743,7 +1758,7 @@ export const performRun = ({
|
|
|
1743
1758
|
try {
|
|
1744
1759
|
const deliveryId = event.payload?.deliveryId;
|
|
1745
1760
|
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
1746
|
-
result: entry.result,
|
|
1761
|
+
result: ackResultFor(entry.result),
|
|
1747
1762
|
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
1748
1763
|
});
|
|
1749
1764
|
} catch (ackErr) {
|
|
@@ -1843,7 +1858,7 @@ export const performRun = ({
|
|
|
1843
1858
|
try {
|
|
1844
1859
|
const deliveryId = event.payload?.deliveryId;
|
|
1845
1860
|
await client.post(`/api/agents/runtime/events/${event._id}/ack`, {
|
|
1846
|
-
result,
|
|
1861
|
+
result: ackResultFor(result),
|
|
1847
1862
|
...(typeof deliveryId === 'string' && deliveryId ? { deliveryId } : {}),
|
|
1848
1863
|
});
|
|
1849
1864
|
} catch (ackErr) {
|
|
@@ -75,6 +75,7 @@ import {
|
|
|
75
75
|
import { CREDENTIAL_FILE_VAR, CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
76
76
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
77
77
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
78
|
+
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
78
79
|
|
|
79
80
|
// See codex.js for the rationale on bumping the default + env override.
|
|
80
81
|
// Keeping both adapters in lockstep so any wrapper agent runtime has the
|
|
@@ -234,7 +235,7 @@ const buildPublicClaudePolicyArgs = (mcpToolPatterns) => {
|
|
|
234
235
|
];
|
|
235
236
|
};
|
|
236
237
|
|
|
237
|
-
const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
238
|
+
const runClaude = ({ cmd, args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => {
|
|
238
239
|
const proc = spawnImpl(cmd, args, {
|
|
239
240
|
cwd,
|
|
240
241
|
env,
|
|
@@ -265,8 +266,19 @@ const runClaude = ({ cmd, args, cwd, env, timeoutMs, spawnImpl = childSpawn }) =
|
|
|
265
266
|
// at all because of this. It is not only a diagnosability problem: the
|
|
266
267
|
// circuit breaker classifies from the error message, so a blank message
|
|
267
268
|
// downgrades a hard quota failure to RUNTIME and its shortest backoff.
|
|
269
|
+
//
|
|
270
|
+
// TASK-103: the tail goes through the same reader pi's refusal log uses.
|
|
271
|
+
// A non-JSON tail (which this normally is — the usage-limit line above is
|
|
272
|
+
// prose) is kept whole and only exact-match redacted; a JSON body is
|
|
273
|
+
// reduced to one named field. The status, when the tail names one, is
|
|
274
|
+
// attached as `error.status` so a refusal classifies by field rather than
|
|
275
|
+
// by regex.
|
|
268
276
|
const detail = [stderr.trim(), stdout.trim()].filter(Boolean).join(' | ');
|
|
269
|
-
return reject(
|
|
277
|
+
return reject(adapterFailure('claude', detail, {
|
|
278
|
+
credentials,
|
|
279
|
+
exitCode: code,
|
|
280
|
+
limit: 2000,
|
|
281
|
+
}));
|
|
270
282
|
}
|
|
271
283
|
resolve(stdout);
|
|
272
284
|
});
|
|
@@ -688,6 +700,11 @@ export default {
|
|
|
688
700
|
cwd: ctx.cwd,
|
|
689
701
|
env,
|
|
690
702
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
703
|
+
// The values this spawn was handed, for the failure tail's exact-match
|
|
704
|
+
// check. Snapshot before the spawn: claude is subscription-authenticated
|
|
705
|
+
// and its provider key is not an env var here, so the seat credential is
|
|
706
|
+
// the value a tail can echo back.
|
|
707
|
+
credentials: spawnCredentials(spawnCtx),
|
|
691
708
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
692
709
|
});
|
|
693
710
|
return { text: stdout.trim(), newSessionId: sessionId };
|
|
@@ -703,7 +720,7 @@ export default {
|
|
|
703
720
|
// session that misses the durable-state cue.
|
|
704
721
|
const freshPrompt = buildPrompt(prompt, ctx.memoryLongTerm, { freshSession: true });
|
|
705
722
|
const retryBase = ['-p', freshPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
|
|
706
|
-
const
|
|
723
|
+
const retryCtx = {
|
|
707
724
|
...ctx,
|
|
708
725
|
mcpConfigPath: mcpConfig?.file || null,
|
|
709
726
|
mcpConfigDir: mcpConfig?.dir || null,
|
|
@@ -714,13 +731,15 @@ export default {
|
|
|
714
731
|
mcpConfig?.expansionEnv,
|
|
715
732
|
mcpConfig?.credential?.path || null,
|
|
716
733
|
),
|
|
717
|
-
}
|
|
734
|
+
};
|
|
735
|
+
const retry = await prepareArgv(retryBase, retryCtx);
|
|
718
736
|
const stdout = await runClaude({
|
|
719
737
|
cmd: retry.cmd,
|
|
720
738
|
args: retry.args,
|
|
721
739
|
cwd: ctx.cwd,
|
|
722
740
|
env: retry.env,
|
|
723
741
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
742
|
+
credentials: spawnCredentials(retryCtx),
|
|
724
743
|
spawnImpl: ctx._spawnImpl,
|
|
725
744
|
});
|
|
726
745
|
return { text: stdout.trim(), newSessionId: freshId };
|
|
@@ -60,6 +60,7 @@ import { CREDENTIAL_KEY, writeCredentialFile } from '../credential-file.js';
|
|
|
60
60
|
import { deliverSeatCredential, withholdRuntimeCredential } from '../mcp-credential-delivery.js';
|
|
61
61
|
import { buildMemoryPreamble } from '../memory-bridge.js';
|
|
62
62
|
import { isLegacySandboxTrust, normalizeSandboxTrust } from '../environment.js';
|
|
63
|
+
import { adapterFailure, spawnCredentials } from '../upstream-refusal.js';
|
|
63
64
|
|
|
64
65
|
// Default timeout for a single codex spawn (exec mode).
|
|
65
66
|
//
|
|
@@ -412,8 +413,21 @@ const makeEventParser = () => {
|
|
|
412
413
|
};
|
|
413
414
|
};
|
|
414
415
|
|
|
415
|
-
|
|
416
|
-
|
|
416
|
+
/**
|
|
417
|
+
* Provider keys this adapter's route can authenticate with. `env_key` in
|
|
418
|
+
* `~/.codex/config.toml` names one of them (ADR-014 — the HTTPS layer is
|
|
419
|
+
* redirected, the CLI's semantics are not), and the adapter cannot know which
|
|
420
|
+
* one this seat uses, so it snapshots whichever are present. A failure tail that
|
|
421
|
+
* echoes a key must not reach a pod (TASK-103).
|
|
422
|
+
*/
|
|
423
|
+
const CODEX_PROVIDER_KEY_ENVS = [
|
|
424
|
+
'LITELLM_API_KEY',
|
|
425
|
+
'LITELLM_MASTER_KEY',
|
|
426
|
+
'OPENAI_API_KEY',
|
|
427
|
+
'CODEX_API_KEY',
|
|
428
|
+
];
|
|
429
|
+
|
|
430
|
+
const runCodex = ({ args, cwd, env, timeoutMs, credentials = [], spawnImpl = childSpawn }) => new Promise((resolve, reject) => { // stdio: ['ignore', 'pipe', 'pipe'] — without this, child_process.spawn
|
|
417
431
|
// defaults stdin to a fresh pipe. Codex 0.125.0's `exec` then blocks on
|
|
418
432
|
// `Reading additional input from stdin...` because it sees an open pipe
|
|
419
433
|
// and waits for input that never arrives. Interactive runs are fine because
|
|
@@ -442,11 +456,18 @@ const runCodex = ({ args, cwd, env, timeoutMs, spawnImpl = childSpawn }) => new
|
|
|
442
456
|
if (events.turnFailedMessage) {
|
|
443
457
|
// Surface the model-side failure message verbatim — the run loop posts
|
|
444
458
|
// it as the agent's reply so the user sees what went wrong rather than
|
|
445
|
-
// a generic "non-zero exit" error.
|
|
446
|
-
|
|
459
|
+
// a generic "non-zero exit" error. Verbatim is about the WORDING: the
|
|
460
|
+
// tail still goes through the shared reader (TASK-103), so a message that
|
|
461
|
+
// echoed a credential this spawn was handed is redacted before it can be
|
|
462
|
+
// posted into a pod, and a status it names is attached for the circuit
|
|
463
|
+
// breaker rather than left to a regex.
|
|
464
|
+
return reject(adapterFailure('codex turn failed', events.turnFailedMessage, {
|
|
465
|
+
credentials,
|
|
466
|
+
limit: 2000,
|
|
467
|
+
}));
|
|
447
468
|
}
|
|
448
469
|
if (code !== 0) {
|
|
449
|
-
return reject(
|
|
470
|
+
return reject(adapterFailure('codex', stderr.trim(), { credentials, exitCode: code, limit: 500 }));
|
|
450
471
|
}
|
|
451
472
|
resolve({ threadId: events.threadId });
|
|
452
473
|
});
|
|
@@ -554,6 +575,12 @@ export default {
|
|
|
554
575
|
cwd: ctx.cwd,
|
|
555
576
|
env: childEnv,
|
|
556
577
|
timeoutMs: ctx.timeoutMs || DEFAULT_TIMEOUT_MS,
|
|
578
|
+
// The values this spawn was handed, for the failure tail's exact-match
|
|
579
|
+
// check: the seat credential and the provider key the route
|
|
580
|
+
// authenticates with. Snapshot HERE — `withholdRuntimeCredential` above
|
|
581
|
+
// already removed the token from the child copy, and after that there is
|
|
582
|
+
// nothing left to compare a tail against.
|
|
583
|
+
credentials: spawnCredentials(ctx, CODEX_PROVIDER_KEY_ENVS),
|
|
557
584
|
spawnImpl: ctx._spawnImpl, // test seam only — do not use in production
|
|
558
585
|
});
|
|
559
586
|
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* claimReleaseFor — the ONE decision about how a finished turn releases its
|
|
3
|
+
* ADR-018 attention claim.
|
|
4
|
+
*
|
|
5
|
+
* It lives here, extracted from two byte-identical copies in the run loop
|
|
6
|
+
* (single-event and batch release), for the reason a second copy is always a
|
|
7
|
+
* risk: the batch path is the one `message.posted` actually reaches, so a
|
|
8
|
+
* change applied to only one of them would pass review and do nothing.
|
|
9
|
+
*
|
|
10
|
+
* Three outcomes, and the KERNEL decides what each one does with the wake
|
|
11
|
+
* (TASK-099 ruling, corrected at 71194/71195/71210):
|
|
12
|
+
*
|
|
13
|
+
* - `refused` — the lease holder could not deliver. Nothing about the
|
|
14
|
+
* agent's CHOICE happened: the model never ran (an upstream
|
|
15
|
+
* 429/502/401, named by TASK-096) or the server refused the
|
|
16
|
+
* post. On a HUMAN wake the kernel hands the message to one
|
|
17
|
+
* remaining listener exactly as it does for a decline — a
|
|
18
|
+
* per-seat budget failure must not make a human's message
|
|
19
|
+
* disappear — and records the refuser so the chain stays
|
|
20
|
+
* finite. On any other wake it is terminal. The routing is
|
|
21
|
+
* the kernel's: this module only names the outcome.
|
|
22
|
+
* - `declined` — a silent, normally completed human broadcast. The agent
|
|
23
|
+
* chose not to answer, so the kernel hands it to exactly one
|
|
24
|
+
* remaining original listener.
|
|
25
|
+
* - `completed` — everything else that produced a result: a posted reply, or
|
|
26
|
+
* a skip with a reason (`no-prompt`, `claim-held`,
|
|
27
|
+
* `duplicate-delivery`) where nothing was refused.
|
|
28
|
+
*
|
|
29
|
+
* The refusal CLASS is an enum, not free text: `upstream-refused` (with the
|
|
30
|
+
* HTTP status), `cascade-cap`, `delivery-refused`. The kernel stores the class
|
|
31
|
+
* so refusals are countable, and rejects anything else with a 400. A producer
|
|
32
|
+
* that cannot classify itself maps to `delivery-refused` before sending — the
|
|
33
|
+
* mapping is here, in one place, rather than in each producer.
|
|
34
|
+
*
|
|
35
|
+
* A thrown turn returns no turnResult and releases with no outcome — the
|
|
36
|
+
* legacy holder-only DELETE whose at-least-once redelivery is the correct
|
|
37
|
+
* behaviour for a crashed spawn (the lid closed, the process died).
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/** The classes the kernel accepts. Mirrors `REFUSAL_REASONS` in agentsRuntime. */
|
|
41
|
+
export const REFUSAL_REASONS = ['upstream-refused', 'cascade-cap', 'delivery-refused'];
|
|
42
|
+
|
|
43
|
+
/** What an unclassifiable refusal is recorded as, rather than a fourth class. */
|
|
44
|
+
export const DEFAULT_REFUSAL_REASON = 'delivery-refused';
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The range the kernel accepts for a refusal's `status` — its
|
|
48
|
+
* `MIN_UPSTREAM_STATUS` / `MAX_UPSTREAM_STATUS` in `messageClaimService`.
|
|
49
|
+
* A number outside it is not a refusal status the kernel will record: it
|
|
50
|
+
* answers 400, and the 400 fallback in `enforcement.release` re-releases as
|
|
51
|
+
* `completed` — which is terminal. So forwarding an out-of-range number does
|
|
52
|
+
* not merely lose the number, it loses the handoff: an upstream refusal on a
|
|
53
|
+
* human wake would be recorded as a clean completion. The sender's rule has to
|
|
54
|
+
* be the receiver's rule, which is why the bound lives here as well as there.
|
|
55
|
+
*/
|
|
56
|
+
export const MIN_UPSTREAM_STATUS = 400;
|
|
57
|
+
export const MAX_UPSTREAM_STATUS = 599;
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* @param {{type?: string, payload?: {senderIsHuman?: boolean}}} event
|
|
61
|
+
* @param {{refused?: {reason?: string, status?: number}|true, outcome?: string, reason?: string}|undefined} turnResult
|
|
62
|
+
* @returns {{outcome?: 'refused'|'declined'|'completed', reason?: string, status?: number}}
|
|
63
|
+
*/
|
|
64
|
+
export const claimReleaseFor = (event, turnResult) => {
|
|
65
|
+
if (!turnResult) return { outcome: undefined };
|
|
66
|
+
// `refused: true` is still honoured: it is what an older producer shape (and
|
|
67
|
+
// a third-party wrapper following TASK-096's docs) sends, and it means the
|
|
68
|
+
// same thing — `delivery-refused`, the class that claims the least.
|
|
69
|
+
if (turnResult.refused) {
|
|
70
|
+
const declared = typeof turnResult.refused === 'object' ? turnResult.refused : {};
|
|
71
|
+
const reason = REFUSAL_REASONS.includes(declared.reason)
|
|
72
|
+
? declared.reason
|
|
73
|
+
: DEFAULT_REFUSAL_REASON;
|
|
74
|
+
// The status is the instance of the class, and only the upstream class has
|
|
75
|
+
// one: a cap refusal or a refused post never saw an upstream HTTP status,
|
|
76
|
+
// so sending its number would put a field on the record that cannot mean
|
|
77
|
+
// what a reader would assume. It is also bounded by what the kernel
|
|
78
|
+
// accepts — an out-of-range number keeps the class and drops only the
|
|
79
|
+
// number, so the refusal still hands off instead of falling back to
|
|
80
|
+
// `completed`.
|
|
81
|
+
const status = reason === 'upstream-refused'
|
|
82
|
+
&& Number.isInteger(declared.status)
|
|
83
|
+
&& declared.status >= MIN_UPSTREAM_STATUS
|
|
84
|
+
&& declared.status <= MAX_UPSTREAM_STATUS
|
|
85
|
+
? declared.status
|
|
86
|
+
: undefined;
|
|
87
|
+
return {
|
|
88
|
+
outcome: 'refused',
|
|
89
|
+
reason,
|
|
90
|
+
...(status !== undefined ? { status } : {}),
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
if (event?.type === 'message.posted'
|
|
94
|
+
&& event.payload?.senderIsHuman === true
|
|
95
|
+
&& turnResult.outcome === 'no_action'
|
|
96
|
+
&& !turnResult.reason) {
|
|
97
|
+
return { outcome: 'declined' };
|
|
98
|
+
}
|
|
99
|
+
return { outcome: 'completed' };
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* `refused` is a LOCAL release marker: the producer sets it so the claim
|
|
104
|
+
* release can name the outcome (see `claimReleaseFor`), and nothing else reads
|
|
105
|
+
* it. The event ack is a wire payload with its own readers, and the information
|
|
106
|
+
* that belongs there already reaches them as `reason` — so the marker is
|
|
107
|
+
* dropped at the ack boundary rather than widening the ack with an internal
|
|
108
|
+
* flag. Returns the result unchanged when there is no marker, so the common
|
|
109
|
+
* path never sees a rebuilt object.
|
|
110
|
+
*
|
|
111
|
+
* @param {object|undefined} turnResult
|
|
112
|
+
*/
|
|
113
|
+
export const ackResultFor = (turnResult) => {
|
|
114
|
+
if (!turnResult || !turnResult.refused) return turnResult;
|
|
115
|
+
// ts-eslint's `arg`/`vars` ignore pattern is `^_`, and ignoreRestSiblings is
|
|
116
|
+
// off in this repo's config — so the dropped key is renamed rather than
|
|
117
|
+
// left to trip no-unused-vars.
|
|
118
|
+
const { refused: _refused, ...rest } = turnResult;
|
|
119
|
+
return rest;
|
|
120
|
+
};
|
package/src/lib/enforcement.js
CHANGED
|
@@ -434,7 +434,7 @@ export const createClaimKeeper = (client, {
|
|
|
434
434
|
if (timer && typeof timer.unref === 'function') timer.unref();
|
|
435
435
|
},
|
|
436
436
|
|
|
437
|
-
async release(outcome) {
|
|
437
|
+
async release(outcome, { reason, status } = {}) {
|
|
438
438
|
stopRenewal();
|
|
439
439
|
if (!acquired || lost) return;
|
|
440
440
|
try {
|
|
@@ -442,7 +442,35 @@ export const createClaimKeeper = (client, {
|
|
|
442
442
|
// outcome. Besides keeping old clients' DELETE shape intact, callers
|
|
443
443
|
// that assert their transport arguments must not see a synthetic
|
|
444
444
|
// `undefined` body. An explicit outcome is the new D6.1 contract.
|
|
445
|
-
|
|
445
|
+
//
|
|
446
|
+
// `reason` and `status` ride only with a refusal (TASK-099) and only
|
|
447
|
+
// when the producer named them: the kernel stores the class as the
|
|
448
|
+
// record of why a delivery never happened, so an absent reason must
|
|
449
|
+
// stay absent rather than become the string "undefined".
|
|
450
|
+
if (!outcome) {
|
|
451
|
+
await client.del(path);
|
|
452
|
+
return;
|
|
453
|
+
}
|
|
454
|
+
const body = {
|
|
455
|
+
outcome,
|
|
456
|
+
...(reason ? { reason } : {}),
|
|
457
|
+
...(status !== undefined ? { status } : {}),
|
|
458
|
+
};
|
|
459
|
+
try {
|
|
460
|
+
await client.del(path, body);
|
|
461
|
+
} catch (err) {
|
|
462
|
+
// An older kernel validates the outcome against its own
|
|
463
|
+
// decline/completed enum and answers 400 — as it does for a reason
|
|
464
|
+
// it does not recognise. A CLI that ships before (or runs against a
|
|
465
|
+
// server earlier than) the third outcome must still release its
|
|
466
|
+
// lease, or the message stays claimed until it expires: one retry as
|
|
467
|
+
// `completed`, which is exactly what the old release did. Bounded to
|
|
468
|
+
// a 400 — a 500 or a timeout is not turned into a second write, and
|
|
469
|
+
// a second 400 is not retried again.
|
|
470
|
+
if (outcome !== 'refused' || err?.status !== 400) throw err;
|
|
471
|
+
log(`claim on message ${messageId}: kernel rejected the refused outcome (400) — releasing as completed`);
|
|
472
|
+
await client.del(path, { outcome: 'completed' });
|
|
473
|
+
}
|
|
446
474
|
} catch {
|
|
447
475
|
// Best-effort: a miss just means the lease already expired.
|
|
448
476
|
}
|
|
@@ -29,14 +29,15 @@
|
|
|
29
29
|
* relayed in an upstream error is not detectable here, because the one
|
|
30
30
|
* inventory this module has is the set of values it was itself handed.
|
|
31
31
|
*
|
|
32
|
-
* Scope: this reader
|
|
33
|
-
* silent the way pi was: the claude adapter
|
|
34
|
-
* deliberately reports stdout with it, because a
|
|
35
|
-
* conditions there; the codex adapter rejects on
|
|
36
|
-
* exit 0. Their gap
|
|
37
|
-
* unscrubbed by the rules above, and
|
|
38
|
-
*
|
|
39
|
-
*
|
|
32
|
+
* Scope: this reader was wired into the pi adapter only. TASK-103 extended it
|
|
33
|
+
* to codex and claude, which are NOT silent the way pi was: the claude adapter
|
|
34
|
+
* rejects on a non-zero exit and deliberately reports stdout with it, because a
|
|
35
|
+
* `-p` run writes quota conditions there; the codex adapter rejects on
|
|
36
|
+
* `turnFailedMessage` even at exit 0. Their gap was the opposite one — the text
|
|
37
|
+
* they raised was the raw tail, unscrubbed by the rules above, and carried no
|
|
38
|
+
* HTTP status for the run loop to classify on. `scrubAdapterFailure` below is
|
|
39
|
+
* the shared reader they now both go through, so the two rules cannot drift
|
|
40
|
+
* apart between three adapters.
|
|
40
41
|
*/
|
|
41
42
|
|
|
42
43
|
/** The kept detail is truncated, ellipsis included, to this many characters. */
|
|
@@ -179,6 +180,154 @@ export const readUpstreamRefusal = (stdout, { credentials = [] } = {}) => {
|
|
|
179
180
|
return { status, detail: keptRefusalDetail(raw, { credentials }) };
|
|
180
181
|
};
|
|
181
182
|
|
|
183
|
+
/**
|
|
184
|
+
* Is this text STRUCTURED upstream data — a JSON object or an array? The gate for
|
|
185
|
+
* the whole-tail fallback below, deliberately NOT `kept === null`, which is true
|
|
186
|
+
* for five different reasons (Vera 71350).
|
|
187
|
+
*
|
|
188
|
+
* An array counts, and that is a correction (Vera 71360, measured): `[{...}]` is
|
|
189
|
+
* structured data with fields this code does not know, which is exactly what rule
|
|
190
|
+
* 1 exists for, and treating it as prose shipped the whole body under exact-match
|
|
191
|
+
* alone. A JSON *string* is the opposite case and does NOT count — `"Too many
|
|
192
|
+
* requests"` is a whole-shape text with no fields to reduce, so it keeps its
|
|
193
|
+
* wording like any other prose.
|
|
194
|
+
*/
|
|
195
|
+
const isStructuredBody = (raw) => {
|
|
196
|
+
try {
|
|
197
|
+
const parsed = JSON.parse(String(raw ?? ''));
|
|
198
|
+
return parsed !== null && typeof parsed === 'object';
|
|
199
|
+
} catch {
|
|
200
|
+
return false;
|
|
201
|
+
}
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* A 3-digit number is read as a status only when the tail NAMES it as one:
|
|
206
|
+
* `status 429`, `HTTP 429`, `code: "429"` — or when it opens the tail or a ` | `
|
|
207
|
+
* segment followed by a colon, which is the shape claude's `stderr | stdout`
|
|
208
|
+
* join produces for a body that leads with its status.
|
|
209
|
+
*
|
|
210
|
+
* A bare `\b(\d{3})\b` scan is not enough, and the false positives are not
|
|
211
|
+
* hypothetical: `500 tokens`, `"max_tokens": 500` and `retry in 500 seconds`
|
|
212
|
+
* all read as 500, and `classifySpawnFailure` tests the status FIELD before the
|
|
213
|
+
* message — so a count misread as a status stands the fleet down on a refusal
|
|
214
|
+
* that never happened, which is worse than no status at all (Vera 71352).
|
|
215
|
+
*/
|
|
216
|
+
const STATUS_NAMED = /\b(?:status(?:_?code)?|code|http)\b\W{0,4}(\d{3})(?!\d)/gi;
|
|
217
|
+
// `HTTP/1.1 429 Too Many Requests` is the most canonical refusal shape there is,
|
|
218
|
+
// and the `\W{0,4}` gap above handles `HTTP 429` but cannot reach a VERSIONED
|
|
219
|
+
// line: the `1` in `/1.1` is a
|
|
220
|
+
// WORD character, so `HTTP` and `429` are not within four non-word characters of
|
|
221
|
+
// each other. It read as `status: null` — an unclassified runtime failure on the
|
|
222
|
+
// single most recognisable shape, which is the misclassification this row exists
|
|
223
|
+
// to remove (Vera 71359). Its own pattern, not a looser gap: widening the gap
|
|
224
|
+
// would start reading `/v1/models 500` as a status.
|
|
225
|
+
const STATUS_LINE = /\bHTTP\/\d(?:\.\d)?\s+(\d{3})(?!\d)/gi;
|
|
226
|
+
const STATUS_COLON = /(?:^|\|\s*)(\d{3})(?!\d)\s*:/g;
|
|
227
|
+
|
|
228
|
+
/** The first 4xx/5xx one of those shapes names, or null. */
|
|
229
|
+
const scanStatuses = (text, pattern) => {
|
|
230
|
+
for (const match of String(text ?? '').matchAll(pattern)) {
|
|
231
|
+
const value = Number(match[1]);
|
|
232
|
+
if (value >= 400 && value <= 599) return value;
|
|
233
|
+
}
|
|
234
|
+
return null;
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Find the status AND the body boundary, because they are the same question.
|
|
239
|
+
*
|
|
240
|
+
* Anchoring the status to a ` | ` segment start is not enough on its own: the
|
|
241
|
+
* body has to be taken from just past that status too, or a mid-tail read
|
|
242
|
+
* isolates the status and then hands the WHOLE joined tail to the body rules,
|
|
243
|
+
* which no longer look like a JSON body and so fall through to "keep it all".
|
|
244
|
+
* That is the difference between dropping a credential-echoing body and shipping
|
|
245
|
+
* the part of it that exact-match cannot see.
|
|
246
|
+
*
|
|
247
|
+
* `STATUS_COLON` is the only shape that moves the boundary, because it is the
|
|
248
|
+
* same shape as the anchored prefix (`429: {body}`), just found at a segment
|
|
249
|
+
* boundary — claude's `stderr | stdout` join. A status that appears inside a
|
|
250
|
+
* body (`{"error":{"code":"429"}}`) or as prose (`HTTP 503`) names the status
|
|
251
|
+
* (`HTTP/1.1 429`) but marks no boundary, so the whole text stays the body.
|
|
252
|
+
*/
|
|
253
|
+
const readStatusAndBody = (text) => {
|
|
254
|
+
const raw = String(text ?? '');
|
|
255
|
+
const prefixed = statusFromError(raw);
|
|
256
|
+
if (prefixed !== null) return { status: prefixed, body: bodyFromError(raw) };
|
|
257
|
+
// Only the colon shape moves the boundary — see the note above. A status LINE
|
|
258
|
+
// names the status but its reason phrase is prose, so the body stays the whole
|
|
259
|
+
// text and the exact-match rule protects it.
|
|
260
|
+
for (const match of raw.matchAll(STATUS_COLON)) {
|
|
261
|
+
const value = Number(match[1]);
|
|
262
|
+
if (value >= 400 && value <= 599) {
|
|
263
|
+
return { status: value, body: raw.slice(match.index + match[0].length).replace(/^\s*:?\s*/, '') };
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
const named = scanStatuses(raw, STATUS_NAMED);
|
|
267
|
+
return { status: named ?? scanStatuses(raw, STATUS_LINE), body: raw };
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* The shared reader for an adapter's own failure tail (TASK-103).
|
|
272
|
+
*
|
|
273
|
+
* The two rules above are about a body an adapter captured, and they apply here
|
|
274
|
+
* too — but the tail is a different shape from pi's refusal, and the difference
|
|
275
|
+
* decides how each rule lands:
|
|
276
|
+
*
|
|
277
|
+
* - The tail is often NOT structured. Claude's `-p` mode writes its terminal
|
|
278
|
+
* condition as prose (`Claude usage limit reached. Your limit will reset at
|
|
279
|
+
* 11:40pm.`), and that line is the whole diagnostic value of reporting
|
|
280
|
+
* stdout at all — 361 consecutive failures on 2026-08-03 carried no reason
|
|
281
|
+
* because of it. So the keep-list applies only when the tail parses as a
|
|
282
|
+
* body; when it does not, the text is KEPT as a whole shape and the
|
|
283
|
+
* exact-match rule is what protects it (`redactKnownCredentials`, which
|
|
284
|
+
* substitutes rather than drops, precisely because a whole-shape text has no
|
|
285
|
+
* second field to fall back to).
|
|
286
|
+
*
|
|
287
|
+
* - A body that IS structured (a JSON object or array) is reduced to one named
|
|
288
|
+
* field, and a body whose field echoed a value this spawn was handed is
|
|
289
|
+
* DROPPED WHOLE — the same trade rule 2 makes everywhere. The fallback to the
|
|
290
|
+
* whole tail is gated on the SHAPE (is it structured at all), never on
|
|
291
|
+
* "the keep-list returned null": that null has
|
|
292
|
+
* four other causes, and one of them is the credential echo this line exists
|
|
293
|
+
* to drop. Gating on `kept === null` would emit the whole body for that case,
|
|
294
|
+
* which is precisely what rule 2 forbids — and exact-match cannot see a
|
|
295
|
+
* transformed shape (limit (a) in the header), so a key that arrived
|
|
296
|
+
* base64'd or percent-encoded would ship (Vera 71350/71351).
|
|
297
|
+
*
|
|
298
|
+
* Returns `{ status, detail }`: `status` is the 4xx/5xx the tail named, or null.
|
|
299
|
+
* The caller decides where the status goes; `classifySpawnFailure` reads it off
|
|
300
|
+
* `error.status` or out of the message text, so both a leading `429: …` prefix
|
|
301
|
+
* and an attached field classify the same way. `detail` is `''` when nothing of
|
|
302
|
+
* the body may be shown — the status is then the whole diagnostic.
|
|
303
|
+
*/
|
|
304
|
+
export const scrubAdapterFailure = (text, { credentials = [], limit = MAX_REFUSAL_DETAIL } = {}) => {
|
|
305
|
+
const raw = String(text ?? '').trim();
|
|
306
|
+
const { status, body } = readStatusAndBody(raw);
|
|
307
|
+
const kept = keptRefusalDetail(body, { credentials });
|
|
308
|
+
const detail = kept ?? (isStructuredBody(body) ? '' : redactKnownCredentials(body, credentials));
|
|
309
|
+
return { status, detail: detail.slice(0, limit) };
|
|
310
|
+
};
|
|
311
|
+
|
|
312
|
+
/**
|
|
313
|
+
* The Error an adapter raises, built from its own failure tail.
|
|
314
|
+
*
|
|
315
|
+
* `status` is attached as a field, not only written into the text: the run
|
|
316
|
+
* loop's classifier tests `error.status === 429` before it looks at the
|
|
317
|
+
* message, and a refusal that is only readable by regex is one wording away
|
|
318
|
+
* from the misclassification that cost an hour on 2026-08-18 (see
|
|
319
|
+
* spawn-retry.js). The message still names the status so a human log says the
|
|
320
|
+
* same thing as the classifier's decision.
|
|
321
|
+
*/
|
|
322
|
+
export const adapterFailure = (label, text, { credentials = [], exitCode = null, limit } = {}) => {
|
|
323
|
+
const { status, detail } = scrubAdapterFailure(text, { credentials, limit });
|
|
324
|
+
const code = exitCode === null ? '' : ` exited with code ${exitCode}`;
|
|
325
|
+
const named = status === null ? '' : ` (upstream ${status})`;
|
|
326
|
+
const error = new Error(`${label}${code}${named}${detail ? `: ${detail}` : ''}`);
|
|
327
|
+
if (status !== null) error.status = status;
|
|
328
|
+
return error;
|
|
329
|
+
};
|
|
330
|
+
|
|
182
331
|
/**
|
|
183
332
|
* The seat-log clause. The status is always present; the detail only when the
|
|
184
333
|
* keep-list kept one. `grep 'upstream refused'` finds every refusal, which is
|