Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ regexes = [
# signature. generic-api-key matched it, and main's Secret Scan was red on
# every push from 2026-10-16. The exact literal only, so a real token still fails.
'''eyJhbGciOiJSUzI1NiJ9\.jwt''',
# A fake OpenRouter key in packages/seats/test/supervisor.test.js (#2701): the
# scrub test needs a value shaped like a real key so it can prove the key never
# reaches the record. generic-api-key matched it in a commit the scan reads, so
# an inline allow on today's line would not clear it. The exact literal only.
'''sk-or-v1-abcdefabcdefabcdef''',
# The namespaced cloudflared ExternalSecret has `- secretKey: credentials.json`
# (#2439) — a Secret KEY NAME (what the tunnel credentials.json is stored under), not
# a value. gitleaks' kubernetes-secret-yaml rule captures `secretKey: credentials` (it
Expand Down
2 changes: 1 addition & 1 deletion packages/seats/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# packages/seats

The adapters that run an agent CLI as a supervised seat: one prompt in, one CLI run, one reply out. The package holds the contract, the registry, the failure naming, the spawn observation, and the first adapters (`stub`, `hermes`).
The adapters that run an agent CLI as a supervised seat: one prompt in, one CLI run, one reply out. The package holds the contract, the registry, the failure naming, the spawn observation, the run supervisor (`superviseRun`: a turn with no progress signal for `stallMs` is traced as stuck, cancelled, and settled even if its adapter ignores the cancel), and the first adapters (`stub`, `hermes`).

It is private and is not published on its own. `@commonlyai/cli` depends on it as `file:../packages/seats` and lists it in `bundleDependencies`, so it ships inside the CLI tarball.

Expand Down
20 changes: 15 additions & 5 deletions packages/seats/docs/record-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -333,7 +333,7 @@ built in Go runs its tests with `-race` in CI.
| kind | data | default actor |
|---|---|---|
| `credential.granted` | `{names: [NAME]}` | `system` |
| `credential.denied` | `{name, reason}`, where `reason` is `not_granted` or `unresolved` | `system` |
| `credential.denied` | `{name, reason}`, where `reason` is `not_granted`, `unresolved` or `too_short` (a value under 8 characters) | `system` |

These cover the environment credentials a run is started with. Values never
appear here: `NAME` is an environment variable name matching
Expand Down Expand Up @@ -434,11 +434,21 @@ What's new or changed in `EVENT_TYPES`:
and a writer refuses one that is not name-shaped.
- Agent-authored text, such as message chunks and tool titles, belongs to
the agent and could repeat a value the agent holds. A writer that knows
the run's credential values replaces every occurrence of 8 characters or
more with `[redacted]` before the line is hashed.
the run's credential values replaces every occurrence of each, whatever
its length, with `[redacted]` before the line is hashed. A writer does
not grant a credential shorter than 8 characters (`credential.denied`,
`too_short`).
- Message and thought chunks are scrubbed per stream, not per line, so a
value split across chunks is caught. A writer holds back a chunk's tail
while it could begin a known value, and writes it with the stream's next
chunk. If the stream ends first, the held tail is a partial value, so the
writer writes `[redacted]` as a chunk of its own in its place, before the
next `attempt.started`, `attempt.settled` or `run.settled`. A short
ordinary tail that happened to begin a value is redacted too. A held tail
lives only in the writer's memory: a writer that crashes before the next
write loses it, and never writes it.
- That replacement covers known values only. A value the writer was not
given, or one the agent transformed (base64, split across chunks), passes
through.
given, or one the agent transformed (base64, say), passes through.
2. **Readers ignore unknown fields and skip unknown kinds.** A reader that
meets a kind it doesn't know still checks the chain over that line.
3. **`v` bumps only on a breaking change:**
Expand Down
1 change: 1 addition & 0 deletions packages/seats/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -14,3 +14,4 @@ export {
RECORD_VERSION, RECORD_SCHEMA, KINDS, canonicalLine, appendEntry, verifyRecord, readRecord, hashLine, toolSurfaceOf, argsDigestOf,
} from './record/format.js';
export { toOtlpJson } from './record/otel.js';
export { superviseRun, SUPERVISOR_DEFAULTS, DEFAULT_RETRY_ON } from './supervisor.js';
4 changes: 3 additions & 1 deletion packages/seats/src/runtime-observation.js
Original file line number Diff line number Diff line change
Expand Up @@ -19,9 +19,11 @@ export const controlsFromArguments = (args, { modelFlag, effortFlag, valueFlags

// Called at the child's successful OS spawn event, not when a requested
// configuration is saved or when a turn is merely queued. Never send argv.
// The child rides along as a second argument so a supervisor can read its
// output as a liveness signal and kill it; a reporter reads only the first.
export const observeSpawn = (child, onSpawn, controls) => {
if (typeof onSpawn !== 'function') return;
child.once('spawn', () => {
try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }); } catch { /* diagnostic only */ }
try { onSpawn({ model: controls.model || null, effort: controls.effort || null, at: new Date().toISOString() }, child); } catch { /* diagnostic only */ }
});
};
287 changes: 287 additions & 0 deletions packages/seats/src/supervisor.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,287 @@
/**
* The run supervisor: one prompt, one traced run, and a guarantee that the run
* settles. A turn that hangs is named `stuck` in the trace, cancelled, and if
* the adapter will not let go, abandoned and settled anyway. Nothing this
* module starts is allowed to sit in "running" forever.
*
* Liveness is read from progress signals, not from the reply: any trace event
* the adapter emits, and any byte on the stdout/stderr of a child the adapter
* reports through `ctx.onSpawn`. A run that is busy but quiet past the window
* is indistinguishable from a hung one, which is why the window is long.
*/
import { RunFailure, runFailure, failureKindOf, FAILURE_KINDS } from './failures.js';
import { redactKnownCredentials, scrubAdapterFailure, spawnCredentials } from './upstream-refusal.js';

export const SUPERVISOR_DEFAULTS = Object.freeze({
// Longer than the quiet stretches a healthy turn has: a long-context first
// token, or a tool (an install, a test suite) whose output the CLI buffers.
stallMs: 120_000,
// Covers an adapter's own SIGTERM -> 5 s -> SIGKILL ladder with room to spare.
cancelGraceMs: 10_000,
// The turn budget the run loop already enforces (COMMONLY_AGENT_RUN_TIMEOUT_MS's
// fallback), so supervising a turn does not shorten or lengthen it.
timeoutMs: 15 * 60 * 1000,
});

export const DEFAULT_RETRY_ON = Object.freeze(['stuck', 'exited', 'timeout']);

// setTimeout overflows past this and fires immediately.
const MAX_TIMER_MS = 2_147_483_647;
// The supervisor's own events; an adapter writing them would forge the run's outcome.
const SUPERVISOR_EVENTS = new Set(['run.started', 'run.settled']);

const windowOf = (value, fallback) => {
if (value === undefined || value === null) return fallback;
const number = Number(value);
if (number === 0 || number === Infinity) return null; // explicitly off
if (!Number.isFinite(number) || number < 0) throw new Error(`supervisor window must be a non-negative number, got ${value}`);
return Math.min(number, MAX_TIMER_MS);
};

const resolvePolicy = (policy = {}) => {
const retry = policy.retry || {};
return {
stallMs: windowOf(policy.stallMs, SUPERVISOR_DEFAULTS.stallMs),
cancelGraceMs: windowOf(policy.cancelGraceMs, SUPERVISOR_DEFAULTS.cancelGraceMs) ?? 0,
timeoutMs: windowOf(policy.timeoutMs, SUPERVISOR_DEFAULTS.timeoutMs),
retryMax: Math.max(0, Math.floor(Number(retry.max) || 0)),
retryOn: Array.isArray(retry.on) ? retry.on : DEFAULT_RETRY_ON,
onStalled: typeof policy.onStalled === 'function' ? policy.onStalled : null,
};
};

const isChild = (value) => !!value && typeof value.kill === 'function' && typeof value.once === 'function';
const isAlive = (child) => child.exitCode === null && child.signalCode === null;

// The retryable flag in FAILURE_KINDS outranks the caller's list: a refusal
// listed in `on` is still never re-sent.
const shouldRetry = (failure, attempt, settings) => {
const kind = failureKindOf(failure);
return attempt <= settings.retryMax
&& !!kind && FAILURE_KINDS[kind].retryable
&& settings.retryOn.includes(kind)
&& !failure.adapterIgnoredCancel;
};

const outcomeOf = (failure) => {
if (!failure) return 'ok';
const kind = failureKindOf(failure);
if (kind === 'stuck') return 'stuck';
if (kind === 'cancelled') return 'cancelled';
return 'failed';
};

// An adapter that threw a bare Error broke the RunFailure rule; its text is
// still scrubbed before it reaches the trace, and it carries no kind rather
// than a guessed one. A RunFailure's detail keeps its shape but loses any
// credential this spawn was handed, whoever built it.
const failureData = (failure, credentials) => (failure instanceof RunFailure
? { kind: failure.kind, detail: redactKnownCredentials(failure.detail, credentials) }
: { kind: null, detail: scrubAdapterFailure(String(failure?.message ?? failure ?? ''), { credentials }).detail });

const sessionIdOf = (failure) => {
const id = failure?.sessionId ?? failure?.newSessionId;
return typeof id === 'string' && id ? id : null;
};

const ignoredCancel = (reason, graceMs) => {
const failure = new RunFailure(reason.kind, `${reason.detail}; the adapter did not settle within ${graceMs}ms of cancellation`);
failure.adapterIgnoredCancel = true;
return failure;
};

const callerCancel = (signal) => (failureKindOf(signal.reason) === 'cancelled'
? signal.reason : runFailure('cancelled', 'cancelled by the caller'));

/** One attempt. Resolves { result } or { failure }; never rejects, never stays pending. */
const runAttempt = ({ adapter, prompt, ctx, run, settings, attempt, sessionId, external }) => new Promise((resolve) => {
const controller = new AbortController();
const children = new Set();
let live = true;
let abortedBy = null;
let lastSignal = Date.now();
let stallTimer = null;
let wallTimer = null;
let graceTimer = null;

const touch = () => { if (live) lastSignal = Date.now(); };

const killChildren = () => {
let killed = 0;
for (const child of children) {
if (!isAlive(child)) continue;
try { child.kill('SIGKILL'); killed += 1; } catch { /* already gone */ }
}
return killed;
};

const onExternal = () => cancel(callerCancel(external), { recovery: false });

const finish = (outcome) => {
if (!live) return;
live = false;
clearTimeout(stallTimer);
clearTimeout(wallTimer);
clearTimeout(graceTimer);
external?.removeEventListener?.('abort', onExternal);
// An adapter that honoured the abort can still leave its child behind.
if (abortedBy && !outcome.failure?.adapterIgnoredCancel && killChildren() > 0) {
run.emit('liveness.recovery', { action: 'kill', attempt });
}
resolve(outcome);
};

function cancel(reason, { recovery = true } = {}) {
if (abortedBy || !live) return;
abortedBy = reason;
clearTimeout(stallTimer);
clearTimeout(wallTimer);
if (recovery) run.emit('liveness.recovery', { action: 'cancel', attempt });
controller.abort(reason);
graceTimer = setTimeout(() => {
if (!live) return;
run.emit('liveness.recovery', { action: 'kill', attempt });
killChildren();
finish({ failure: ignoredCancel(reason, settings.cancelGraceMs) });
}, settings.cancelGraceMs);
}

const checkStall = () => {
if (!live || abortedBy) return;
const idleMs = Date.now() - lastSignal;
if (idleMs < settings.stallMs) {
stallTimer = setTimeout(checkStall, settings.stallMs - idleMs);
return;
}
const windowMs = settings.stallMs;
run.emit('liveness.stalled', { idleMs, windowMs });
if (settings.onStalled) {
// A host's notifier failing must not keep the run from recovering.
try {
Promise.resolve(settings.onStalled({ runId: run.runId, adapter: adapter.name, attempt, idleMs, windowMs })).catch(() => {});
} catch { /* notifier only */ }
}
cancel(runFailure('stuck', `no progress signal for ${idleMs}ms (window ${windowMs}ms)`));
};

// Counts as progress only while this attempt is live, so a cancelled
// attempt's late output cannot keep the next one alive or reach the trace.
// The emit options (the entry's actor) pass through unchanged. `redact` is
// offered only when the run has one, so prepareRunEnv cannot hand its
// values to a no-op; it is not gated on `live`, since adding a value to
// scrub is never unsafe.
const trace = {
emit(type, data, options) {
if (!live || SUPERVISOR_EVENTS.has(type)) return null;
touch();
return run.emit(type, data, options);
},
...(typeof run.redact === 'function' ? { redact: (values) => run.redact(values) } : {}),
};

// Wraps the caller's onSpawn: every argument reaches it unchanged.
const onSpawn = (...args) => {
if (live) {
touch();
for (const child of args.filter(isChild)) {
children.add(child);
child.stdout?.on?.('data', touch);
child.stderr?.on?.('data', touch);
}
}
return typeof ctx.onSpawn === 'function' ? ctx.onSpawn(...args) : undefined;
};

if (external?.aborted) {
abortedBy = callerCancel(external);
finish({ failure: abortedBy });
return;
}
external?.addEventListener?.('abort', onExternal, { once: true });
if (settings.stallMs) stallTimer = setTimeout(checkStall, settings.stallMs);
if (settings.timeoutMs) {
wallTimer = setTimeout(() => cancel(runFailure('timeout', `exceeded the ${settings.timeoutMs}ms wall-clock budget`)), settings.timeoutMs);
}

let pending;
try {
pending = Promise.resolve(adapter.spawn(prompt, {
...ctx, ...(sessionId ? { sessionId } : {}), trace, signal: controller.signal, onSpawn,
}));
} catch (error) {
pending = Promise.reject(error);
}
// Once the supervisor has aborted, the abort's reason is the outcome, even
// if the adapter answers inside the grace window: the trace already says why.
pending.then(
(result) => finish(abortedBy ? { failure: abortedBy } : { result }),
(error) => finish({ failure: abortedBy || error }),
);
});

/**
* Run one prompt under liveness supervision.
*
* @param {object} args
* @param {import('./contract.js').Adapter} args.adapter
* @param {string} args.prompt
* @param {object} [args.ctx] passed to adapter.spawn; `trace`, `signal` and `onSpawn` are wrapped.
* `ctx.signal`, when given, cancels the run (outcome `cancelled`).
* @param {ReturnType<import('./trace/store.js').createTraceStore>} args.store
* @param {object} [args.policy]
* @param {number} [args.policy.stallMs] no-progress window; 0 turns it off
* @param {number} [args.policy.cancelGraceMs] how long a cancelled adapter has to settle
* @param {number} [args.policy.timeoutMs] wall-clock budget per attempt; 0 turns it off
* @param {{max: number, on?: string[]}} [args.policy.retry] off unless max > 0
* @param {(info: {runId, adapter, attempt, idleMs, windowMs}) => void} [args.policy.onStalled]
* @param {string} [args.runId]
* @param {string} [args.label]
* @returns {Promise<{ runId: string, outcome: string, attempts: number, result?: object, failure?: Error }>}
*/
export const superviseRun = async ({ adapter, prompt, ctx = {}, store, policy = {}, runId, label } = {}) => {
if (!adapter || typeof adapter.spawn !== 'function') throw new Error('superviseRun needs an adapter with spawn()');
if (!store || typeof store.startRun !== 'function') throw new Error('superviseRun needs a trace store');
const settings = resolvePolicy(policy);
const run = store.startRun({ runId, adapter: adapter.name, label });
// The runtime token rides in ctx, not through the valet, so the store would
// not otherwise know to scrub it from agent text.
if (typeof run.redact === 'function' && typeof ctx.runtimeToken === 'string' && ctx.runtimeToken) {
run.redact([ctx.runtimeToken]);
}
const startedAt = Date.now();
const external = ctx.signal;
let attempt = 0;
let sessionId = ctx.sessionId || null;
let last = { failure: null };

try {
for (;;) {
attempt += 1;
run.emit('run.started', { adapter: adapter.name, ...(sessionId ? { sessionId } : {}), attempt });
last = await runAttempt({ adapter, prompt, ctx, run, settings, attempt, sessionId, external });
if (!last.failure || external?.aborted || !shouldRetry(last.failure, attempt, settings)) break;
sessionId = sessionIdOf(last.failure) || sessionId;
run.emit('liveness.recovery', { action: 'retry', attempt: attempt + 1 });
}
} catch (error) {
// The supervisor's own fault (a trace write failing, say) still settles the run.
last = { failure: error };
}

const outcome = outcomeOf(last.failure);
const credentials = spawnCredentials(ctx, [adapter.providerKeyEnv].filter(Boolean));
try {
run.emit('run.settled', {
outcome,
...(last.failure ? { failure: failureData(last.failure, credentials) } : {}),
durationMs: Date.now() - startedAt,
attempts: attempt,
});
} catch { /* the outcome is still returned */ }
return {
runId: run.runId,
outcome,
attempts: attempt,
...(last.failure ? { failure: last.failure } : { result: last.result }),
};
};
2 changes: 1 addition & 1 deletion packages/seats/src/trace/events.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ export const EVENT_TYPES = Object.freeze([
'liveness.stalled', // { idleMs, windowMs }
'liveness.recovery', // { action: 'cancel'|'kill'|'retry', attempt }
'credential.granted', // { names: [...] } names only, never values
'credential.denied', // { name, reason?: 'not_granted'|'unresolved' }
'credential.denied', // { name, reason?: 'not_granted'|'unresolved'|'too_short' }
'process.exited', // { code, signal }
// Record kinds with no earlier trace name (docs/record-v1.md has the full data).
'task.claimed', // { leaseUntil? } mirrors the board's claim; the board stays the source of truth
Expand Down
Loading
Loading