@commonlyai/cli 0.1.9 → 0.1.11
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 +144 -13
- package/src/lib/adapters/claude.js +13 -2
- package/src/lib/enforcement.js +29 -9
- package/src/lib/environment.js +24 -1
- package/src/lib/spawn-retry.js +31 -5
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -710,6 +710,7 @@ export const performRun = ({
|
|
|
710
710
|
retryJitterRatio,
|
|
711
711
|
claimLeaseSeconds = 90,
|
|
712
712
|
cascadeCap = 3,
|
|
713
|
+
cascadeAddressedGrace = 2,
|
|
713
714
|
cascadeResetMs = 10 * 60 * 1000,
|
|
714
715
|
chatCharLimit = 400,
|
|
715
716
|
maxChatChunks = 3,
|
|
@@ -729,7 +730,11 @@ export const performRun = ({
|
|
|
729
730
|
const spawnJitterRatio = retryJitterRatio ?? spawnRetryJitter(agentName);
|
|
730
731
|
// Per-seat cascade state — lives with the process, like the session store.
|
|
731
732
|
// A wrapper restart forgets the streak; the decay window covers that gap.
|
|
732
|
-
const cascadeGovernor = createCascadeGovernor({
|
|
733
|
+
const cascadeGovernor = createCascadeGovernor({
|
|
734
|
+
cap: cascadeCap,
|
|
735
|
+
addressedGrace: cascadeAddressedGrace,
|
|
736
|
+
resetMs: cascadeResetMs,
|
|
737
|
+
});
|
|
733
738
|
// Fairness: recent broadcast-race winners start the next race from the back.
|
|
734
739
|
const claimHandicap = createClaimHandicap({ delayMs: claimYieldDelayMs });
|
|
735
740
|
|
|
@@ -798,11 +803,47 @@ export const performRun = ({
|
|
|
798
803
|
// capped agent-triggered event is exactly the traffic we want dropped.
|
|
799
804
|
// Human-triggered turns are never capped.
|
|
800
805
|
const trigger = classifyTrigger(event, preSpawn);
|
|
801
|
-
|
|
806
|
+
// A human turn is a property of the POD, not of this seat's reaction to
|
|
807
|
+
// it. Record it as soon as it is classified — before the cascade check and
|
|
808
|
+
// before the claim race — so a seat that stands down still observes the
|
|
809
|
+
// reset.
|
|
810
|
+
//
|
|
811
|
+
// Without this, losing a claim on a human message is SELF-REINFORCING.
|
|
812
|
+
// The claim stand-down returns before `runTurn`, and `record()` lives at
|
|
813
|
+
// the end of `runTurn`, so the loser never records the human turn that
|
|
814
|
+
// would have cleared its streak. It meets the next broadcast still capped,
|
|
815
|
+
// declines, and again skips `record()`. The only other escape is the
|
|
816
|
+
// seat's own resetMs clock: stateFor reads only this process's
|
|
817
|
+
// lastAgentTurnAt, so a capped seat self-clears 10 minutes after its last
|
|
818
|
+
// completed agent turn regardless of room traffic — cyclical throttling,
|
|
819
|
+
// not permanent starvation.
|
|
820
|
+
//
|
|
821
|
+
// Measured in one pod, 2026-08-18, same window: ux-lead 14 lost claims /
|
|
822
|
+
// 73 cap refusals / 2 posts, against sprint-review 0 / 2 / 95. Losing one
|
|
823
|
+
// race is what kept it losing — the mechanism meant to SHARE work
|
|
824
|
+
// concentrated it instead.
|
|
825
|
+
//
|
|
826
|
+
// `=== 'human'` rather than `!== 'agent'`. These are behaviourally the
|
|
827
|
+
// same today: `record()` dispatches on exact equality and leaves every
|
|
828
|
+
// other value untouched, including the 'unknown' that `classifyTrigger`
|
|
829
|
+
// returns for an unresolvable trigger — `enforcement.js:123-131`, and the
|
|
830
|
+
// "'unknown' is neutral: no count, no reset" note at :130. The narrow form
|
|
831
|
+
// is defensive, not load-bearing: it keeps an unidentifiable event from
|
|
832
|
+
// becoming a cap-reset primitive if `record()` ever grows a fallthrough
|
|
833
|
+
// branch. It closes no hole that was open.
|
|
834
|
+
//
|
|
835
|
+
// Agent-triggered turns still record only on completion (see `runTurn`),
|
|
836
|
+
// so the no-double-count property for redelivered events is unchanged.
|
|
837
|
+
// Recording a human trigger sets the streak to 0, so this call and the
|
|
838
|
+
// completion-time one are idempotent with each other.
|
|
839
|
+
if (trigger === 'human') cascadeGovernor.record(eventPodId, trigger);
|
|
840
|
+
const admission = cascadeGovernor.admit(eventPodId, trigger, event.type);
|
|
802
841
|
if (!admission.allowed) {
|
|
803
842
|
log(
|
|
804
843
|
`[${event.type}] cascade cap: ${admission.streak} consecutive agent-triggered `
|
|
805
|
-
+ `turns in pod ${eventPodId}
|
|
844
|
+
+ `turns in pod ${eventPodId}`
|
|
845
|
+
+ (admission.addressed ? ' (addressed grace also spent)' : '')
|
|
846
|
+
+ ' — standing down until a human speaks or the pod goes quiet for the reset window',
|
|
806
847
|
);
|
|
807
848
|
return { outcome: 'no_action', reason: 'cascade-cap' };
|
|
808
849
|
}
|
|
@@ -980,7 +1021,34 @@ export const performRun = ({
|
|
|
980
1021
|
}
|
|
981
1022
|
} else if (silentReply) {
|
|
982
1023
|
const reason = heartbeatControlReply ? replyText : (replyText || 'empty output');
|
|
983
|
-
|
|
1024
|
+
// A turn that posted via tool and THEN ended with the sentinel is not the
|
|
1025
|
+
// same event as a turn that produced nothing — but this branch reported
|
|
1026
|
+
// both as `no wrapper-post (NO_REPLY)`, because it is evaluated before
|
|
1027
|
+
// the `agentPostedItself` branch below and swallowed that fact.
|
|
1028
|
+
//
|
|
1029
|
+
// The skill told agents to do exactly this (post via commonly_post_message,
|
|
1030
|
+
// then end with NO_REPLY so the wrapper does not double-post), so CORRECT
|
|
1031
|
+
// behaviour and total silence were indistinguishable on stdout.
|
|
1032
|
+
//
|
|
1033
|
+
// Cost, 2026-08-18: a seat was diagnosed as mute for "19 hours" on the
|
|
1034
|
+
// strength of `grep -c "posted via tool" == 0` against its log. It had in
|
|
1035
|
+
// fact posted seven times in that window. Five remedies were applied to a
|
|
1036
|
+
// seat that was never broken — cleared session, fresh process, MCP
|
|
1037
|
+
// repointed, model repinned — before the seat itself pointed at the
|
|
1038
|
+
// ledger. The instrument was broken, not the agent.
|
|
1039
|
+
//
|
|
1040
|
+
// Report the two cases distinctly. `posted` is the load-bearing word: a
|
|
1041
|
+
// reader scanning for whether a seat is contributing must not have to
|
|
1042
|
+
// infer it from the absence of a different line.
|
|
1043
|
+
if (agentPostedItself) {
|
|
1044
|
+
log(
|
|
1045
|
+
`[${event.type}] posted via tool, then ${reason} — no echo needed `
|
|
1046
|
+
+ `(matched message ${suppressedBy.id} by ${suppressedBy.author} `
|
|
1047
|
+
+ `via ${suppressedBy.basis})`,
|
|
1048
|
+
);
|
|
1049
|
+
} else {
|
|
1050
|
+
log(`[${event.type}] no wrapper-post (${reason}) — nothing posted this turn`);
|
|
1051
|
+
}
|
|
984
1052
|
} else if (agentPostedItself) {
|
|
985
1053
|
// Name the message that caused the suppression. A silently dropped reply
|
|
986
1054
|
// is invisible to everyone; #757 went unnoticed precisely because this
|
|
@@ -1082,8 +1150,26 @@ export const performRun = ({
|
|
|
1082
1150
|
circuitOpen: retry.circuitOpen,
|
|
1083
1151
|
eventId: event._id,
|
|
1084
1152
|
});
|
|
1085
|
-
|
|
1086
|
-
|
|
1153
|
+
// ONE emission, not two. `wrapped.message` already opens with the
|
|
1154
|
+
// event type, so the log copy added a second prefix and a second
|
|
1155
|
+
// line of identical text into the same merged stream — enough that
|
|
1156
|
+
// `grep -c 'session limit'` returned 8 for 4 failures, and a reader
|
|
1157
|
+
// counting hits per event id saw 2 and inferred two deliveries.
|
|
1158
|
+
// On 2026-08-18 that count pointed at the wrong cause for #993:
|
|
1159
|
+
// two deliveries per event puts `attempts` at 2 and the requeue cap
|
|
1160
|
+
// one step away. It did not decide anything — the cap was ruled out
|
|
1161
|
+
// from the DB, where a capped event would have left a `failed` row
|
|
1162
|
+
// and none existed — but it corroborated the wrong theory, and the
|
|
1163
|
+
// doubling had to be spotted by hand before the count could be
|
|
1164
|
+
// discounted. A log that inflates is worse than one that is silent,
|
|
1165
|
+
// because it argues.
|
|
1166
|
+
//
|
|
1167
|
+
// Routed to the error channel when a caller provides one, and to
|
|
1168
|
+
// the log when it doesn't, so neither contract loses the failure:
|
|
1169
|
+
// an embedder that passes no `onError` still sees it, and `agent
|
|
1170
|
+
// run` — which always passes one — stops printing it twice.
|
|
1171
|
+
if (onError) onError(wrapped);
|
|
1172
|
+
else log(`[${event.type}] ${wrapped.message}`);
|
|
1087
1173
|
break;
|
|
1088
1174
|
}
|
|
1089
1175
|
// Only a completed model turn proves the local runtime and delivery
|
|
@@ -1300,6 +1386,24 @@ export const performDetach = async ({
|
|
|
1300
1386
|
return { backend: backendResult, localCleaned: true };
|
|
1301
1387
|
};
|
|
1302
1388
|
|
|
1389
|
+
/**
|
|
1390
|
+
* Every line a seat emits lands in one file, because the fleet is launched as
|
|
1391
|
+
* `nohup commonly agent run <name> > <log> 2>&1`. Until now none of those lines
|
|
1392
|
+
* carried a time.
|
|
1393
|
+
*
|
|
1394
|
+
* That is not a cosmetic gap. On 2026-08-18 a fleet-wide quota stall destroyed
|
|
1395
|
+
* 38 queued events (#993), and the seat log was the ONLY surviving trace —
|
|
1396
|
+
* the kernel rows were deleted, so nothing else recorded that those turns had
|
|
1397
|
+
* been attempted. The log records `(4 consecutive)` and `next probe in 2.0m`
|
|
1398
|
+
* and gives no way to place either on a clock: the investigation had to date
|
|
1399
|
+
* events by decoding ObjectId prefixes instead, because the file that named
|
|
1400
|
+
* them could not say when.
|
|
1401
|
+
*
|
|
1402
|
+
* ISO-8601 so stamps sort lexically, diff cleanly, and line up with the
|
|
1403
|
+
* kernel's own timestamps without conversion.
|
|
1404
|
+
*/
|
|
1405
|
+
const stamp = () => new Date().toISOString();
|
|
1406
|
+
|
|
1303
1407
|
export const registerAgent = (program) => {
|
|
1304
1408
|
const agent = program.command('agent').description('Manage agents');
|
|
1305
1409
|
|
|
@@ -1649,15 +1753,15 @@ Docs:
|
|
|
1649
1753
|
record = await bootstrapAgentRecordFromEnv({
|
|
1650
1754
|
name,
|
|
1651
1755
|
adapterOverride: opts.adapter || null,
|
|
1652
|
-
log: (line) => console.log(
|
|
1756
|
+
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
1653
1757
|
});
|
|
1654
1758
|
} catch (err) {
|
|
1655
|
-
console.error(err.message);
|
|
1759
|
+
console.error(`${stamp()} [${name}] ${err.message}`);
|
|
1656
1760
|
process.exit(1);
|
|
1657
1761
|
}
|
|
1658
1762
|
if (record) {
|
|
1659
1763
|
saveAgentToken(record.agentName, record);
|
|
1660
|
-
console.log(
|
|
1764
|
+
console.log(`${stamp()} [${name}] bootstrapped ${tokenFile(record.agentName)} from COMMONLY_AGENT_TOKEN (adapter: ${record.adapter})`);
|
|
1661
1765
|
} else {
|
|
1662
1766
|
console.error(
|
|
1663
1767
|
`No token for '${name}'. Either export COMMONLY_API_URL + COMMONLY_AGENT_TOKEN`
|
|
@@ -1674,7 +1778,14 @@ Docs:
|
|
|
1674
1778
|
process.exit(1);
|
|
1675
1779
|
}
|
|
1676
1780
|
|
|
1677
|
-
|
|
1781
|
+
// THE line to stamp, not just one of them. It is the first line of every
|
|
1782
|
+
// seat log and the truncation boundary — the fleet is launched as
|
|
1783
|
+
// `nohup … > log 2>&1`, so this banner is written at boot and everything
|
|
1784
|
+
// before it is gone. Stamped, it dates the restart from the log itself.
|
|
1785
|
+
// Unstamped, it took `ps -o lstart` to establish when nine seats came
|
|
1786
|
+
// back on 2026-08-18, and that only worked because the processes were
|
|
1787
|
+
// still alive — after the next restart that route is gone too.
|
|
1788
|
+
console.log(`${stamp()} [${name}] polling ${record.instanceUrl} for events (ctrl+c to stop)`);
|
|
1678
1789
|
|
|
1679
1790
|
const { stop } = performRun({
|
|
1680
1791
|
instanceUrl: record.instanceUrl,
|
|
@@ -1686,12 +1797,32 @@ Docs:
|
|
|
1686
1797
|
environment: record.environment || null,
|
|
1687
1798
|
workspacePath: record.workspacePath || null,
|
|
1688
1799
|
intervalMs: parseInt(opts.interval, 10),
|
|
1689
|
-
log: (line) => console.log(
|
|
1690
|
-
|
|
1800
|
+
log: (line) => console.log(`${stamp()} [${name}] ${line}`),
|
|
1801
|
+
// Both sinks are stamped, and both must be — but not for the reason
|
|
1802
|
+
// this comment gave until now, which its own PR falsified.
|
|
1803
|
+
//
|
|
1804
|
+
// A spawn failure is routed to `onError` when a caller provides one and
|
|
1805
|
+
// to `log` only as the fallback, so in `agent run` — which always passes
|
|
1806
|
+
// one — EVERY failure line comes out of the error channel and none out
|
|
1807
|
+
// of the log. Stamping only `log:` would leave the entire failure class
|
|
1808
|
+
// undated, which is the class these stamps exist for.
|
|
1809
|
+
//
|
|
1810
|
+
// The two sinks also carry genuinely different text elsewhere: the
|
|
1811
|
+
// no-prompt skip logs a terse line and sends a detailed diagnostic, so
|
|
1812
|
+
// one stamped and one bare would date half of that pair.
|
|
1813
|
+
//
|
|
1814
|
+
// (History, because the reasoning moved twice. This originally argued
|
|
1815
|
+
// from a DOUBLE emission — the retry path called both sinks with
|
|
1816
|
+
// identical text, so `grep -c 'session limit'` returned 8 for 4 failures
|
|
1817
|
+
// and a per-event count read as two deliveries. That was true when the
|
|
1818
|
+
// stamps landed and false eleven minutes later, when the same PR
|
|
1819
|
+
// collapsed the duplication. The conclusion survived the premise; the
|
|
1820
|
+
// sentence did not, and nothing in the diff pointed at it.)
|
|
1821
|
+
onError: (err) => console.error(`${stamp()} [${name}] ${err.message}`),
|
|
1691
1822
|
});
|
|
1692
1823
|
|
|
1693
1824
|
process.on('SIGINT', () => {
|
|
1694
|
-
console.log(`\n[${name}] stopping...`);
|
|
1825
|
+
console.log(`\n${stamp()} [${name}] stopping...`);
|
|
1695
1826
|
stop();
|
|
1696
1827
|
process.exit(0);
|
|
1697
1828
|
});
|
|
@@ -474,7 +474,18 @@ export default {
|
|
|
474
474
|
const sessionId = ctx.sessionId || randomUUID();
|
|
475
475
|
const fullPrompt = buildPrompt(prompt, ctx.memoryLongTerm || '');
|
|
476
476
|
const sessionFlag = isResume ? '--resume' : '--session-id';
|
|
477
|
-
|
|
477
|
+
// Model pin from the ADR-008 environment spec. Absent it, claude picks its
|
|
478
|
+
// own default — which is how a fleet of ten agents ended up running three
|
|
479
|
+
// different Opus versions that nobody chose and nothing recorded, with one
|
|
480
|
+
// seat named after a model it does not run.
|
|
481
|
+
//
|
|
482
|
+
// Computed ONCE and spread into BOTH arg builders. The session-recovery
|
|
483
|
+
// path at the bottom of this function constructs its own array, and a model
|
|
484
|
+
// present in one but not the other means a retry silently runs a different
|
|
485
|
+
// model than the turn it is replacing — the same drifting-copy shape that
|
|
486
|
+
// has bitten this codebase repeatedly.
|
|
487
|
+
const modelArgs = ctx.environment?.model ? ['--model', String(ctx.environment.model)] : [];
|
|
488
|
+
const baseArgs = ['-p', fullPrompt, '--output-format', 'text', sessionFlag, sessionId, ...modelArgs];
|
|
478
489
|
|
|
479
490
|
if (ctx.environment && ctx.cwd) {
|
|
480
491
|
const skills = await mountSkills(ctx.environment, ctx.cwd);
|
|
@@ -529,7 +540,7 @@ export default {
|
|
|
529
540
|
// session id poisons every subsequent event re-delivery.
|
|
530
541
|
if (isResume && /already in use|no conversation|no session/i.test(String(err.message))) {
|
|
531
542
|
const freshId = randomUUID();
|
|
532
|
-
const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId];
|
|
543
|
+
const retryBase = ['-p', fullPrompt, '--output-format', 'text', '--session-id', freshId, ...modelArgs];
|
|
533
544
|
const retry = await prepareArgv(retryBase, {
|
|
534
545
|
...ctx,
|
|
535
546
|
mcpConfigPath: mcpConfig?.file || null,
|
package/src/lib/enforcement.js
CHANGED
|
@@ -55,6 +55,13 @@ export const classifyTrigger = (event, recentMessages) => {
|
|
|
55
55
|
return trigger.isBot ? 'agent' : 'human';
|
|
56
56
|
};
|
|
57
57
|
|
|
58
|
+
// Direct-address event types: the seat was NAMED (explicit @, implicit human
|
|
59
|
+
// reply, or DM routing). A lost claim on these does not silence the seat —
|
|
60
|
+
// being chosen by a human outranks being beaten to a CAS. Broadcast wakes
|
|
61
|
+
// (message.posted) are the opposite: nobody asked for THIS seat, so a lost
|
|
62
|
+
// race is a free stand-down.
|
|
63
|
+
export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
|
|
64
|
+
|
|
58
65
|
// ── cascade governor ────────────────────────────────────────────────────────
|
|
59
66
|
|
|
60
67
|
/**
|
|
@@ -68,12 +75,18 @@ export const classifyTrigger = (event, recentMessages) => {
|
|
|
68
75
|
* pod recovers on its own — a legitimate a2a handoff an hour later must not
|
|
69
76
|
* inherit a stale cap).
|
|
70
77
|
*
|
|
78
|
+
* The only other escape is the seat's own resetMs clock: stateFor reads only
|
|
79
|
+
* this process's lastAgentTurnAt, so a capped seat self-clears 10
|
|
80
|
+
* minutes after its last completed agent turn regardless of room traffic —
|
|
81
|
+
* cyclical throttling, not permanent starvation.
|
|
82
|
+
*
|
|
71
83
|
* Split into admit/record so a spawn that fails (and will be redelivered)
|
|
72
84
|
* never double-counts: admit() only reads, record() runs after a turn
|
|
73
85
|
* actually completed. Human-triggered turns are always admitted.
|
|
74
86
|
*/
|
|
75
87
|
export const createCascadeGovernor = ({
|
|
76
88
|
cap = 3,
|
|
89
|
+
addressedGrace = 2,
|
|
77
90
|
resetMs = 10 * 60 * 1000,
|
|
78
91
|
now = Date.now,
|
|
79
92
|
} = {}) => {
|
|
@@ -88,10 +101,23 @@ export const createCascadeGovernor = ({
|
|
|
88
101
|
};
|
|
89
102
|
|
|
90
103
|
return {
|
|
91
|
-
admit(podId, trigger) {
|
|
92
|
-
if (trigger !== 'agent') return { allowed: true, streak: 0 };
|
|
104
|
+
admit(podId, trigger, eventType) {
|
|
105
|
+
if (trigger !== 'agent') return { allowed: true, streak: 0, addressed: false };
|
|
93
106
|
const s = stateFor(podId);
|
|
94
|
-
|
|
107
|
+
// Being NAMED outranks a mechanical brake — the same judgement the claim
|
|
108
|
+
// path already makes forty lines down in agent.js. Without this, a peer
|
|
109
|
+
// can @mention a capped seat and get silence, with no signal to either
|
|
110
|
+
// side that anything was suppressed. Observed 2026-08-18: one seat took
|
|
111
|
+
// 51 wakes and 28 consecutive cap refusals, five of them chat.mention,
|
|
112
|
+
// and answered none of them.
|
|
113
|
+
//
|
|
114
|
+
// A GRACE, not an exemption: an unbounded pass would restore the exact
|
|
115
|
+
// A-mentions-B-mentions-A echo this governor exists to kill. Addressed
|
|
116
|
+
// turns still count toward the streak, so a mention loop terminates at
|
|
117
|
+
// cap + addressedGrace instead of never.
|
|
118
|
+
const addressed = ADDRESSED_EVENT_TYPES.has(eventType);
|
|
119
|
+
const limit = addressed ? cap + addressedGrace : cap;
|
|
120
|
+
return { allowed: s.streak < limit, streak: s.streak, addressed };
|
|
95
121
|
},
|
|
96
122
|
record(podId, trigger) {
|
|
97
123
|
if (trigger === 'human') {
|
|
@@ -107,12 +133,6 @@ export const createCascadeGovernor = ({
|
|
|
107
133
|
|
|
108
134
|
// ── claim fairness ──────────────────────────────────────────────────────────
|
|
109
135
|
|
|
110
|
-
// Direct-address event types: the seat was NAMED (explicit @, implicit human
|
|
111
|
-
// reply, or DM routing). A lost claim on these does not silence the seat —
|
|
112
|
-
// being chosen by a human outranks being beaten to a CAS. Broadcast wakes
|
|
113
|
-
// (message.posted) are the opposite: nobody asked for THIS seat, so a lost
|
|
114
|
-
// race is a free stand-down.
|
|
115
|
-
export const ADDRESSED_EVENT_TYPES = new Set(['chat.mention', 'thread.mention', 'dm.message']);
|
|
116
136
|
|
|
117
137
|
// Frame prepended when an ADDRESSED seat lost the claim race: it still gets
|
|
118
138
|
// its turn, but knows a peer is (probably) already answering — the bar for
|
package/src/lib/environment.js
CHANGED
|
@@ -29,8 +29,19 @@ import { homedir } from 'os';
|
|
|
29
29
|
// exposes `$HOME` layout for zero server-side benefit. Callers pass
|
|
30
30
|
// `envFileDir` as a separate argument to resolveWorkspace / mountSkills.
|
|
31
31
|
|
|
32
|
+
// `model` closes issue #774. Before it, the only way to pin a wrapper's model
|
|
33
|
+
// was `ANTHROPIC_MODEL` exported at `commonly agent run` time — per-process,
|
|
34
|
+
// never persisted, and silently dropped by any restart. Measured consequence on
|
|
35
|
+
// 2026-08-16: six of nine live seats had lost their assigned model, including
|
|
36
|
+
// ux-lead, which was supposed to be on Fable and had been running the CLI
|
|
37
|
+
// default for days with nothing recording the drift.
|
|
38
|
+
//
|
|
39
|
+
// Putting it in the env spec makes the model a persisted, server-side fact that
|
|
40
|
+
// survives restarts and is readable by the platform — which is what ADR-022's
|
|
41
|
+
// "persona and runtime are chosen separately" requires to mean anything for a
|
|
42
|
+
// BYO seat, and what lets an identity card answer "what is this running".
|
|
32
43
|
const ALLOWED_TOP_KEYS = new Set([
|
|
33
|
-
'version', 'workspace', 'sandbox', 'skills', 'mcp',
|
|
44
|
+
'version', 'workspace', 'sandbox', 'skills', 'mcp', 'model',
|
|
34
45
|
]);
|
|
35
46
|
const ALLOWED_SANDBOX_MODES = new Set([
|
|
36
47
|
'none', 'workspace', 'read-only', 'bwrap', 'firejail', 'container', 'managed',
|
|
@@ -112,6 +123,18 @@ export const validateEnvironmentSpec = (spec) => {
|
|
|
112
123
|
errors.push(`version must be 1, got ${JSON.stringify(spec.version)}`);
|
|
113
124
|
}
|
|
114
125
|
|
|
126
|
+
// Validated as an opaque non-empty string, deliberately not against a list of
|
|
127
|
+
// known model ids. A whitelist here would need editing every time a provider
|
|
128
|
+
// ships a model, and would reject a valid id the local CLI understands and
|
|
129
|
+
// this file does not — failing the user's attach for a fact it is not the
|
|
130
|
+
// authority on. The adapter passes it through to `--model`; the CLI is the
|
|
131
|
+
// thing that knows what is valid, and its error is the honest one.
|
|
132
|
+
if (spec.model !== undefined) {
|
|
133
|
+
if (typeof spec.model !== 'string' || spec.model.trim() === '') {
|
|
134
|
+
errors.push('model must be a non-empty string');
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
115
138
|
if (spec.workspace !== undefined) {
|
|
116
139
|
if (typeof spec.workspace !== 'object' || spec.workspace === null) {
|
|
117
140
|
errors.push('workspace must be an object');
|
package/src/lib/spawn-retry.js
CHANGED
|
@@ -19,11 +19,37 @@ export const SPAWN_CIRCUIT_THRESHOLD = 3;
|
|
|
19
19
|
export const SPAWN_RETRY_MAX_MS = 15 * 60 * 1000;
|
|
20
20
|
export const SPAWN_RETRY_JITTER_MAX_RATIO = 0.2;
|
|
21
21
|
|
|
22
|
-
//
|
|
23
|
-
//
|
|
24
|
-
//
|
|
25
|
-
//
|
|
26
|
-
|
|
22
|
+
// This list is a per-provider allowlist, and it only ever grows after an
|
|
23
|
+
// outage has already been misclassified. Twice now:
|
|
24
|
+
// 2026-08-03 codex "Your workspace is out of credits." → `out of credits`
|
|
25
|
+
// 2026-08-18 claude "You've hit your session limit" → `session limit`
|
|
26
|
+
// The second one is the instructive failure: `usage limit` was already here —
|
|
27
|
+
// it is Claude's OTHER exhaustion wording — so the fleet stalled for an hour on
|
|
28
|
+
// a string one word away from a pattern we had. Both times the miss meant
|
|
29
|
+
// RUNTIME, the weakest class with the shortest backoff, against a provider that
|
|
30
|
+
// was not going to answer for hours.
|
|
31
|
+
//
|
|
32
|
+
// What a miss costs, measured against these constants (intervalMs 5000,
|
|
33
|
+
// jitter 0; `*` = circuit open):
|
|
34
|
+
//
|
|
35
|
+
// quota n=1,2,3 -> 900s* 900s* 900s*
|
|
36
|
+
// configuration n=1,2,3 -> 900s* 900s* 900s*
|
|
37
|
+
// rate_limit n=1,2,3 -> 60s* 120s* 240s*
|
|
38
|
+
// runtime n=1,2,3 -> 5s 10s 60s*
|
|
39
|
+
//
|
|
40
|
+
// So an unmatched wording is not one class off — it is 180x faster on the
|
|
41
|
+
// first retry than the class it belonged in, and the only class that does not
|
|
42
|
+
// open the circuit at n=1. That is the price of a missing string, and it is
|
|
43
|
+
// why the entry below is a list of exact wordings rather than a loose pattern.
|
|
44
|
+
//
|
|
45
|
+
// Note RUNTIME is also `classifySpawnFailure`'s fallthrough, so "unrecognised"
|
|
46
|
+
// and "transient local fault" resolve to the same, most aggressive schedule.
|
|
47
|
+
// That is a structural issue rather than a vocabulary one — see #996.
|
|
48
|
+
//
|
|
49
|
+
// Deliberately NOT loosened to a bare `limit`: QUOTA is tested before
|
|
50
|
+
// RATE_LIMIT, so that would swallow every "rate limit" error into the 15-minute
|
|
51
|
+
// cooldown. Add exact wordings, not looser ones.
|
|
52
|
+
const QUOTA_RE = /(?:quota|usage limit|session limit|credit balance|out of credits|billing|insufficient[_ -]?quota|resource exhausted|spending limit)/i;
|
|
27
53
|
const RATE_LIMIT_RE = /(?:rate[ -]?limit|too many requests|\b429\b|overloaded|capacity)/i;
|
|
28
54
|
const CONFIGURATION_RE = /(?:ENOENT|command not found|not on PATH|login required|not logged in|invalid api key|authentication failed|unauthori[sz]ed|forbidden|\b40[13]\b)/i;
|
|
29
55
|
|