Skip to content
2 changes: 2 additions & 0 deletions packages/seats/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,4 +23,6 @@ It is private and is not published on its own. `@commonlyai/cli` depends on it a

The extraction plan is D62, extract-in-place.

**Worktrees.** A run given `ctx.workspace = { repo, base? }` runs in its own detached, locked git worktree under `~/.jellymoon/worktrees/<runId>` (`src/workspace.js`). At settle a clean one is removed and anything else is kept locked, with its path in the result; `sweepWorktrees({ root, isRunLive })` releases the worktrees of runs that died without settling. An adapter whose CLI has its own worktree flag declares `nativeWorktree: true` and gets `ctx.workspace` instead.

**Run record.** The trace store writes each run as a versioned, hash-chained JSONL record. It covers who granted which agent what access for which task, who approved each call, what was attempted, and the result. A tool outside this package can read, verify and export it to OTLP. The format is specified in [`docs/record-v1.md`](docs/record-v1.md), and a generated sample is in [`docs/examples/`](docs/examples/).
16 changes: 13 additions & 3 deletions packages/seats/docs/record-v1.md
Original file line number Diff line number Diff line change
Expand Up @@ -255,10 +255,10 @@ request, and the OTLP export places it there.

| kind | data | default actor |
|---|---|---|
| `attempt.started` | `{attempt, adapter, cli?: {name, version}, sessionId?}` | run |
| `attempt.started` | `{attempt, adapter, cli?: {name, version}, sessionId?, workspace?: {base}}` | run |
| `session.opened` | `{method, sessionId, requestedSessionId?, fallback?}` | `system` |
| `attempt.settled` | `{attempt, outcome, failure?: {kind, detail}}` | `system` |
| `run.settled` | `{outcome, failure?, durationMs, attempts}` | `system` |
| `run.settled` | `{outcome, failure?, durationMs, attempts, workspace?: {base, head, removed, kept?}}` | `system` |
| `harness.switched` | `{from, to, reason}` | `system` |

- `outcome` is `ok`, `failed`, `stuck` or `cancelled`.
Expand All @@ -275,6 +275,14 @@ request, and the OTLP export places it there.
nor session/load`, or `session/<resume|load> answered error <code>`
with the JSON-RPC error's integer code;
- no other field is allowed.
- `workspace` is set when the run had its own git worktree (`src/workspace.js`):
- `base` is the commit SHA it was checked out at;
- `head` is its HEAD when the run settled, or `null` if that could not be read;
- `removed` says whether it was removed at settle;
- `kept` says why not: `dirty` (tracked changes or untracked files),
`committed` (HEAD moved off base), `abandoned` (the adapter ignored the
cancel, so its process may still be running) or `error`.
The worktree's path is not recorded.
- `harness.switched` comes before the `attempt.started` of an attempt that
runs on another entry of the seat's run config than the one the seat was on
(`src/harness-plan.js`):
Expand Down Expand Up @@ -621,7 +629,9 @@ to that commit. `error.type` is Stable.
`seats.attempt.outcome`, `seats.adapter`, `seats.cli.name`,
`seats.usage.context_tokens`, `seats.usage.context_size` (the last
known values in the attempt),
`seats.cli.version`, `seats.agent.owner`, `seats.agent.on_behalf_of.id`,
`seats.cli.version`, `seats.workspace.base` (attempt span, and the run
span with `seats.workspace.head`, `.removed` and `.kept` from
`run.settled`), `seats.agent.owner`, `seats.agent.on_behalf_of.id`,
`seats.agent.on_behalf_of.role`, `seats.tool.kind`,
`seats.tool.surface` and `seats.tool.args_digest`;
- on every event, `seats.seq` and `seats.actor.type`, `.id`, `.owner`,
Expand Down
2 changes: 2 additions & 0 deletions packages/seats/src/contract.js
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@
* @property {() => Promise<{ path: string, version: string } | null>} detect
* @property {(environment: object) => Promise<void>} [validateEnvironment] refuse before spawn
* @property {(prompt: string, ctx: object) => Promise<SpawnResult>} spawn
* @property {boolean} [nativeWorktree] the CLI makes its own git worktree (Claude Code `--worktree`,
* Codex `--worktree`) from ctx.workspace; the supervisor then makes none (workspace.js)
*
* @typedef {object} SpawnResult
* @property {string} text
Expand Down
3 changes: 3 additions & 0 deletions packages/seats/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,3 +20,6 @@ export {
} from './record/format.js';
export { toOtlpJson } from './record/otel.js';
export { superviseRun, SUPERVISOR_DEFAULTS, DEFAULT_RETRY_ON } from './supervisor.js';
export {
prepareWorktree, settleWorktree, sweepWorktrees, usesWorktreeHelper, defaultWorktreeRoot, lockReasonOf,
} from './workspace.js';
3 changes: 3 additions & 0 deletions packages/seats/src/record/otel.js
Original file line number Diff line number Diff line change
Expand Up @@ -148,6 +148,7 @@ const spansOfRun = (entries) => {
['seats.agent.on_behalf_of.id', entry.actor?.onBehalfOf?.id], ['seats.agent.on_behalf_of.role', entry.actor?.onBehalfOf?.role],
['seats.attempt.number', data.attempt], ['seats.adapter', data.adapter],
['seats.cli.name', data.cli?.name], ['seats.cli.version', data.cli?.version],
['seats.workspace.base', data.workspace?.base],
]),
});
spans.push(s);
Expand All @@ -160,6 +161,8 @@ const spansOfRun = (entries) => {
closeAttempt(entry.ts);
} else if (kind === 'run.settled') {
run.attributes.push(...attrs([['seats.run.outcome', data.outcome], ['seats.run.attempts', data.attempts]]));
// The run's own worktree, when it had one: base, head, removed, kept.
if (data.workspace && typeof data.workspace === 'object') run.attributes.push(...flatten(data.workspace, 'seats.workspace'));
if (FAILED_OUTCOMES.has(data.outcome)) {
const errorType = data.failure?.kind || data.outcome;
fail(run, errorType);
Expand Down
39 changes: 36 additions & 3 deletions packages/seats/src/supervisor.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
import { RunFailure, runFailure, failureKindOf, FAILURE_KINDS } from './failures.js';
import { redactKnownCredentials, scrubAdapterFailure, spawnCredentials } from './upstream-refusal.js';
import { killProcessTree, trackSupervisedGroup, OWN_PROCESS_GROUP } from './process-group.js';
import { prepareWorktree, settleWorktree, usesWorktreeHelper } from './workspace.js';

export const SUPERVISOR_DEFAULTS = Object.freeze({
// Longer than the quiet stretches a healthy turn has: a long-context first
Expand Down Expand Up @@ -239,6 +240,9 @@ const runAttempt = ({ adapter, prompt, ctx, run, settings, attempt, sessionId, e
* @param {object} [args.ctx] passed to adapter.spawn; `trace`, `signal` and `onSpawn` are wrapped,
* and `ownProcessGroup` is set to true.
* `ctx.signal`, when given, cancels the run (outcome `cancelled`).
* `ctx.workspace = {repo, base?, root?}` runs the turn in its own git worktree (workspace.js):
* the adapter gets its path as `ctx.cwd` and no `workspace`, unless it declares
* `nativeWorktree`, in which case it gets `workspace` unchanged and makes its own.
* @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
Expand All @@ -253,7 +257,8 @@ const runAttempt = ({ adapter, prompt, ctx, run, settings, attempt, sessionId, e
* @param {object} [args.actor] the run's own party, {type, id, owner?, onBehalfOf?};
* entries the run writes without naming an actor (run.started, tool calls,
* permission requests) record this one instead of null
* @returns {Promise<{ runId: string, outcome: string, attempts: number, result?: object, failure?: Error }>}
* @returns {Promise<{ runId: string, outcome: string, attempts: number, result?: object, failure?: Error,
* workspace?: { path, base, head, removed, kept? } }>}
*/
export const superviseRun = async ({
adapter, prompt, ctx = {}, store, policy = {}, runId, label, taskId, podId, actor,
Expand All @@ -274,12 +279,24 @@ export const superviseRun = async ({
let attempt = 0;
let sessionId = ctx.sessionId || null;
let last = { failure: null };
let worktree = null;
let workspace = null;
let spawnCtx = ctx;

try {
if (usesWorktreeHelper(adapter, ctx)) {
worktree = prepareWorktree({ ...ctx.workspace, runId: run.runId });
const { workspace: _consumed, ...rest } = ctx;
spawnCtx = { ...rest, cwd: worktree.path };
}
for (;;) {
attempt += 1;
run.emit('run.started', { adapter: adapter.name, ...(sessionId ? { sessionId } : {}), attempt });
last = await runAttempt({ adapter, prompt, ctx, run, settings, attempt, sessionId, external });
run.emit('run.started', {
adapter: adapter.name, ...(sessionId ? { sessionId } : {}), attempt, ...(worktree ? { workspace: { base: worktree.base } } : {}),
});
last = await runAttempt({
adapter, prompt, ctx: spawnCtx, 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 });
Expand All @@ -289,6 +306,15 @@ export const superviseRun = async ({
last = { failure: error };
}

// Every verdict but an abandoned run means no process of this run is left,
// so a clean worktree goes now; an abandoned one waits, locked, for the sweep.
if (worktree) {
try {
workspace = settleWorktree(worktree, { abandoned: !!last.failure?.adapterIgnoredCancel });
} catch {
workspace = { path: worktree.path, base: worktree.base, head: null, removed: false, kept: 'error' };
}
}
const outcome = outcomeOf(last.failure);
const credentials = spawnCredentials(ctx, [adapter.providerKeyEnv].filter(Boolean));
try {
Expand All @@ -297,12 +323,19 @@ export const superviseRun = async ({
...(last.failure ? { failure: failureData(last.failure, credentials) } : {}),
durationMs: Date.now() - startedAt,
attempts: attempt,
// The host path stays out of the record; the caller gets it below.
...(workspace ? {
workspace: {
base: workspace.base, head: workspace.head, removed: workspace.removed, ...(workspace.kept ? { kept: workspace.kept } : {}),
},
} : {}),
});
} catch { /* the outcome is still returned */ }
return {
runId: run.runId,
outcome,
attempts: attempt,
...(last.failure ? { failure: last.failure } : { result: last.result }),
...(workspace ? { workspace } : {}),
};
};
Loading
Loading