@commonlyai/cli 0.1.185 → 0.1.187
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/node_modules/@commonlyai/seats/src/failures.js +6 -0
- package/node_modules/@commonlyai/seats/src/harness-plan.js +329 -0
- package/node_modules/@commonlyai/seats/src/record/format.js +15 -0
- package/node_modules/@commonlyai/seats/src/trace/events.js +1 -0
- package/package.json +1 -1
- package/src/commands/agent.js +31 -2
- package/src/lib/adapters/claude.js +23 -5
- package/src/lib/sandbox/host-check.js +14 -8
- package/src/lib/workspace-trust.js +6 -3
|
@@ -16,6 +16,12 @@ export const FAILURE_KINDS = Object.freeze({
|
|
|
16
16
|
cancelled: { retryable: false, meaning: 'the run was cancelled' },
|
|
17
17
|
credential_denied: { retryable: false, meaning: 'the run asked for a credential its grant does not allow' },
|
|
18
18
|
unsupported: { retryable: false, meaning: 'the CLI or its version is outside what this adapter supports' },
|
|
19
|
+
// The harness cannot serve anyone right now. Retrying on the same harness is
|
|
20
|
+
// pointless; a run config's fallback moves to its next entry (harness-plan.js).
|
|
21
|
+
not_authenticated: { retryable: false, meaning: 'the CLI is not signed in, or its sign-in was rejected' },
|
|
22
|
+
quota: { retryable: false, meaning: 'the account behind the CLI is out of quota or credits' },
|
|
23
|
+
provider_unsupported: { retryable: false, meaning: 'the provider does not serve this model to this account' },
|
|
24
|
+
capacity: { retryable: true, meaning: 'every entry the run config allows is at a host cap or held; it may run once one frees' },
|
|
19
25
|
});
|
|
20
26
|
|
|
21
27
|
export class RunFailure extends Error {
|
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Which harness a seat runs a turn on, and what it does when that harness
|
|
3
|
+
* cannot serve.
|
|
4
|
+
*
|
|
5
|
+
* A seat declares a run config: a primary entry {harness, model, effort?} and
|
|
6
|
+
* an ordered fallback list of the same shape. `runTurnWithFallback` tries the
|
|
7
|
+
* active entry. When the attempt fails with one of FALLBACK_KINDS (the harness
|
|
8
|
+
* cannot serve anyone right now: not signed in, out of quota, the provider
|
|
9
|
+
* does not take this model), it moves to the next entry in the same turn, so
|
|
10
|
+
* the turn is still served. Every other failure is thrown unchanged and no
|
|
11
|
+
* switch happens. A content refusal in particular never switches: re-sending
|
|
12
|
+
* refused content through another provider is refusal-shopping.
|
|
13
|
+
*
|
|
14
|
+
* While a fallback is active, the next turn after the failed entry's cooldown
|
|
15
|
+
* starts from the primary again. That turn is the probe: if the primary
|
|
16
|
+
* answers, the seat is back on it. If it fails with a fallback kind or a
|
|
17
|
+
* retryable liveness kind (PROBE_CARRY_KINDS), the same turn carries on down
|
|
18
|
+
* the list. Any other failure, an unnamed one included, ends the turn there:
|
|
19
|
+
* an adapter's upstream refusal reaches this module as an unnamed error, and
|
|
20
|
+
* carrying it on would be the same refusal-shopping. Either way the cooldown
|
|
21
|
+
* restarts, so the next turn stays on the seat's own entry.
|
|
22
|
+
*
|
|
23
|
+
* An entry the host's policy blocks is skipped and never attempted. An entry
|
|
24
|
+
* the host cannot take right now (a per-model concurrency cap, a stop file;
|
|
25
|
+
* the caller's `acquire`) is skipped for this turn only. If no entry served,
|
|
26
|
+
* the turn fails visibly; nothing substitutes a harness the config does not
|
|
27
|
+
* list.
|
|
28
|
+
*
|
|
29
|
+
* Pure: state goes in and comes out as plain data, persisted by the caller,
|
|
30
|
+
* and time is passed in. Nothing here reads a file or the clock.
|
|
31
|
+
*/
|
|
32
|
+
import { RunFailure, failureKindOf } from './failures.js';
|
|
33
|
+
|
|
34
|
+
/** The failure kinds that move a turn to the next entry. Nothing else does. */
|
|
35
|
+
export const FALLBACK_KINDS = Object.freeze(['not_authenticated', 'quota', 'provider_unsupported']);
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The failure kinds on which a failed probe carries on to the seat's own
|
|
39
|
+
* entry: the fallback kinds, and the retryable kinds that say the primary
|
|
40
|
+
* never produced an answer. An allowlist, so a kind added later ends a probe
|
|
41
|
+
* turn until someone decides otherwise.
|
|
42
|
+
*/
|
|
43
|
+
export const PROBE_CARRY_KINDS = Object.freeze([...FALLBACK_KINDS, 'stuck', 'timeout', 'exited']);
|
|
44
|
+
|
|
45
|
+
/** How long a failed entry rests before a turn probes it again, per kind. */
|
|
46
|
+
export const PROBE_COOLDOWN_MS = Object.freeze({
|
|
47
|
+
quota: 15 * 60 * 1000,
|
|
48
|
+
not_authenticated: 30 * 60 * 1000,
|
|
49
|
+
provider_unsupported: 60 * 60 * 1000,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* Why a seat's active entry changed, as `harness.switched` records it:
|
|
54
|
+
* a fallback kind, `blocked` (the active entry is blocked by policy), `probe`
|
|
55
|
+
* (a probe moved back up the list) and `operator` (`commonly agent use`).
|
|
56
|
+
*/
|
|
57
|
+
export const SWITCH_REASONS = Object.freeze([...FALLBACK_KINDS, 'blocked', 'probe', 'operator']);
|
|
58
|
+
|
|
59
|
+
/** Fallback entries a config may list after its primary. */
|
|
60
|
+
export const MAX_FALLBACK = 5;
|
|
61
|
+
/** Switches kept in state.history, newest last. */
|
|
62
|
+
export const HISTORY_LIMIT = 20;
|
|
63
|
+
|
|
64
|
+
const HARNESS = /^[a-z][a-z0-9-]{0,31}$/;
|
|
65
|
+
// No '@' and no whitespace: '@' separates the effort in the short form.
|
|
66
|
+
const MODEL = /^[A-Za-z0-9][A-Za-z0-9._:/+-]{0,127}$/;
|
|
67
|
+
const EFFORT = /^[a-z]{1,16}$/;
|
|
68
|
+
const REASON = /^[A-Za-z0-9][A-Za-z0-9 ._:/#()-]{0,119}$/;
|
|
69
|
+
|
|
70
|
+
const fail = (message) => { throw new Error(`harness plan: ${message}`); };
|
|
71
|
+
const isPlainObject = (value) => value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
72
|
+
const refuseUnknownKeys = (value, allowed, where) => {
|
|
73
|
+
for (const key of Object.keys(value)) if (!allowed.includes(key)) fail(`unknown field ${where}${key}`);
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* One entry, as an object or the short form `harness/model[@effort]`
|
|
78
|
+
* (`claude/claude-sonnet-5-5@high`). The harness is everything before the first '/',
|
|
79
|
+
* so a model may itself contain '/' (`opencode/litellm/deepseek-flash-1017`).
|
|
80
|
+
*/
|
|
81
|
+
export const parseEntry = (value, where = 'entry') => {
|
|
82
|
+
let entry = value;
|
|
83
|
+
if (typeof value === 'string') {
|
|
84
|
+
const slash = value.indexOf('/');
|
|
85
|
+
if (slash < 1) fail(`${where} must read harness/model[@effort], got ${JSON.stringify(value)}`);
|
|
86
|
+
const rest = value.slice(slash + 1);
|
|
87
|
+
const at = rest.lastIndexOf('@');
|
|
88
|
+
entry = at === -1
|
|
89
|
+
? { harness: value.slice(0, slash), model: rest }
|
|
90
|
+
: { harness: value.slice(0, slash), model: rest.slice(0, at), effort: rest.slice(at + 1) };
|
|
91
|
+
}
|
|
92
|
+
if (!isPlainObject(entry)) fail(`${where} must be an object or harness/model[@effort]`);
|
|
93
|
+
refuseUnknownKeys(entry, ['harness', 'model', 'effort'], `${where}.`);
|
|
94
|
+
if (typeof entry.harness !== 'string' || !HARNESS.test(entry.harness)) fail(`${where}.harness must match ${HARNESS}`);
|
|
95
|
+
if (typeof entry.model !== 'string' || !MODEL.test(entry.model)) fail(`${where}.model must match ${MODEL}`);
|
|
96
|
+
if (entry.effort !== undefined && (typeof entry.effort !== 'string' || !EFFORT.test(entry.effort))) {
|
|
97
|
+
fail(`${where}.effort must match ${EFFORT}, or be absent`);
|
|
98
|
+
}
|
|
99
|
+
return Object.freeze({ harness: entry.harness, model: entry.model, ...(entry.effort ? { effort: entry.effort } : {}) });
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
export const entryLabel = (entry) => `${entry.harness}/${entry.model}${entry.effort ? `@${entry.effort}` : ''}`;
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* `{harness, model, effort?, fallback?: [entry, …]}` → `{primary, fallback, chain, key}`.
|
|
106
|
+
* Refuses unknown fields, a fallback longer than MAX_FALLBACK and a repeated
|
|
107
|
+
* entry (an entry listed twice would be retried under another position).
|
|
108
|
+
*/
|
|
109
|
+
export const validateRunConfig = (config) => {
|
|
110
|
+
if (!isPlainObject(config)) fail('run config must be an object');
|
|
111
|
+
refuseUnknownKeys(config, ['harness', 'model', 'effort', 'fallback'], 'config.');
|
|
112
|
+
const primary = parseEntry({
|
|
113
|
+
harness: config.harness, model: config.model, ...(config.effort !== undefined ? { effort: config.effort } : {}),
|
|
114
|
+
}, 'config');
|
|
115
|
+
const list = config.fallback ?? [];
|
|
116
|
+
if (!Array.isArray(list)) fail('config.fallback must be an array');
|
|
117
|
+
if (list.length > MAX_FALLBACK) fail(`config.fallback lists at most ${MAX_FALLBACK} entries`);
|
|
118
|
+
const fallback = list.map((item, index) => parseEntry(item, `config.fallback[${index}]`));
|
|
119
|
+
const chain = [primary, ...fallback];
|
|
120
|
+
const labels = chain.map(entryLabel);
|
|
121
|
+
const repeated = labels.find((label, index) => labels.indexOf(label) !== index);
|
|
122
|
+
if (repeated) fail(`config lists ${repeated} twice`);
|
|
123
|
+
return Object.freeze({ primary, fallback: Object.freeze(fallback), chain: Object.freeze(chain), key: labels.join(' > ') });
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Host policy: `{blocked?: [{harness?, model?, reason}], limits?: [{model, maxConcurrent}]}`.
|
|
128
|
+
* A blocked rule names a harness, a model or both, and matches an entry when
|
|
129
|
+
* every field it names is equal. The reason is a short operator word (a task
|
|
130
|
+
* id), recorded with each skip. A limit caps how many turns on this host run
|
|
131
|
+
* one model at once; it is enforced by the caller's `acquire`, which this
|
|
132
|
+
* module only consults. Absent or empty policy blocks and caps nothing.
|
|
133
|
+
*/
|
|
134
|
+
export const validatePolicy = (policy) => {
|
|
135
|
+
if (policy === undefined || policy === null) return Object.freeze({ blocked: Object.freeze([]), limits: Object.freeze([]) });
|
|
136
|
+
if (!isPlainObject(policy)) fail('policy must be an object');
|
|
137
|
+
refuseUnknownKeys(policy, ['blocked', 'limits'], 'policy.');
|
|
138
|
+
const list = policy.blocked ?? [];
|
|
139
|
+
if (!Array.isArray(list)) fail('policy.blocked must be an array');
|
|
140
|
+
const blocked = list.map((rule, index) => {
|
|
141
|
+
const where = `policy.blocked[${index}]`;
|
|
142
|
+
if (!isPlainObject(rule)) fail(`${where} must be an object`);
|
|
143
|
+
refuseUnknownKeys(rule, ['harness', 'model', 'reason'], `${where}.`);
|
|
144
|
+
if (rule.harness === undefined && rule.model === undefined) fail(`${where} names a harness, a model or both`);
|
|
145
|
+
if (rule.harness !== undefined && (typeof rule.harness !== 'string' || !HARNESS.test(rule.harness))) fail(`${where}.harness must match ${HARNESS}`);
|
|
146
|
+
if (rule.model !== undefined && (typeof rule.model !== 'string' || !MODEL.test(rule.model))) fail(`${where}.model must match ${MODEL}`);
|
|
147
|
+
if (typeof rule.reason !== 'string' || !REASON.test(rule.reason)) fail(`${where}.reason must match ${REASON}`);
|
|
148
|
+
return Object.freeze({ ...rule });
|
|
149
|
+
});
|
|
150
|
+
const capList = policy.limits ?? [];
|
|
151
|
+
if (!Array.isArray(capList)) fail('policy.limits must be an array');
|
|
152
|
+
const limits = capList.map((limit, index) => {
|
|
153
|
+
const where = `policy.limits[${index}]`;
|
|
154
|
+
if (!isPlainObject(limit)) fail(`${where} must be an object`);
|
|
155
|
+
refuseUnknownKeys(limit, ['model', 'maxConcurrent'], `${where}.`);
|
|
156
|
+
if (typeof limit.model !== 'string' || !MODEL.test(limit.model)) fail(`${where}.model must match ${MODEL}`);
|
|
157
|
+
if (!Number.isInteger(limit.maxConcurrent) || limit.maxConcurrent < 1) fail(`${where}.maxConcurrent must be an integer >= 1`);
|
|
158
|
+
return Object.freeze({ model: limit.model, maxConcurrent: limit.maxConcurrent });
|
|
159
|
+
});
|
|
160
|
+
const models = limits.map((limit) => limit.model);
|
|
161
|
+
const twice = models.find((model, index) => models.indexOf(model) !== index);
|
|
162
|
+
if (twice) fail(`policy.limits lists ${twice} twice`);
|
|
163
|
+
return Object.freeze({ blocked: Object.freeze(blocked), limits: Object.freeze(limits) });
|
|
164
|
+
};
|
|
165
|
+
|
|
166
|
+
/** The concurrency cap for this entry's model, or null. */
|
|
167
|
+
export const limitFor = (entry, policy) => (policy?.limits || []).find((limit) => limit.model === entry.model) || null;
|
|
168
|
+
|
|
169
|
+
/** The rule that blocks this entry, or null. */
|
|
170
|
+
export const blockedBy = (entry, policy) => (policy?.blocked || []).find((rule) => (
|
|
171
|
+
(rule.harness === undefined || rule.harness === entry.harness)
|
|
172
|
+
&& (rule.model === undefined || rule.model === entry.model)
|
|
173
|
+
)) || null;
|
|
174
|
+
|
|
175
|
+
/** State for a config that has never run: on the primary, nothing to probe. */
|
|
176
|
+
export const initialState = (plan) => ({
|
|
177
|
+
v: 1, configKey: plan.key, active: 0, since: null, reason: null, nextProbeAt: null, history: [],
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
// A persisted probe time as ms. An ISO string (a hand-edited or foreign
|
|
181
|
+
// state file) is read as its time; anything unreadable means the probe is due.
|
|
182
|
+
const probeTimeOf = (value) => {
|
|
183
|
+
if (Number.isFinite(value)) return value;
|
|
184
|
+
const parsed = typeof value === 'string' ? Date.parse(value) : NaN;
|
|
185
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
186
|
+
};
|
|
187
|
+
|
|
188
|
+
// State written by another version starts over. State for another config
|
|
189
|
+
// (the operator switched the seat) starts over on the new primary and keeps
|
|
190
|
+
// its history; the operator command records its own switch.
|
|
191
|
+
const stateFor = (plan, state) => {
|
|
192
|
+
if (!isPlainObject(state) || state.v !== 1) return initialState(plan);
|
|
193
|
+
if (state.configKey !== plan.key) {
|
|
194
|
+
return { ...initialState(plan), history: Array.isArray(state.history) ? state.history.slice(-HISTORY_LIMIT) : [] };
|
|
195
|
+
}
|
|
196
|
+
const active = Number.isInteger(state.active) && state.active >= 0 && state.active < plan.chain.length ? state.active : 0;
|
|
197
|
+
return {
|
|
198
|
+
...initialState(plan), ...state, active, nextProbeAt: probeTimeOf(state.nextProbeAt),
|
|
199
|
+
history: Array.isArray(state.history) ? state.history : [],
|
|
200
|
+
};
|
|
201
|
+
};
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* The entry indices this turn tries, in order. From the active entry down;
|
|
205
|
+
* from the primary when a fallback is active and its probe is due.
|
|
206
|
+
*/
|
|
207
|
+
export const turnOrder = (plan, state, now) => {
|
|
208
|
+
const current = stateFor(plan, state);
|
|
209
|
+
const probing = current.active > 0 && (current.nextProbeAt === null || now >= current.nextProbeAt);
|
|
210
|
+
const start = probing ? 0 : current.active;
|
|
211
|
+
return { probing, order: plan.chain.map((_, index) => index).slice(start) };
|
|
212
|
+
};
|
|
213
|
+
|
|
214
|
+
const iso = (ms) => new Date(ms).toISOString();
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* Run one turn on the plan.
|
|
218
|
+
*
|
|
219
|
+
* @param {object} args
|
|
220
|
+
* @param {ReturnType<typeof validateRunConfig>} args.plan
|
|
221
|
+
* @param {ReturnType<typeof validatePolicy>} [args.policy]
|
|
222
|
+
* @param {object} [args.state] what the last turn returned; absent for a first run
|
|
223
|
+
* @param {number} args.now ms since the epoch
|
|
224
|
+
* @param {(entry, info: {index, fresh, probing, switch}) => Promise<any>} args.attempt
|
|
225
|
+
* runs the turn on one entry. `fresh` is true when the entry is not the one
|
|
226
|
+
* the seat was on, so the caller starts a new session for it. `switch` is
|
|
227
|
+
* then `{from, to, reason}` (reason in SWITCH_REASONS) for the caller to
|
|
228
|
+
* write into that attempt's trace as `harness.switched`; null otherwise.
|
|
229
|
+
* @param {(entry, {limit}) => Promise<{release?: Function}|{unavailable: string}>} [args.acquire]
|
|
230
|
+
* asked before each attempt; `{unavailable}` skips the entry for this turn
|
|
231
|
+
* (its cap is full, the host holds it). `release` runs when the attempt
|
|
232
|
+
* settles. Default: every entry is available.
|
|
233
|
+
* @param {(error) => string|null} [args.classify] the failure kind; default failureKindOf
|
|
234
|
+
* @param {(skip: {entry, reason, blocked}) => void} [args.onSkip] per skipped entry
|
|
235
|
+
* @returns {Promise<{result, entry, index, state, attempts}>}
|
|
236
|
+
* On failure it rejects with the failure that ended the turn, carrying
|
|
237
|
+
* `harnessState` (persist it as on success) and `harnessAttempts`.
|
|
238
|
+
*/
|
|
239
|
+
export const runTurnWithFallback = async ({
|
|
240
|
+
plan, policy, state, now, attempt, acquire = async () => ({}), classify = failureKindOf, onSkip = () => {},
|
|
241
|
+
}) => {
|
|
242
|
+
if (!plan?.chain?.length) fail('runTurnWithFallback needs a validated plan');
|
|
243
|
+
if (!Number.isFinite(now)) fail('runTurnWithFallback needs now');
|
|
244
|
+
const current = stateFor(plan, state);
|
|
245
|
+
const { probing, order } = turnOrder(plan, current, now);
|
|
246
|
+
const was = plan.chain[current.active];
|
|
247
|
+
const attempts = [];
|
|
248
|
+
let lastKind = null;
|
|
249
|
+
let probeKind = null;
|
|
250
|
+
let lastError = null;
|
|
251
|
+
const skip = (entry, reason, blocked) => {
|
|
252
|
+
attempts.push({ entry: entryLabel(entry), outcome: blocked ? 'blocked' : 'unavailable', reason });
|
|
253
|
+
try { onSkip({ entry, reason, blocked }); } catch { /* a listener never fails a turn */ }
|
|
254
|
+
};
|
|
255
|
+
|
|
256
|
+
const cooldownOf = (kind) => PROBE_COOLDOWN_MS[kind] ?? PROBE_COOLDOWN_MS.quota;
|
|
257
|
+
const failWith = (error, next) => {
|
|
258
|
+
error.harnessState = next;
|
|
259
|
+
error.harnessAttempts = attempts;
|
|
260
|
+
return error;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
for (const index of order) {
|
|
264
|
+
const entry = plan.chain[index];
|
|
265
|
+
const rule = blockedBy(entry, policy);
|
|
266
|
+
if (rule) { skip(entry, rule.reason, true); continue; }
|
|
267
|
+
// eslint-disable-next-line no-await-in-loop
|
|
268
|
+
const slot = await acquire(entry, { limit: limitFor(entry, policy) });
|
|
269
|
+
if (typeof slot?.unavailable === 'string') { skip(entry, slot.unavailable, false); continue; }
|
|
270
|
+
const fresh = index !== current.active;
|
|
271
|
+
// Why this attempt is not on the seat's entry: what failed above it, a
|
|
272
|
+
// probe moving up, or the entries above it skipped.
|
|
273
|
+
let reason = null;
|
|
274
|
+
if (fresh) reason = index < current.active ? 'probe' : (lastKind || 'blocked');
|
|
275
|
+
try {
|
|
276
|
+
// eslint-disable-next-line no-await-in-loop
|
|
277
|
+
const result = await attempt(entry, {
|
|
278
|
+
index, fresh, probing, switch: fresh ? { from: was, to: entry, reason } : null,
|
|
279
|
+
});
|
|
280
|
+
attempts.push({ entry: entryLabel(entry), outcome: 'ok' });
|
|
281
|
+
const next = { ...current, active: index };
|
|
282
|
+
if (fresh) {
|
|
283
|
+
const why = index < current.active ? 'recovered' : reason;
|
|
284
|
+
next.since = iso(now);
|
|
285
|
+
next.reason = why;
|
|
286
|
+
next.history = [...current.history, { at: iso(now), from: entryLabel(was), to: entryLabel(entry), reason: why }]
|
|
287
|
+
.slice(-HISTORY_LIMIT);
|
|
288
|
+
}
|
|
289
|
+
// Back on the primary there is nothing to probe. On a fallback, the
|
|
290
|
+
// next probe waits out the cooldown of what failed above it this turn;
|
|
291
|
+
// a turn that simply stayed put keeps the probe time it had.
|
|
292
|
+
if (index === 0) next.nextProbeAt = null;
|
|
293
|
+
else if (probeKind || fresh) next.nextProbeAt = now + cooldownOf(probeKind || lastKind);
|
|
294
|
+
return { result, entry, index, attempts, state: next };
|
|
295
|
+
} catch (error) {
|
|
296
|
+
const kind = classify(error);
|
|
297
|
+
attempts.push({ entry: entryLabel(entry), outcome: 'failed', kind: kind || null });
|
|
298
|
+
const probeFailed = probing && index < current.active;
|
|
299
|
+
if (probeFailed && probeKind === null) probeKind = kind || 'unnamed';
|
|
300
|
+
// Not a reason to change harness: the turn fails as it would have with
|
|
301
|
+
// no fallback configured, and the seat stays where it was. A probe that
|
|
302
|
+
// failed only for want of an answer (PROBE_CARRY_KINDS) carries on to
|
|
303
|
+
// the seat's own entry; any other probe failure ends the turn too.
|
|
304
|
+
const carries = probeFailed ? PROBE_CARRY_KINDS.includes(kind) : FALLBACK_KINDS.includes(kind);
|
|
305
|
+
if (!carries) {
|
|
306
|
+
// A probe failed this turn, so the primary rests again whatever ended
|
|
307
|
+
// the turn; otherwise the next turn would probe it straight away.
|
|
308
|
+
const next = probeKind ? { ...current, nextProbeAt: now + cooldownOf(probeKind) } : current;
|
|
309
|
+
throw failWith(error, next);
|
|
310
|
+
}
|
|
311
|
+
// Only a failure at or below the seat's entry is a reason to move down
|
|
312
|
+
// from it; a probe's failures above it are the probe's own (probeKind).
|
|
313
|
+
if (FALLBACK_KINDS.includes(kind) && index >= current.active) lastKind = kind;
|
|
314
|
+
lastError = error;
|
|
315
|
+
} finally {
|
|
316
|
+
try { slot?.release?.(); } catch { /* releasing never fails a turn */ }
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
// Nothing served. Stay on the entry the seat was on and push the probe out,
|
|
320
|
+
// so the next turn does not go straight back to an entry that just failed.
|
|
321
|
+
const next = { ...current };
|
|
322
|
+
if (current.active > 0 && (lastKind || probeKind)) next.nextProbeAt = now + cooldownOf(probeKind || lastKind);
|
|
323
|
+
const summary = attempts.map((a) => (a.outcome === 'failed' ? `${a.entry} ${a.kind || 'failed'}` : `${a.entry} ${a.outcome} (${a.reason})`)).join(', ');
|
|
324
|
+
if (lastError) throw failWith(lastError, next);
|
|
325
|
+
// Every entry was skipped: by policy alone it cannot run at all; with any
|
|
326
|
+
// entry merely unavailable it may run once a slot frees, and is retryable.
|
|
327
|
+
const onlyBlocked = attempts.every((a) => a.outcome === 'blocked');
|
|
328
|
+
throw failWith(new RunFailure(onlyBlocked ? 'unsupported' : 'capacity', `no entry in the run config could run: ${summary}`), next);
|
|
329
|
+
};
|
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
import { createHash } from 'node:crypto';
|
|
8
8
|
import { isIP } from 'node:net';
|
|
9
9
|
import { FAILURE_KINDS } from '../failures.js';
|
|
10
|
+
import { SWITCH_REASONS, entryLabel, parseEntry } from '../harness-plan.js';
|
|
10
11
|
|
|
11
12
|
export const RECORD_VERSION = 1;
|
|
12
13
|
export const RECORD_SCHEMA = 'seats.record';
|
|
@@ -84,6 +85,8 @@ export const KINDS = Object.freeze({
|
|
|
84
85
|
'session.opened': Object.freeze({ group: 'attempt', actor: 'system' }),
|
|
85
86
|
'attempt.settled': Object.freeze({ group: 'attempt', actor: 'system' }),
|
|
86
87
|
'run.settled': Object.freeze({ group: 'attempt', actor: 'system' }),
|
|
88
|
+
// The attempt that follows runs on another harness entry than the seat was on.
|
|
89
|
+
'harness.switched': Object.freeze({ group: 'attempt', actor: 'system' }),
|
|
87
90
|
'tool.call': Object.freeze({ group: 'tool', actor: 'run' }),
|
|
88
91
|
'tool.update': Object.freeze({ group: 'tool', actor: 'run' }),
|
|
89
92
|
'credential.granted': Object.freeze({ group: 'credential', actor: 'system' }),
|
|
@@ -407,6 +410,18 @@ const CHECKS = {
|
|
|
407
410
|
}
|
|
408
411
|
return data;
|
|
409
412
|
},
|
|
413
|
+
'harness.switched': (data) => {
|
|
414
|
+
refuseUnknownKeys(data, ['from', 'to', 'reason'], 'harness.switched.');
|
|
415
|
+
for (const key of ['from', 'to']) {
|
|
416
|
+
let entry = null;
|
|
417
|
+
try { entry = typeof data[key] === 'string' ? parseEntry(data[key]) : null; } catch { entry = null; }
|
|
418
|
+
// The canonical label only, so one entry is always written one way.
|
|
419
|
+
if (!entry || entryLabel(entry) !== data[key]) fail(`harness.switched.${key} must be an entry label harness/model[@effort]`);
|
|
420
|
+
}
|
|
421
|
+
if (data.from === data.to) fail('harness.switched.from and to must differ');
|
|
422
|
+
if (!SWITCH_REASONS.includes(data.reason)) fail(`harness.switched.reason must be one of ${SWITCH_REASONS.join(', ')}`);
|
|
423
|
+
return data;
|
|
424
|
+
},
|
|
410
425
|
'agent.retry': (data) => {
|
|
411
426
|
refuseUnknownKeys(data, ['state', 'attempt', 'nextRetryAt', 'status', 'errorType'], 'agent.retry.');
|
|
412
427
|
if (!RETRY_STATES.includes(data.state)) fail(`agent.retry.state must be one of ${RETRY_STATES.join(', ')}`);
|
|
@@ -35,6 +35,7 @@ export const EVENT_TYPES = Object.freeze([
|
|
|
35
35
|
'attempt.settled', // { attempt, outcome, failure?: {kind, detail} }
|
|
36
36
|
'decision.ruled', // { decisionCardId, value, decidedBy, decidedByRole } a card ruling not tied to a parked tool call
|
|
37
37
|
'result.reported', // { status: 'done'|'partial'|'blocked'|'failed', refs?: [{type, id}] }
|
|
38
|
+
'harness.switched', // { from, to, reason } entry labels harness/model[@effort]; reason in SWITCH_REASONS
|
|
38
39
|
]);
|
|
39
40
|
|
|
40
41
|
const KNOWN = new Set(EVENT_TYPES);
|
package/package.json
CHANGED
package/src/commands/agent.js
CHANGED
|
@@ -1563,13 +1563,15 @@ export const performRun = ({
|
|
|
1563
1563
|
return { environment: seatEnvironment, cwd: agentCwd, binding: sessionBinding };
|
|
1564
1564
|
}
|
|
1565
1565
|
let installations = null;
|
|
1566
|
+
let readError = null;
|
|
1566
1567
|
try {
|
|
1567
1568
|
const identity = await client.get('/api/agents/runtime/installations', {}, {
|
|
1568
1569
|
signal: AbortSignal.timeout(5000),
|
|
1569
1570
|
});
|
|
1570
1571
|
installations = Array.isArray(identity?.installations) ? identity.installations : [];
|
|
1571
|
-
} catch {
|
|
1572
|
+
} catch (err) {
|
|
1572
1573
|
installations = null;
|
|
1574
|
+
readError = err;
|
|
1573
1575
|
}
|
|
1574
1576
|
const placement = workspaceTurnPlacement({
|
|
1575
1577
|
environment: seatEnvironment, workspacePath: agentCwd, installations, scratchDir: scratchCwd,
|
|
@@ -1582,7 +1584,7 @@ export const performRun = ({
|
|
|
1582
1584
|
}
|
|
1583
1585
|
if (!existsSync(placement.cwd)) mkdirSync(placement.cwd, { recursive: true });
|
|
1584
1586
|
const where = `${placement.environment.sandbox.trust}:${placement.cwd}`;
|
|
1585
|
-
return { ...placement, binding: sessionBinding ? `${sessionBinding}:${where}` : where };
|
|
1587
|
+
return { ...placement, readError, binding: sessionBinding ? `${sessionBinding}:${where}` : where };
|
|
1586
1588
|
};
|
|
1587
1589
|
const batchLimit = Math.min(50, Math.max(1, Number(inboxBatchLimit) || 10));
|
|
1588
1590
|
|
|
@@ -1835,6 +1837,33 @@ export const performRun = ({
|
|
|
1835
1837
|
event, eventPodId, prompt, preSpawnIds, snapshotMessages, claimKeeper, trigger,
|
|
1836
1838
|
}) => {
|
|
1837
1839
|
const placement = await placeTurn();
|
|
1840
|
+
// A workspace turn placed public (its pod turned public, or its pods could
|
|
1841
|
+
// not be read) is confined as a public seat, which this host may not be
|
|
1842
|
+
// able to do: Claude Code on Linux has only the workspace jail. Refuse it
|
|
1843
|
+
// as the run start would, before anything is read or spawned. The model
|
|
1844
|
+
// never runs and nothing is posted. A public pod is a settled answer, so a
|
|
1845
|
+
// human's message is handed to another listener (claim-outcome.js). A
|
|
1846
|
+
// failed read is not: it throws, so the event stays unacked and comes back
|
|
1847
|
+
// through the spawn-retry backoff instead of being refused for good.
|
|
1848
|
+
if (placement.reason) {
|
|
1849
|
+
const turnRefusal = hostSandboxRefusal({ environment: placement.environment, adapterName: adapter.name });
|
|
1850
|
+
if (turnRefusal && placement.readError) {
|
|
1851
|
+
throw Object.assign(
|
|
1852
|
+
new Error(`turn deferred, because its pods could not be read: ${placement.readError.message}`, {
|
|
1853
|
+
cause: placement.readError,
|
|
1854
|
+
}),
|
|
1855
|
+
{ status: placement.readError.status },
|
|
1856
|
+
);
|
|
1857
|
+
}
|
|
1858
|
+
if (turnRefusal) {
|
|
1859
|
+
log(`turn refused, because ${placement.reason}: ${turnRefusal.replace(/\n\s*/g, ' ')}`);
|
|
1860
|
+
return {
|
|
1861
|
+
outcome: 'no_action',
|
|
1862
|
+
refused: { reason: 'delivery-refused' },
|
|
1863
|
+
reason: 'host-cannot-confine-turn',
|
|
1864
|
+
};
|
|
1865
|
+
}
|
|
1866
|
+
}
|
|
1838
1867
|
// TASK-440: a cold, large session starts fresh at wake instead of being
|
|
1839
1868
|
// resumed. The restart note leads the prompt; memory still reaches the
|
|
1840
1869
|
// fresh session through the adapter's preamble.
|
|
@@ -788,6 +788,25 @@ const resolveClaudePath = (env = process.env) => {
|
|
|
788
788
|
return 'claude';
|
|
789
789
|
};
|
|
790
790
|
|
|
791
|
+
// Where the claude this host would run is installed: the folder the Linux
|
|
792
|
+
// workspace jail has to bind, since it binds the system binary folders only.
|
|
793
|
+
// Null when the binary cannot be located (a bare name, a dangling link).
|
|
794
|
+
const resolveClaudeInstall = (env) => {
|
|
795
|
+
const claudeBin = resolveClaudePath(env);
|
|
796
|
+
if (!isAbsolute(claudeBin)) return { claudeBin, claudeInstall: null };
|
|
797
|
+
try {
|
|
798
|
+
return { claudeBin, claudeInstall: dirname(realpathSync(claudeBin)) };
|
|
799
|
+
} catch {
|
|
800
|
+
return { claudeBin, claudeInstall: null };
|
|
801
|
+
}
|
|
802
|
+
};
|
|
803
|
+
|
|
804
|
+
// A jail that cannot carry claude would fail inside at every spawn.
|
|
805
|
+
const claudeInstallRefusal = (claudeBin) => new Error(
|
|
806
|
+
'workspace Claude agents on Linux need Claude Code on PATH, so the sandbox can carry its install; '
|
|
807
|
+
+ `\`${claudeBin}\` could not be resolved`,
|
|
808
|
+
);
|
|
809
|
+
|
|
791
810
|
const prepareArgv = async (innerArgv, ctx) => {
|
|
792
811
|
const env = ctx.environment;
|
|
793
812
|
const claudeEnv = withClaudePath(ctx.claudeEnv);
|
|
@@ -941,12 +960,9 @@ const prepareArgv = async (innerArgv, ctx) => {
|
|
|
941
960
|
+ `repository (run \`git init\` in ${ctx.cwd}), so its .git can be held read-only`);
|
|
942
961
|
}
|
|
943
962
|
}
|
|
944
|
-
const claudeBin =
|
|
963
|
+
const { claudeBin, claudeInstall } = resolveClaudeInstall(claudeEnv);
|
|
964
|
+
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
945
965
|
const { statePath } = ctx.publicClaudeState;
|
|
946
|
-
let claudeInstall = null;
|
|
947
|
-
try {
|
|
948
|
-
claudeInstall = dirname(realpathSync(claudeBin));
|
|
949
|
-
} catch { /* an unresolvable binary fails at spawn with its own error */ }
|
|
950
966
|
const wrapped = wrapArgvWithBwrap([claudeBin, ...innerArgv], env, {
|
|
951
967
|
workspacePath: ctx.cwd,
|
|
952
968
|
workspaceReadOnly: !grants.includes('write'),
|
|
@@ -1125,6 +1141,8 @@ export default {
|
|
|
1125
1141
|
// jail never gets a copy of the login.
|
|
1126
1142
|
const bwrap = (ctx._detectBwrap || detectBwrap)();
|
|
1127
1143
|
if (!bwrap.available) throw new Error(`workspace Claude agents require bwrap: ${bwrap.error}`);
|
|
1144
|
+
const { claudeBin, claudeInstall } = resolveClaudeInstall(withClaudePath(ctx.env));
|
|
1145
|
+
if (!claudeInstall) throw claudeInstallRefusal(claudeBin);
|
|
1128
1146
|
publicClaudeState = await prepareWorkspaceBwrapClaudeState(ctx);
|
|
1129
1147
|
}
|
|
1130
1148
|
if (publicTrust) {
|
|
@@ -46,15 +46,17 @@ export const windowsSandboxMessage = (adapterName) => {
|
|
|
46
46
|
};
|
|
47
47
|
|
|
48
48
|
/**
|
|
49
|
-
* Claude Code on Linux (WSL included)
|
|
50
|
-
*
|
|
51
|
-
*
|
|
52
|
-
*
|
|
53
|
-
*
|
|
49
|
+
* Claude Code on Linux (WSL included) for any seat but a workspace one. Only
|
|
50
|
+
* the workspace jail (#2784) carries what Claude needs: its install, a HOME
|
|
51
|
+
* of its own and a copy of the login. The public jail binds the system binary
|
|
52
|
+
* folders only, so a confined spawn could not even start Claude (Ubuntu run,
|
|
53
|
+
* 2026-10-16), and giving it the login would hand strangers a jail that holds
|
|
54
|
+
* it. A workspace seat needs every pod it answers in to be invite-only.
|
|
54
55
|
*/
|
|
55
56
|
export const linuxClaudeMessage = () => [
|
|
56
|
-
'Commonly runs a connected Claude Code inside a sandbox
|
|
57
|
-
'
|
|
57
|
+
'Commonly runs a connected Claude Code inside a sandbox. On Linux (WSL included) it does that only',
|
|
58
|
+
'for an agent whose pods are all invite-only, and this one is not, so it cannot listen for @mentions here. Instead:',
|
|
59
|
+
' - From an invite-only pod: run the commands its Connect page shows, from your project\'s folder.',
|
|
58
60
|
' - Use Commonly from your own Claude Code sessions: the Connect page shows a `claude mcp add` command.',
|
|
59
61
|
HOSTED_LINE,
|
|
60
62
|
].join('\n');
|
|
@@ -76,7 +78,11 @@ export const hostSandboxRefusal = ({
|
|
|
76
78
|
if (mode !== 'bwrap' && !SEATBELT_MODES.has(mode)) return null;
|
|
77
79
|
|
|
78
80
|
if (platform === 'win32') return windowsSandboxMessage(adapterName);
|
|
79
|
-
|
|
81
|
+
// A workspace claude seat falls through to the bubblewrap probe below; the
|
|
82
|
+
// probe failing, for any reason, is a refusal like any other adapter's.
|
|
83
|
+
if (mode === 'bwrap' && adapterName === 'claude' && sandbox.trust !== 'workspace') {
|
|
84
|
+
return linuxClaudeMessage();
|
|
85
|
+
}
|
|
80
86
|
if (mode === 'bwrap') {
|
|
81
87
|
const bwrap = detectBwrap();
|
|
82
88
|
return bwrap.available ? null : `This seat runs in a bubblewrap sandbox, which is not available here. ${bwrap.error}`;
|
|
@@ -73,9 +73,10 @@ export const workspaceStartDirRefusal = (dir, { home = homedir() } = {}) => {
|
|
|
73
73
|
};
|
|
74
74
|
|
|
75
75
|
const PUBLIC_POD_REASON = 'a pod it answers in is publicly readable or open to join, or did not report whether it is';
|
|
76
|
-
// claude.js
|
|
76
|
+
// claude.js runs a workspace seat under Seatbelt on macOS and under bwrap on
|
|
77
|
+
// Linux (#2784). Native Windows has no sandbox for it, so there the default
|
|
77
78
|
// would be a seat that fails every turn rather than one that reads a project.
|
|
78
|
-
const
|
|
79
|
+
const UNSUPPORTED_OS_REASON = 'an agent works in its folder only on macOS and Linux for now';
|
|
79
80
|
|
|
80
81
|
/**
|
|
81
82
|
* Where a first-run claude seat may read (TASK-294), from three facts the
|
|
@@ -90,7 +91,9 @@ export const firstRunWorkspace = ({
|
|
|
90
91
|
adapterName, installations, startDir, home, platform = process.platform,
|
|
91
92
|
} = {}) => {
|
|
92
93
|
if (adapterName !== 'claude') return null;
|
|
93
|
-
if (platform !== 'darwin'
|
|
94
|
+
if (platform !== 'darwin' && platform !== 'linux') {
|
|
95
|
+
return { workspacePath: null, reason: UNSUPPORTED_OS_REASON };
|
|
96
|
+
}
|
|
94
97
|
if (!allPodsConfirmedPrivate(installations)) {
|
|
95
98
|
return { workspacePath: null, reason: PUBLIC_POD_REASON };
|
|
96
99
|
}
|