@cabane/companion 0.6.71 → 0.6.73

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.
Files changed (3) hide show
  1. package/dist/cli.js +502 -355
  2. package/dist/runtime.js +497 -350
  3. package/package.json +1 -1
package/dist/cli.js CHANGED
@@ -7276,10 +7276,13 @@ var ConnectorHealthStore = class {
7276
7276
  };
7277
7277
 
7278
7278
  // src/dispatcher.ts
7279
- import { createHash as createHash2, randomUUID } from "crypto";
7280
- import { existsSync as existsSync10, readdirSync as readdirSync2, statSync } from "fs";
7279
+ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } from "fs";
7281
7280
  import { join as join14 } from "path";
7282
7281
 
7282
+ // src/turn-execution.ts
7283
+ import { createHash as createHash2, randomUUID } from "crypto";
7284
+ import { existsSync as existsSync10 } from "fs";
7285
+
7283
7286
  // src/turn-control-tools.ts
7284
7287
  import { z as z13 } from "zod";
7285
7288
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
@@ -8089,7 +8092,7 @@ var TurnCommitter = class {
8089
8092
  }
8090
8093
  };
8091
8094
 
8092
- // src/dispatcher.ts
8095
+ // src/turn-execution.ts
8093
8096
  var PREPARING_TOOL_NAME = "preparing";
8094
8097
  var PREPARE_FAILED_PREFIX = "**Couldn't prepare your environment.** I wasn't able to provision a working directory for this conversation, so I can't run this turn. The provisioning command reported:";
8095
8098
  var MISSING_SECRET_PREFIX = "**Missing secret on this companion.** This agent's tools need a credential this device hasn't been given, so I can't run this turn safely. Declare it in this companion\u2019s secret store (`~/.cabane/secrets.json`) and try again. Missing:";
@@ -8101,33 +8104,6 @@ var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
8101
8104
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
8102
8105
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
8103
8106
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
8104
- function checkoutState(cwd) {
8105
- if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
8106
- if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
8107
- let entries;
8108
- try {
8109
- entries = readdirSync2(cwd);
8110
- } catch (error) {
8111
- return { ok: false, reason: `${cwd} is unreadable (${error.message})` };
8112
- }
8113
- if (entries.length === 0) {
8114
- return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
8115
- }
8116
- const gitPath = join14(cwd, ".git");
8117
- if (!existsSync10(gitPath)) return { ok: true, reason: "usable" };
8118
- let stat;
8119
- try {
8120
- stat = statSync(gitPath);
8121
- } catch (error) {
8122
- return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
8123
- }
8124
- if (stat.isDirectory() && !existsSync10(join14(gitPath, "HEAD")))
8125
- return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
8126
- return { ok: true, reason: "usable" };
8127
- }
8128
- function runKey(conversationId, agentId) {
8129
- return `${conversationId}|${agentId}`;
8130
- }
8131
8107
  var LEASE_REFUSALS = /* @__PURE__ */ new Set([
8132
8108
  "dispatch_not_admitted",
8133
8109
  "turn_already_ended",
@@ -8162,61 +8138,128 @@ function describeErrorBody(body) {
8162
8138
  if (text.length === 0) return void 0;
8163
8139
  return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
8164
8140
  }
8165
- var Dispatcher = class {
8166
- constructor(opts) {
8141
+ function initialOutcome() {
8142
+ return {
8143
+ sessionWritten: false,
8144
+ sessionDegraded: false,
8145
+ sessionWriteRejected: false,
8146
+ okResult: false,
8147
+ resultReason: void 0,
8148
+ turnUsage: void 0,
8149
+ turnResolvedModel: void 0,
8150
+ turnResolvedConfig: void 0,
8151
+ turnMcpInventory: void 0,
8152
+ eventCounts: { session: 0, text: 0, thinking: 0, tool: 0, result: 0 },
8153
+ runtimeResultKind: null,
8154
+ contentBearingEvents: 0,
8155
+ latestSessionState: null,
8156
+ settledDiagnostics: null,
8157
+ silentMarkerEmitted: false,
8158
+ timeoutReason: null,
8159
+ leaseLost: false
8160
+ };
8161
+ }
8162
+ var TurnConcluded = class {
8163
+ constructor(reason, errorReason) {
8164
+ this.reason = reason;
8165
+ this.errorReason = errorReason;
8166
+ }
8167
+ reason;
8168
+ errorReason;
8169
+ };
8170
+ var TurnExecution = class {
8171
+ constructor(opts, supervisor, payload, handleOpts = {}) {
8167
8172
  this.opts = opts;
8168
- this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
8173
+ this.supervisor = supervisor;
8174
+ this.payload = payload;
8175
+ this.dispatchId = payload.messageId;
8176
+ this.workspaceId = opts.workspaceId;
8177
+ this.turnLog = opts.log.child({
8178
+ workspaceId: opts.workspaceId,
8179
+ conversationId: payload.conversationId,
8180
+ agentId: payload.agentId,
8181
+ messageId: payload.messageId
8182
+ });
8183
+ this.turnId = handleOpts.turnId ?? randomUUID();
8169
8184
  }
8170
8185
  opts;
8171
- // SJ383: per-(conversation, agent) abort registry.
8172
- // CT1288: RunKey -> (turnId -> controller). This used to be one controller
8173
- // per (conversation, agent), which quietly encoded the invariant the whole
8174
- // task exists to enforce: that a pair can only ever have one live loop. When
8175
- // that assumption broke, the second `set` EVICTED the first controller and
8176
- // the first loop became permanently uncancellable — no other code path can
8177
- // reach into a running turn. So the registry that Stop depends on failed
8178
- // exactly when Stop was the thing you needed.
8179
- //
8180
- // Nesting by turn id costs nothing in the normal single-turn case and makes
8181
- // `cancel` total: it aborts every loop under the pair, not the newest one.
8182
- aborts;
8183
- // CT1109: pairs already told, in the conversation, that their session state is
8184
- // being refused. The failure repeats every single turn until someone fixes the
8185
- // payload, so without this the notice would be a per-turn drumbeat; one notice
8186
- // is the signal and the rest is noise. Deliberately in-memory and unbounded-free:
8187
- // a companion restart re-arms it, which is the behaviour we want — a restart is
8188
- // exactly when it's worth re-stating that memory is still being lost.
8189
- sessionWriteNotified = /* @__PURE__ */ new Set();
8190
- notifyStart(info) {
8191
- try {
8192
- this.opts.observer?.onStart(info);
8193
- } catch {
8194
- }
8195
- }
8196
- notifyEnd(info) {
8186
+ supervisor;
8187
+ payload;
8188
+ startedAt = Date.now();
8189
+ turnId;
8190
+ dispatchId;
8191
+ workspaceId;
8192
+ turnLog;
8193
+ abortController = new AbortController();
8194
+ outcome = initialOutcome();
8195
+ seqCounter = 0;
8196
+ // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
8197
+ nextSeq = () => ++this.seqCounter;
8198
+ turnContext;
8199
+ resolvedMcpServers;
8200
+ effectiveCwd;
8201
+ hookEnv;
8202
+ turnEnv;
8203
+ sendState;
8204
+ skipState;
8205
+ askState;
8206
+ wakeState;
8207
+ replyState;
8208
+ controlOrder;
8209
+ turnControlServer;
8210
+ request;
8211
+ adapter;
8212
+ committer = null;
8213
+ transcript = null;
8214
+ turnControlIntentFetched = false;
8215
+ applyRecordedTurnControlIntent = async () => {
8216
+ };
8217
+ // CT1292: the committer is built before the lease watchdog arms, so the
8218
+ // failed-commit hint reaches it through this slot. Safe as a no-op until the
8219
+ // watchdog replaces it — the committer only fires it from inside the adapter
8220
+ // loop, which runs after.
8221
+ noteCommitFailed = () => {
8222
+ };
8223
+ disarmWatchdogs = () => {
8224
+ };
8225
+ // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
8226
+ // PATCH returns — the mailroom now holds this turn as the conversation's
8227
+ // running one, so every exit after this point must settle THE TURN, not just
8228
+ // clear the participant flag.
8229
+ admitted = false;
8230
+ concluded(reason, errorReason) {
8231
+ return new TurnConcluded(reason, errorReason);
8232
+ }
8233
+ // The arc. Phases in order; every pre-loop exit throws `TurnConcluded` and
8234
+ // funnels through ONE conclusion (hand back the controller — idempotent and
8235
+ // identity-checked at the supervisor — then clear the active-run flag);
8236
+ // everything past the lease settles in `execute()`'s one `finally`.
8237
+ async run() {
8197
8238
  try {
8198
- this.opts.observer?.onEnd(info);
8199
- } catch {
8239
+ await this.fetchContext();
8240
+ this.gateTrigger();
8241
+ await this.resolveSecrets();
8242
+ await this.prepareEnvironment();
8243
+ this.buildRequest();
8244
+ await this.acquireLease();
8245
+ await this.selectAdapter();
8246
+ } catch (err) {
8247
+ if (err instanceof TurnConcluded) {
8248
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
8249
+ return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
8250
+ }
8251
+ if (this.admitted) {
8252
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
8253
+ const msg = err instanceof Error ? err.message : String(err);
8254
+ await this.concludeAdmittedRun("setup_failed", `setup_failed: ${msg}`);
8255
+ }
8256
+ throw err;
8200
8257
  }
8258
+ await this.execute();
8259
+ return this.report();
8201
8260
  }
8202
- // CT138: shared pre-run teardown for every early-return that happens BEFORE
8203
- // the active-run flag is flipped (the `setActiveRun` working-flip below).
8204
- // The server lights the "X is replying…" indicator eagerly at dispatch
8205
- // (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
8206
- // clear it — the SJ383 `finally` after the SDK loop is the one clear, and
8207
- // every pre-run exit returns before reaching it. So each pre-run failure has
8208
- // to clear `active_run_started_at` itself, mirroring that `finally`, or the
8209
- // indicator strands until the 12h age sweep.
8210
- //
8211
- // `errorReason` controls the server's duplicate-notice rule (the active-run
8212
- // PATCH handler in conversations.ts): a clear carrying `errorReason` makes the
8213
- // server post a `role:'system'` "X's reply failed: …" notice. Exits that
8214
- // already posted their own user-facing `final` (prepare-failed, missing-secret)
8215
- // pass NO errorReason, so the user doesn't see a second failure message; exits
8216
- // that posted nothing pass `errorReason` so the user still gets a notice. The
8217
- // clear is durable (CT93 outbox, last-writer-wins per pair), so a terminal
8218
- // failure here just logs and the server age-sweep backstops it.
8219
- async concludeBeforeRun(payload, turnLog, startedAt, reason, errorReason) {
8261
+ async concludeBeforeRun(reason, errorReason) {
8262
+ const { payload, turnLog, startedAt } = this;
8220
8263
  const body = {
8221
8264
  activeRunStartedAt: null,
8222
8265
  // CT1046: this dispatch is over before it ran — retire its evidence so it
@@ -8238,25 +8281,47 @@ var Dispatcher = class {
8238
8281
  );
8239
8282
  }
8240
8283
  const durationMs = Date.now() - startedAt;
8241
- this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8284
+ this.supervisor.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8242
8285
  return { ok: false, durationMs, reason };
8243
8286
  }
8244
- // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
8245
- // under before the companion died. Passing it makes the replay ask the
8246
- // mailroom to re-admit the turn it already owns, which the server explicitly
8247
- // supports ("a replay reports, it never re-points"). Omitted on a fresh
8248
- // dispatch, where minting below is correct.
8249
- async handle(payload, opts = {}) {
8250
- const startedAt = Date.now();
8251
- const dispatchId = payload.messageId;
8252
- const workspaceId = this.opts.workspaceId;
8253
- const turnLog = this.opts.log.child({
8254
- workspaceId,
8255
- conversationId: payload.conversationId,
8256
- agentId: payload.agentId,
8257
- messageId: payload.messageId
8258
- });
8259
- const turnId = opts.turnId ?? randomUUID();
8287
+ // The post-admission analogue of `concludeBeforeRun` (Codo's stack review,
8288
+ // blocking finding #2): this turn WAS admitted the mailroom holds it as
8289
+ // the conversation's running turn so the clear must NAME the turn and
8290
+ // DECLARE its terminal outcome. A bare pre-run clear here would reset the
8291
+ // participant flag while the turn metadata stayed running, the dispatch
8292
+ // stayed live, and the queue stayed held until the reaper. `outcome`
8293
+ // follows the settle wire's rule: a failure names its reason; without one
8294
+ // the turn settled — the runtime-unavailable exit posts its user-facing
8295
+ // `final` before concluding, so the turn genuinely produced its terminal
8296
+ // output and `settled` releases the queue honestly.
8297
+ async concludeAdmittedRun(reason, errorReason) {
8298
+ const { payload, turnLog, startedAt, turnId } = this;
8299
+ const body = {
8300
+ activeRunStartedAt: null,
8301
+ turnId,
8302
+ settledMessageId: payload.messageId,
8303
+ outcome: errorReason ? "failed" : "settled"
8304
+ };
8305
+ if (errorReason) body.errorReason = errorReason.slice(0, 200);
8306
+ try {
8307
+ await this.opts.api.setActiveRun(
8308
+ this.opts.workspaceId,
8309
+ payload.conversationId,
8310
+ payload.agentId,
8311
+ body
8312
+ );
8313
+ } catch (err) {
8314
+ turnLog.warn(
8315
+ { err: err instanceof Error ? err.message : String(err), turnId },
8316
+ "dispatcher: admitted-run settle failed terminally; server age-sweep is the backstop"
8317
+ );
8318
+ }
8319
+ const durationMs = Date.now() - startedAt;
8320
+ this.supervisor.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
8321
+ return { ok: false, durationMs, reason };
8322
+ }
8323
+ async fetchContext() {
8324
+ const { payload, turnId, turnLog } = this;
8260
8325
  let turnContext;
8261
8326
  try {
8262
8327
  turnContext = await this.opts.api.getTurnContext(
@@ -8271,35 +8336,36 @@ var Dispatcher = class {
8271
8336
  { err: err instanceof Error ? err.message : String(err) },
8272
8337
  "dispatcher: turn-context 404 (stale event \u2014 conversation/message/participant gone); skipping"
8273
8338
  );
8274
- return this.concludeBeforeRun(payload, turnLog, startedAt, "turn_context_not_found");
8339
+ throw this.concluded("turn_context_not_found");
8275
8340
  }
8276
8341
  const reason = err instanceof Error ? err.message : String(err);
8277
8342
  turnLog.error({ err: reason }, "dispatcher: failed to fetch turn context");
8278
8343
  const fetchReason = `fetch_failed: ${reason}`;
8279
- return this.concludeBeforeRun(payload, turnLog, startedAt, fetchReason, fetchReason);
8344
+ throw this.concluded(fetchReason, fetchReason);
8280
8345
  }
8281
- const message = turnContext.message;
8346
+ this.turnContext = turnContext;
8347
+ }
8348
+ gateTrigger() {
8349
+ const { payload, turnLog } = this;
8350
+ const message = this.turnContext.message;
8282
8351
  const isDispatchableTrigger = message.role === "user" || message.role === "agent" || message.role === "system" && message.hasPrimaryDispatch === true;
8283
8352
  if (!isDispatchableTrigger) {
8284
8353
  turnLog.warn({ role: message.role }, "dispatcher: trigger role not dispatchable \u2014 skipping");
8285
- return this.concludeBeforeRun(
8286
- payload,
8287
- turnLog,
8288
- startedAt,
8289
- "unexpected_role",
8290
- UNEXPECTED_ROLE_REASON
8291
- );
8354
+ throw this.concluded("unexpected_role", UNEXPECTED_ROLE_REASON);
8292
8355
  }
8293
- this.notifyStart({
8294
- id: dispatchId,
8356
+ this.supervisor.notifyStart({
8357
+ id: this.dispatchId,
8295
8358
  conversationId: payload.conversationId,
8296
8359
  message: message.body
8297
8360
  });
8361
+ }
8362
+ async resolveSecrets() {
8363
+ const { payload, workspaceId, turnLog } = this;
8298
8364
  const secretStore = this.opts.secretStore ?? loadSecretStoreTolerant((m) => turnLog.warn(m));
8299
8365
  const { servers: resolvedMcpServers, missing } = resolveMcpSecrets(
8300
8366
  // CT262: the user MCP DEFINITIONS (placeholder form) come from the turn
8301
8367
  // context now, not a separate `getAgentSelf` run-config fetch.
8302
- turnContext.mcpServers ?? {},
8368
+ this.turnContext.mcpServers ?? {},
8303
8369
  secretStore
8304
8370
  );
8305
8371
  if (missing.length > 0) {
@@ -8320,13 +8386,15 @@ var Dispatcher = class {
8320
8386
  );
8321
8387
  }
8322
8388
  const reason = `missing_secret: ${missing.join(",")}`;
8323
- return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
8389
+ throw this.concluded(reason);
8324
8390
  }
8391
+ this.resolvedMcpServers = resolvedMcpServers;
8392
+ }
8393
+ async prepareEnvironment() {
8394
+ const { payload, workspaceId, turnId, turnLog } = this;
8325
8395
  const localCwd = this.opts.local.cwd;
8326
8396
  const prepareHook = this.opts.local.prepareHook;
8327
- const cabaneCwd = turnContext.cwd;
8328
- let seqCounter = 0;
8329
- const nextSeq = () => ++seqCounter;
8397
+ const cabaneCwd = this.turnContext.cwd;
8330
8398
  let effectiveCwd = localCwd ?? cabaneCwd;
8331
8399
  if (effectiveCwd && !existsSync10(effectiveCwd)) {
8332
8400
  turnLog.warn(
@@ -8336,9 +8404,9 @@ var Dispatcher = class {
8336
8404
  effectiveCwd = void 0;
8337
8405
  }
8338
8406
  let hookEnv;
8339
- const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
8340
- const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
8341
- dispatch: turnContext.dispatchedByAgentId,
8407
+ const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
8408
+ const prepareFailureDispatch = this.turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
8409
+ dispatch: this.turnContext.dispatchedByAgentId,
8342
8410
  dispatchBody: `${PREPARE_FAILED_PREFIX}
8343
8411
 
8344
8412
  The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
@@ -8364,11 +8432,11 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
8364
8432
  conversationId: payload.conversationId,
8365
8433
  agentId: payload.agentId,
8366
8434
  agentUsername: this.opts.agentUsername,
8367
- runtime: turnContext.runtime,
8368
- hostAccess: turnContext.policy.hostFs,
8369
- triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
8370
- title: turnContext.conversation.title,
8371
- messageBody: message.body,
8435
+ runtime: this.turnContext.runtime,
8436
+ hostAccess: this.turnContext.policy.hostFs,
8437
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
8438
+ title: this.turnContext.conversation.title,
8439
+ messageBody: this.turnContext.message.body,
8372
8440
  prepared: cached2
8373
8441
  });
8374
8442
  } catch (err) {
@@ -8390,7 +8458,7 @@ ${reason}`,
8390
8458
  "dispatcher: prepare-rejection post failed"
8391
8459
  );
8392
8460
  }
8393
- return this.concludeBeforeRun(payload, turnLog, startedAt, `prepare_failed: ${reason}`);
8461
+ throw this.concluded(`prepare_failed: ${reason}`);
8394
8462
  }
8395
8463
  }
8396
8464
  } else {
@@ -8398,7 +8466,7 @@ ${reason}`,
8398
8466
  let preparingStarted = false;
8399
8467
  let preparingSeq = null;
8400
8468
  const reportPreparing = (phase) => {
8401
- if (preparingSeq === null) preparingSeq = nextSeq();
8469
+ if (preparingSeq === null) preparingSeq = this.nextSeq();
8402
8470
  const seq = preparingSeq;
8403
8471
  void this.opts.api.reportActivity(workspaceId, payload.conversationId, payload.agentId, {
8404
8472
  turnId,
@@ -8426,18 +8494,18 @@ ${reason}`,
8426
8494
  conversationId: payload.conversationId,
8427
8495
  agentId: payload.agentId,
8428
8496
  agentUsername: this.opts.agentUsername,
8429
- runtime: turnContext.runtime,
8430
- hostAccess: turnContext.policy.hostFs,
8497
+ runtime: this.turnContext.runtime,
8498
+ hostAccess: this.turnContext.policy.hostFs,
8431
8499
  // CT317/CT319: the trigger message's referenced-entry paths — what the
8432
8500
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
8433
8501
  // an older API. The conversation anchor is gone (CT319).
8434
- triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
8435
- title: turnContext.conversation.title,
8502
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
8503
+ title: this.turnContext.conversation.title,
8436
8504
  // CT943: the dispatching message's text — where an `env:` directive
8437
8505
  // rides. The server has always sent the trigger body on the turn
8438
8506
  // context (for the live feed); this is the first thing to read it as
8439
8507
  // an INPUT, so a dispatch can ask for its environment in words.
8440
- messageBody: message.body
8508
+ messageBody: this.turnContext.message.body
8441
8509
  });
8442
8510
  clearTimeout(preparingTimer);
8443
8511
  if (preparingStarted) reportPreparing("done");
@@ -8467,31 +8535,23 @@ ${reason}`,
8467
8535
  );
8468
8536
  }
8469
8537
  const failReason = `prepare_failed: ${reason}`;
8470
- return this.concludeBeforeRun(payload, turnLog, startedAt, failReason);
8538
+ throw this.concluded(failReason);
8471
8539
  }
8472
8540
  }
8473
8541
  }
8474
8542
  const turnEnv = {
8475
8543
  ...hookEnv,
8476
- CABANE_HOST_ACCESS: turnContext.policy.hostFs ? "1" : "0",
8544
+ CABANE_HOST_ACCESS: this.turnContext.policy.hostFs ? "1" : "0",
8477
8545
  CABANE_COMPANION_HOME: agentCompanionHome()
8478
8546
  };
8479
- const key = runKey(payload.conversationId, payload.agentId);
8480
- const abortController = new AbortController();
8481
- let pairAborts = this.aborts.get(key);
8482
- if (!pairAborts) {
8483
- pairAborts = /* @__PURE__ */ new Map();
8484
- this.aborts.set(key, pairAborts);
8485
- }
8486
- pairAborts.set(turnId, abortController);
8487
- const releaseAbort = () => {
8488
- const pair2 = this.aborts.get(key);
8489
- if (pair2?.get(turnId) !== abortController) return;
8490
- pair2.delete(turnId);
8491
- if (pair2.size === 0) this.aborts.delete(key);
8492
- };
8493
- let timeoutReason = null;
8494
- let leaseLost = false;
8547
+ this.effectiveCwd = effectiveCwd;
8548
+ this.hookEnv = hookEnv;
8549
+ this.turnEnv = turnEnv;
8550
+ }
8551
+ async acquireLease() {
8552
+ const { payload, workspaceId, turnId, turnLog } = this;
8553
+ const abortController = this.abortController;
8554
+ this.supervisor.registerAbort(turnId, abortController);
8495
8555
  try {
8496
8556
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
8497
8557
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8504,27 +8564,30 @@ ${reason}`,
8504
8564
  });
8505
8565
  } catch (err) {
8506
8566
  const refusal = leaseRefusal(err);
8507
- releaseAbort();
8508
8567
  if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
8509
8568
  turnLog.error(
8510
8569
  { refusal, turnId },
8511
8570
  "dispatcher: refused a turn lease; not running the model"
8512
8571
  );
8513
- return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
8572
+ throw this.concluded(`lease_refused: ${refusal}`);
8514
8573
  }
8515
8574
  const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
8516
8575
  turnLog.error(
8517
8576
  { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
8518
8577
  "dispatcher: turn lease not confirmed; not running the model"
8519
8578
  );
8520
- return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
8521
- }
8522
- const sendState = createSendState();
8523
- const skipState = createSkipState();
8524
- const askState = createAskState();
8525
- const wakeState = createWakeState();
8526
- const replyState = createReplyState();
8527
- const controlOrder = createTurnControlOrder();
8579
+ throw this.concluded(reason, reason);
8580
+ }
8581
+ this.admitted = true;
8582
+ }
8583
+ buildRequest() {
8584
+ const { payload, workspaceId } = this;
8585
+ const sendState = this.sendState = createSendState();
8586
+ const skipState = this.skipState = createSkipState();
8587
+ const askState = this.askState = createAskState();
8588
+ const wakeState = this.wakeState = createWakeState();
8589
+ const replyState = this.replyState = createReplyState();
8590
+ const controlOrder = this.controlOrder = createTurnControlOrder();
8528
8591
  const turnControlServer = createTurnControlMcpServer(
8529
8592
  sendState,
8530
8593
  skipState,
@@ -8533,18 +8596,18 @@ ${reason}`,
8533
8596
  replyState,
8534
8597
  controlOrder
8535
8598
  );
8536
- const request = buildCompanionTurnRequest({
8537
- turnContext,
8599
+ this.request = buildCompanionTurnRequest({
8600
+ turnContext: this.turnContext,
8538
8601
  baseUrl: this.opts.baseUrl,
8539
8602
  agentPat: this.opts.credential,
8540
8603
  // CT306: the per-turn OBO credential when the API minted one; falls back to
8541
8604
  // the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
8542
- ...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
8605
+ ...this.turnContext.turnToken ? { turnToken: this.turnContext.turnToken } : {},
8543
8606
  // SJ524: the hook-resolved cwd overrides the static local cwd.
8544
- ...effectiveCwd ? { cwd: effectiveCwd } : {},
8607
+ ...this.effectiveCwd ? { cwd: this.effectiveCwd } : {},
8545
8608
  // CT1103: the prepare-hook env plus the authoritative host-access token.
8546
- env: turnEnv,
8547
- mcpServers: resolvedMcpServers,
8609
+ env: this.turnEnv,
8610
+ mcpServers: this.resolvedMcpServers,
8548
8611
  turnControlServer,
8549
8612
  // CT238: this turn's conversation, forwarded as the active-conversation
8550
8613
  // header so a cross-thread post/spawn stamps its origin.
@@ -8555,6 +8618,9 @@ ${reason}`,
8555
8618
  // CT289: the operator's auto-memory escape hatch (machine-local), when set.
8556
8619
  ...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
8557
8620
  });
8621
+ }
8622
+ async selectAdapter() {
8623
+ const { payload, workspaceId, turnId, turnLog } = this;
8558
8624
  const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
8559
8625
  const adapters = [];
8560
8626
  if (this.opts.claudeCodeAvailable?.() ?? true) {
@@ -8573,9 +8639,8 @@ ${reason}`,
8573
8639
  );
8574
8640
  }
8575
8641
  const registry = createAdapterRegistry(adapters);
8576
- let adapter;
8577
8642
  try {
8578
- adapter = selectAdapter(registry, turnContext.runtime);
8643
+ this.adapter = selectAdapter(registry, this.turnContext.runtime);
8579
8644
  } catch (err) {
8580
8645
  if (!(err instanceof RuntimeUnavailableError)) throw err;
8581
8646
  turnLog.error(
@@ -8594,17 +8659,18 @@ ${reason}`,
8594
8659
  { err: postErr instanceof Error ? postErr.message : String(postErr) },
8595
8660
  "dispatcher: runtime-unavailable notice post failed"
8596
8661
  );
8662
+ const reason = `runtime_unavailable:${err.runtime}`;
8663
+ throw this.concluded(reason, reason);
8597
8664
  }
8598
- releaseAbort();
8599
- return this.concludeBeforeRun(
8600
- payload,
8601
- turnLog,
8602
- startedAt,
8603
- `runtime_unavailable:${err.runtime}`
8604
- );
8665
+ throw this.concluded(`runtime_unavailable:${err.runtime}`);
8605
8666
  }
8667
+ }
8668
+ async execute() {
8669
+ const { payload, workspaceId, turnId, turnLog, startedAt } = this;
8670
+ const o = this.outcome;
8671
+ const abortController = this.abortController;
8606
8672
  const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
8607
- const transcript2 = this.opts.transcriptDir ? new TranscriptWriter(
8673
+ const transcript2 = this.transcript = this.opts.transcriptDir ? new TranscriptWriter(
8608
8674
  this.opts.transcriptDir,
8609
8675
  {
8610
8676
  ts: new Date(startedAt).toISOString(),
@@ -8612,36 +8678,13 @@ ${reason}`,
8612
8678
  workspaceId,
8613
8679
  conversationId: payload.conversationId,
8614
8680
  agentId: payload.agentId,
8615
- dispatchId,
8616
- message: message.body
8681
+ dispatchId: this.dispatchId,
8682
+ message: this.turnContext.message.body
8617
8683
  },
8618
8684
  (m) => turnLog.warn(m)
8619
8685
  ) : null;
8620
- let sessionWritten = false;
8621
- let sessionDegraded = false;
8622
- let sessionWriteRejected = false;
8623
- let okResult = false;
8624
- let resultReason;
8625
- const turnRuntime = turnContext.runtime;
8626
- let turnUsage;
8627
- let turnResolvedModel;
8628
- let turnResolvedConfig;
8629
- let turnMcpInventory;
8630
- const eventCounts = {
8631
- session: 0,
8632
- text: 0,
8633
- thinking: 0,
8634
- tool: 0,
8635
- result: 0
8636
- };
8637
- let runtimeResultKind = null;
8638
- let contentBearingEvents = 0;
8639
- let latestSessionState = request.session;
8640
- let settledDiagnostics = null;
8641
- let silentMarkerEmitted = false;
8642
- let noteCommitFailed = () => {
8643
- };
8644
- const committer = new TurnCommitter({
8686
+ const turnRuntime = this.turnContext.runtime;
8687
+ const committer = this.committer = new TurnCommitter({
8645
8688
  api: this.opts.api,
8646
8689
  workspaceId,
8647
8690
  conversationId: payload.conversationId,
@@ -8652,29 +8695,32 @@ ${reason}`,
8652
8695
  parentMessageId: payload.messageId,
8653
8696
  signal: abortController.signal,
8654
8697
  log: turnLog,
8655
- nextSeq,
8698
+ nextSeq: this.nextSeq,
8656
8699
  // The committer reads this at commit to carry the addressed send on the
8657
8700
  // terminal row; the server writes the send itself as a distinct message.
8658
- sendState,
8701
+ sendState: this.sendState,
8659
8702
  // CT326: likewise the ask payload. CT1281: the server writes the ask as its
8660
8703
  // own addressed message to the human — which ENQUEUES like any other send —
8661
8704
  // and creates the `asks` row against that carrier, not against turn speech.
8662
- askState,
8705
+ askState: this.askState,
8663
8706
  // CT442: likewise the wake payload — attached to the `final` row so the
8664
8707
  // server arms the wake schedule atomically with the reply it rode on.
8665
- wakeState,
8666
- replyState,
8708
+ wakeState: this.wakeState,
8709
+ replyState: this.replyState,
8667
8710
  // The ledger-derived owed reply, for the runtime's own declaration when
8668
8711
  // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
8669
- owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
8712
+ owedReplyMessageId: this.turnContext.owedReplyMessageId ?? null,
8670
8713
  // CT1292: a commit that didn't land may mean this turn's lease is gone.
8671
- onCommitFailed: (err) => noteCommitFailed(err)
8714
+ // `noteCommitFailed` is a field slot the lease watchdog fills when it
8715
+ // arms (it starts as a no-op) — the committer only fires it from inside
8716
+ // the adapter loop, which runs after the watchdog has replaced it.
8717
+ onCommitFailed: (err) => this.noteCommitFailed(err)
8672
8718
  });
8673
8719
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8674
- let turnControlIntentFetched = false;
8675
- const applyRecordedTurnControlIntent = async () => {
8676
- if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
8677
- turnControlIntentFetched = true;
8720
+ this.applyRecordedTurnControlIntent = async () => {
8721
+ if (this.turnControlIntentFetched || !usesHttpTurnControl || !this.turnContext.turnToken)
8722
+ return;
8723
+ this.turnControlIntentFetched = true;
8678
8724
  try {
8679
8725
  const intent = await this.opts.api.getTurnIntent(
8680
8726
  workspaceId,
@@ -8683,44 +8729,44 @@ ${reason}`,
8683
8729
  turnId
8684
8730
  );
8685
8731
  if (intent.ask) {
8686
- askState.targetUserId = intent.ask.targetUserId;
8732
+ this.askState.targetUserId = intent.ask.targetUserId;
8687
8733
  if (intent.ask.questions && intent.ask.questions.length > 0) {
8688
- askState.questions = intent.ask.questions;
8689
- askState.question = null;
8690
- askState.headline = null;
8691
- askState.options = null;
8734
+ this.askState.questions = intent.ask.questions;
8735
+ this.askState.question = null;
8736
+ this.askState.headline = null;
8737
+ this.askState.options = null;
8692
8738
  } else {
8693
- askState.question = intent.ask.question ?? null;
8694
- askState.headline = intent.ask.headline ?? null;
8695
- askState.options = intent.ask.options ?? null;
8696
- askState.questions = null;
8739
+ this.askState.question = intent.ask.question ?? null;
8740
+ this.askState.headline = intent.ask.headline ?? null;
8741
+ this.askState.options = intent.ask.options ?? null;
8742
+ this.askState.questions = null;
8697
8743
  }
8698
8744
  }
8699
8745
  if (intent.wake) {
8700
8746
  if ("cancel" in intent.wake) {
8701
- wakeState.cancelled = true;
8702
- wakeState.afterSeconds = null;
8703
- wakeState.at = null;
8704
- wakeState.note = null;
8747
+ this.wakeState.cancelled = true;
8748
+ this.wakeState.afterSeconds = null;
8749
+ this.wakeState.at = null;
8750
+ this.wakeState.note = null;
8705
8751
  } else {
8706
- wakeState.cancelled = false;
8707
- wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
8708
- wakeState.at = intent.wake.at ?? null;
8709
- wakeState.note = intent.wake.note;
8752
+ this.wakeState.cancelled = false;
8753
+ this.wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
8754
+ this.wakeState.at = intent.wake.at ?? null;
8755
+ this.wakeState.note = intent.wake.note;
8710
8756
  }
8711
8757
  }
8712
8758
  if (intent.sendAgentId && intent.sendBody) {
8713
- sendState.agentId = intent.sendAgentId;
8714
- sendState.message = intent.sendBody;
8715
- sendState.order = intent.sendOrder ?? null;
8759
+ this.sendState.agentId = intent.sendAgentId;
8760
+ this.sendState.message = intent.sendBody;
8761
+ this.sendState.order = intent.sendOrder ?? null;
8716
8762
  }
8717
8763
  if (intent.answersMessageId) {
8718
- replyState.answersMessageId = intent.answersMessageId;
8719
- replyState.order = intent.replyOrder ?? null;
8764
+ this.replyState.answersMessageId = intent.answersMessageId;
8765
+ this.replyState.order = intent.replyOrder ?? null;
8720
8766
  }
8721
8767
  if (intent.skipped) {
8722
- skipState.skipped = true;
8723
- skipState.reason = intent.skipReason;
8768
+ this.skipState.skipped = true;
8769
+ this.skipState.reason = intent.skipReason;
8724
8770
  }
8725
8771
  } catch (err) {
8726
8772
  turnLog.warn(
@@ -8732,7 +8778,7 @@ ${reason}`,
8732
8778
  const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
8733
8779
  const fireTimeout = (reason) => {
8734
8780
  if (abortController.signal.aborted) return;
8735
- timeoutReason = reason;
8781
+ o.timeoutReason = reason;
8736
8782
  turnLog.warn(
8737
8783
  { reason, idleTimeoutMs, totalTimeoutMs },
8738
8784
  "dispatcher: turn timeout \u2014 aborting"
@@ -8751,7 +8797,7 @@ ${reason}`,
8751
8797
  const leaseRenewalMs = this.opts.leaseRenewalMs ?? DEFAULT_LEASE_RENEWAL_MS;
8752
8798
  let leaseCheckInFlight = false;
8753
8799
  const checkLease = async (trigger) => {
8754
- if (leaseLost || leaseCheckInFlight || abortController.signal.aborted) return;
8800
+ if (o.leaseLost || leaseCheckInFlight || abortController.signal.aborted) return;
8755
8801
  leaseCheckInFlight = true;
8756
8802
  try {
8757
8803
  const state = await this.opts.api.checkTurnLease(
@@ -8762,7 +8808,7 @@ ${reason}`,
8762
8808
  );
8763
8809
  if (state !== "ended") return;
8764
8810
  if (abortController.signal.aborted) return;
8765
- leaseLost = true;
8811
+ o.leaseLost = true;
8766
8812
  turnLog.warn(
8767
8813
  { turnId, trigger },
8768
8814
  "dispatcher: this turn is no longer running server-side \u2014 aborting the loop"
@@ -8774,26 +8820,31 @@ ${reason}`,
8774
8820
  };
8775
8821
  const leaseTimer = setInterval(() => void checkLease("cadence"), leaseRenewalMs);
8776
8822
  leaseTimer.unref?.();
8777
- noteCommitFailed = (err) => {
8823
+ this.noteCommitFailed = (err) => {
8778
8824
  if (isWriteFenceRefusal(err)) void checkLease("commit_refused");
8779
8825
  };
8826
+ this.disarmWatchdogs = () => {
8827
+ if (idleTimer) clearTimeout(idleTimer);
8828
+ clearTimeout(totalTimer);
8829
+ clearInterval(leaseTimer);
8830
+ };
8780
8831
  try {
8781
- for await (const event of adapter.runTurn(request, abortController.signal)) {
8832
+ for await (const event of this.adapter.runTurn(this.request, abortController.signal)) {
8782
8833
  transcript2?.write(event);
8783
- eventCounts[event.type] += 1;
8784
- if (isContentBearingEvent(event)) contentBearingEvents += 1;
8834
+ o.eventCounts[event.type] += 1;
8835
+ if (isContentBearingEvent(event)) o.contentBearingEvents += 1;
8785
8836
  armIdle();
8786
8837
  if (abortController.signal.aborted) {
8787
8838
  turnLog.info("dispatcher: aborted mid-turn");
8788
- okResult = false;
8789
- resultReason = timeoutReason ?? "cancelled";
8839
+ o.okResult = false;
8840
+ o.resultReason = o.timeoutReason ?? "cancelled";
8790
8841
  break;
8791
8842
  }
8792
8843
  if (event.type === "session") {
8793
- latestSessionState = event.state;
8794
- if (event.degraded) sessionDegraded = true;
8795
- if (!sessionWritten) {
8796
- sessionWritten = true;
8844
+ o.latestSessionState = event.state;
8845
+ if (event.degraded) o.sessionDegraded = true;
8846
+ if (!o.sessionWritten) {
8847
+ o.sessionWritten = true;
8797
8848
  try {
8798
8849
  await this.opts.api.setActiveRun(
8799
8850
  workspaceId,
@@ -8804,7 +8855,7 @@ ${reason}`,
8804
8855
  } catch (err) {
8805
8856
  const status2 = err instanceof ApiError ? err.status : void 0;
8806
8857
  if (status2 !== void 0 && status2 >= 400 && status2 < 500) {
8807
- sessionWriteRejected = true;
8858
+ o.sessionWriteRejected = true;
8808
8859
  turnLog.error(
8809
8860
  {
8810
8861
  err: err instanceof Error ? err.message : String(err),
@@ -8824,48 +8875,52 @@ ${reason}`,
8824
8875
  }
8825
8876
  }
8826
8877
  } else if (event.type === "result") {
8827
- okResult = event.ok;
8828
- resultReason = event.reason;
8829
- turnUsage = event.usage;
8830
- turnResolvedModel = event.resolvedModel;
8831
- turnResolvedConfig = event.resolvedConfig;
8832
- turnMcpInventory = event.mcpInventory;
8833
- runtimeResultKind = event.ok ? "success" : event.reason === "no_terminal" ? "no_terminal" : "error";
8834
- } else if (event.type === "text" && skipState.skipped) {
8878
+ o.okResult = event.ok;
8879
+ o.resultReason = event.reason;
8880
+ o.turnUsage = event.usage;
8881
+ o.turnResolvedModel = event.resolvedModel;
8882
+ o.turnResolvedConfig = event.resolvedConfig;
8883
+ o.turnMcpInventory = event.mcpInventory;
8884
+ o.runtimeResultKind = event.ok ? "success" : event.reason === "no_terminal" ? "no_terminal" : "error";
8885
+ } else if (event.type === "text" && this.skipState.skipped) {
8835
8886
  } else {
8836
8887
  if (event.type === "text" && event.terminal) {
8837
- await applyRecordedTurnControlIntent();
8888
+ await this.applyRecordedTurnControlIntent();
8838
8889
  }
8839
8890
  await committer.ingestEvent(event);
8840
8891
  }
8841
8892
  }
8842
8893
  if (abortController.signal.aborted) {
8843
- okResult = false;
8844
- resultReason = leaseLost ? "lease_lost" : timeoutReason ?? "cancelled";
8894
+ o.okResult = false;
8895
+ o.resultReason = o.leaseLost ? "lease_lost" : o.timeoutReason ?? "cancelled";
8845
8896
  }
8846
- const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
8897
+ const emptyResultReason = !this.skipState.skipped && classifyEmptyResult({
8898
+ ok: o.okResult,
8899
+ contentBearingEvents: o.contentBearingEvents,
8900
+ usage: o.turnUsage
8901
+ });
8847
8902
  if (emptyResultReason) {
8848
- okResult = false;
8849
- resultReason = emptyResultReason;
8903
+ o.okResult = false;
8904
+ o.resultReason = emptyResultReason;
8850
8905
  }
8851
- if (!okResult && !resultReason) {
8852
- resultReason = "no_result";
8906
+ if (!o.okResult && !o.resultReason) {
8907
+ o.resultReason = "no_result";
8853
8908
  }
8854
8909
  if (!abortController.signal.aborted) {
8855
- await applyRecordedTurnControlIntent();
8910
+ await this.applyRecordedTurnControlIntent();
8856
8911
  }
8857
- if (!abortController.signal.aborted && skipState.skipped) {
8912
+ if (!abortController.signal.aborted && this.skipState.skipped) {
8858
8913
  turnLog.info(
8859
- { reason: skipState.reason, turnId, ok: okResult },
8914
+ { reason: this.skipState.reason, turnId, ok: o.okResult },
8860
8915
  "agent skipped turn (skip_turn)"
8861
8916
  );
8862
- const { wake: skipWake } = wakeCommitField(wakeState);
8917
+ const { wake: skipWake } = wakeCommitField(this.wakeState);
8863
8918
  try {
8864
8919
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8865
8920
  body: SKIPPED_MARKER_BODY,
8866
8921
  kind: "skipped",
8867
8922
  turnId,
8868
- seq: nextSeq(),
8923
+ seq: this.nextSeq(),
8869
8924
  parentMessageId: payload.messageId,
8870
8925
  ...skipWake ? { wake: skipWake } : {}
8871
8926
  });
@@ -8876,21 +8931,21 @@ ${reason}`,
8876
8931
  );
8877
8932
  }
8878
8933
  } else {
8879
- await committer.finalize(okResult);
8880
- if (!abortController.signal.aborted && okResult && !committer.finalEmitted) {
8934
+ await committer.finalize(o.okResult);
8935
+ if (!abortController.signal.aborted && o.okResult && !committer.finalEmitted) {
8881
8936
  try {
8882
8937
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8883
8938
  body: SILENT_MARKER_BODY,
8884
8939
  kind: "silent",
8885
8940
  turnId,
8886
- seq: nextSeq(),
8941
+ seq: this.nextSeq(),
8887
8942
  parentMessageId: payload.messageId,
8888
8943
  // ask, wake and send must survive a wordless turn exactly as
8889
8944
  // they survive a textual final; dropping one can strand a person
8890
8945
  // or the next actor with no visible failure.
8891
8946
  ...committer.turnControlFields("silent")
8892
8947
  });
8893
- silentMarkerEmitted = true;
8948
+ o.silentMarkerEmitted = true;
8894
8949
  } catch (err) {
8895
8950
  turnLog.warn(
8896
8951
  { err: err instanceof Error ? err.message : String(err) },
@@ -8900,27 +8955,25 @@ ${reason}`,
8900
8955
  }
8901
8956
  }
8902
8957
  } catch (err) {
8903
- okResult = false;
8904
- resultReason = err instanceof Error ? err.message : String(err);
8905
- turnLog.error({ err: resultReason }, "dispatcher: SDK query threw");
8958
+ o.okResult = false;
8959
+ o.resultReason = err instanceof Error ? err.message : String(err);
8960
+ turnLog.error({ err: o.resultReason }, "dispatcher: SDK query threw");
8906
8961
  } finally {
8907
- if (idleTimer) clearTimeout(idleTimer);
8908
- clearTimeout(totalTimer);
8909
- clearInterval(leaseTimer);
8910
- if (leaseLost) {
8911
- resultReason = "lease_lost";
8912
- okResult = false;
8962
+ this.disarmWatchdogs();
8963
+ if (o.leaseLost) {
8964
+ o.resultReason = "lease_lost";
8965
+ o.okResult = false;
8913
8966
  }
8914
- const userCancelled = abortController.signal.aborted && timeoutReason === null && !leaseLost;
8915
- if (timeoutReason !== null) {
8916
- resultReason = timeoutReason;
8917
- okResult = false;
8918
- if (timeoutReason === "timeout_idle") {
8967
+ const userCancelled = abortController.signal.aborted && o.timeoutReason === null && !o.leaseLost;
8968
+ if (o.timeoutReason !== null) {
8969
+ o.resultReason = o.timeoutReason;
8970
+ o.okResult = false;
8971
+ if (o.timeoutReason === "timeout_idle") {
8919
8972
  const health = this.opts.connectorHealth?.lookup(turnRuntime);
8920
8973
  if (health?.quotaState === "limited" && health.limitedUntil) {
8921
8974
  const resetMs = Date.parse(health.limitedUntil);
8922
8975
  if (Number.isFinite(resetMs) && resetMs > Date.now()) {
8923
- resultReason = encodeFailureReason({
8976
+ o.resultReason = encodeFailureReason({
8924
8977
  kind: "usage_capped",
8925
8978
  resetsAt: health.limitedUntil
8926
8979
  });
@@ -8944,42 +8997,41 @@ ${reason}`,
8944
8997
  // turn is just as over, and its evidence must die with it.
8945
8998
  settledMessageId: payload.messageId
8946
8999
  };
8947
- if (turnUsage) {
8948
- body.usage = turnUsage;
9000
+ if (o.turnUsage) {
9001
+ body.usage = o.turnUsage;
8949
9002
  }
8950
- if (turnResolvedModel) {
8951
- body.resolvedModel = turnResolvedModel;
9003
+ if (o.turnResolvedModel) {
9004
+ body.resolvedModel = o.turnResolvedModel;
8952
9005
  }
8953
- if (turnResolvedConfig && Object.keys(turnResolvedConfig).length > 0) {
8954
- body.resolvedConfig = turnResolvedConfig;
9006
+ if (o.turnResolvedConfig && Object.keys(o.turnResolvedConfig).length > 0) {
9007
+ body.resolvedConfig = o.turnResolvedConfig;
8955
9008
  }
8956
- if (!okResult && resultReason && resultReason !== "cancelled" && !userCancelled && !leaseLost && !skipState.skipped) {
8957
- body.errorReason = resultReason.slice(0, 200);
9009
+ if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !this.skipState.skipped) {
9010
+ body.errorReason = o.resultReason.slice(0, 200);
8958
9011
  }
8959
- if (okResult) {
9012
+ if (o.okResult) {
8960
9013
  body.lastSeenMessageId = payload.messageId;
8961
9014
  }
8962
- body.outcome = okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
8963
- if (sessionDegraded) {
9015
+ body.outcome = o.okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
9016
+ if (o.sessionDegraded) {
8964
9017
  body.degraded = true;
8965
9018
  }
8966
- if (sessionWriteRejected && !this.sessionWriteNotified.has(key)) {
9019
+ if (o.sessionWriteRejected && this.supervisor.claimSessionWriteNotice()) {
8967
9020
  body.sessionWriteRejected = true;
8968
- this.sessionWriteNotified.add(key);
8969
9021
  }
8970
- const outcome = skipState.skipped ? "skipped" : userCancelled || leaseLost ? "cancelled" : okResult ? "success" : "failure";
8971
- const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? leaseLost ? { kind: "lease_lost" } : { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8972
- settledDiagnostics = {
9022
+ const outcome = this.skipState.skipped ? "skipped" : userCancelled || o.leaseLost ? "cancelled" : o.okResult ? "success" : "failure";
9023
+ const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? o.leaseLost ? { kind: "lease_lost" } : { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(o.resultReason) : null;
9024
+ o.settledDiagnostics = {
8973
9025
  outcome,
8974
9026
  resultReason: diagnosticReason,
8975
- sessionMode: sessionDegraded ? "degraded" : request.session ? "resumed" : "fresh",
8976
- sessionFingerprint: fingerprintSessionState(latestSessionState),
8977
- eventCounts,
8978
- runtimeResultKind,
8979
- ...turnMcpInventory ? { mcpInventory: turnMcpInventory } : {},
8980
- finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
9027
+ sessionMode: o.sessionDegraded ? "degraded" : this.request.session ? "resumed" : "fresh",
9028
+ sessionFingerprint: fingerprintSessionState(o.latestSessionState),
9029
+ eventCounts: o.eventCounts,
9030
+ runtimeResultKind: o.runtimeResultKind,
9031
+ ...o.turnMcpInventory ? { mcpInventory: o.turnMcpInventory } : {},
9032
+ finalSource: outcome === "skipped" || outcome === "cancelled" || o.silentMarkerEmitted ? "marker" : committer.finalSource
8981
9033
  };
8982
- body.diagnostics = settledDiagnostics;
9034
+ body.diagnostics = o.settledDiagnostics;
8983
9035
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
8984
9036
  diagnosticReason.kind
8985
9037
  )) {
@@ -8990,16 +9042,16 @@ ${reason}`,
8990
9042
  conversationId: payload.conversationId,
8991
9043
  agentId: payload.agentId,
8992
9044
  runtime: turnRuntime,
8993
- model: turnResolvedModel ?? null,
8994
- usage: turnUsage ?? null,
8995
- hadStoredSession: request.session != null,
8996
- diagnostics: settledDiagnostics
9045
+ model: o.turnResolvedModel ?? null,
9046
+ usage: o.turnUsage ?? null,
9047
+ hadStoredSession: this.request.session != null,
9048
+ diagnostics: o.settledDiagnostics
8997
9049
  },
8998
9050
  "dispatcher: anomalous turn settled"
8999
9051
  );
9000
9052
  }
9001
9053
  this.opts.connectorHealth?.recordSettle(turnRuntime, {
9002
- ok: okResult,
9054
+ ok: o.okResult,
9003
9055
  errorReason: body.errorReason ?? null
9004
9056
  });
9005
9057
  try {
@@ -9015,45 +9067,151 @@ ${reason}`,
9015
9067
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
9016
9068
  );
9017
9069
  }
9018
- releaseAbort();
9070
+ this.supervisor.releaseAbort(turnId, abortController);
9019
9071
  }
9072
+ }
9073
+ report() {
9074
+ const o = this.outcome;
9075
+ const { startedAt, turnLog } = this;
9020
9076
  const durationMs = Date.now() - startedAt;
9021
- const finalReplyBody = committer.replyBody;
9022
- if (transcript2) {
9023
- transcript2.close({
9024
- ok: okResult,
9025
- ...resultReason ? { reason: resultReason } : {},
9077
+ const finalReplyBody = this.committer.replyBody;
9078
+ if (this.transcript) {
9079
+ this.transcript.close({
9080
+ ok: o.okResult,
9081
+ ...o.resultReason ? { reason: o.resultReason } : {},
9026
9082
  durationMs
9027
9083
  });
9028
- if (!okResult && resultReason !== "cancelled") {
9029
- turnLog.info(`turn failed \u2014 full transcript: ${transcript2.path}`);
9084
+ if (!o.okResult && o.resultReason !== "cancelled") {
9085
+ turnLog.info(`turn failed \u2014 full this.transcript: ${this.transcript.path}`);
9030
9086
  }
9031
- if (settledDiagnostics?.resultReason?.kind === "empty_result" || settledDiagnostics?.resultReason?.kind === "empty_result_unverified") {
9032
- transcript2.preserveAnomaly();
9087
+ if (o.settledDiagnostics?.resultReason?.kind === "empty_result" || o.settledDiagnostics?.resultReason?.kind === "empty_result_unverified") {
9088
+ this.transcript.preserveAnomaly();
9033
9089
  }
9034
9090
  }
9035
- if (okResult) {
9091
+ if (o.okResult) {
9036
9092
  turnLog.debug({ durationMs }, "dispatcher: turn end (ok)");
9037
- this.notifyEnd({
9038
- id: dispatchId,
9093
+ this.supervisor.notifyEnd({
9094
+ id: this.dispatchId,
9039
9095
  ok: true,
9040
9096
  durationMs,
9041
9097
  ...finalReplyBody ? { reply: finalReplyBody.trim() } : {}
9042
9098
  });
9043
9099
  return { ok: true, durationMs };
9044
9100
  }
9045
- turnLog.debug({ durationMs, reason: resultReason }, "dispatcher: turn end (not ok)");
9046
- this.notifyEnd({
9047
- id: dispatchId,
9101
+ turnLog.debug({ durationMs, reason: o.resultReason }, "dispatcher: turn end (not ok)");
9102
+ this.supervisor.notifyEnd({
9103
+ id: this.dispatchId,
9048
9104
  ok: false,
9049
9105
  durationMs,
9050
- ...resultReason ? { reason: resultReason } : {}
9106
+ ...o.resultReason ? { reason: o.resultReason } : {}
9051
9107
  });
9052
9108
  return {
9053
9109
  ok: false,
9054
9110
  durationMs,
9055
- ...resultReason ? { reason: resultReason } : {}
9111
+ ...o.resultReason ? { reason: o.resultReason } : {}
9112
+ };
9113
+ }
9114
+ };
9115
+ function fingerprintSessionState(state) {
9116
+ if (!state) return null;
9117
+ let opaqueId = state;
9118
+ try {
9119
+ const parsed = JSON.parse(state);
9120
+ const candidate = parsed.sdkSessionId ?? parsed.threadId ?? parsed.sessionId;
9121
+ if (typeof candidate === "string" && candidate.length > 0) opaqueId = candidate;
9122
+ } catch {
9123
+ }
9124
+ return createHash2("sha256").update(opaqueId).digest("hex").slice(0, 16);
9125
+ }
9126
+
9127
+ // src/dispatcher.ts
9128
+ function checkoutState(cwd) {
9129
+ if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
9130
+ if (!existsSync11(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
9131
+ let entries;
9132
+ try {
9133
+ entries = readdirSync2(cwd);
9134
+ } catch (error) {
9135
+ return { ok: false, reason: `${cwd} is unreadable (${error.message})` };
9136
+ }
9137
+ if (entries.length === 0) {
9138
+ return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
9139
+ }
9140
+ const gitPath = join14(cwd, ".git");
9141
+ if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
9142
+ let stat;
9143
+ try {
9144
+ stat = statSync(gitPath);
9145
+ } catch (error) {
9146
+ return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
9147
+ }
9148
+ if (stat.isDirectory() && !existsSync11(join14(gitPath, "HEAD")))
9149
+ return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
9150
+ return { ok: true, reason: "usable" };
9151
+ }
9152
+ function runKey(conversationId, agentId) {
9153
+ return `${conversationId}|${agentId}`;
9154
+ }
9155
+ var Dispatcher = class {
9156
+ constructor(opts) {
9157
+ this.opts = opts;
9158
+ this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
9159
+ }
9160
+ opts;
9161
+ // CT1288: keyed (conversation|agent) → per-TURN controllers. Stop is
9162
+ // pair-scoped and must reach every live loop for the pair.
9163
+ aborts;
9164
+ // CT1109: pairs already told "your session couldn't be saved" — the notice is
9165
+ // once per (conversation, agent), while the rejection repeats every turn.
9166
+ sessionWriteNotified = /* @__PURE__ */ new Set();
9167
+ notifyStart(info) {
9168
+ try {
9169
+ this.opts.observer?.onStart(info);
9170
+ } catch {
9171
+ }
9172
+ }
9173
+ notifyEnd(info) {
9174
+ try {
9175
+ this.opts.observer?.onEnd(info);
9176
+ } catch {
9177
+ }
9178
+ }
9179
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
9180
+ // under before the companion died. Passing it makes the replay ask the
9181
+ // mailroom to re-admit the turn it already owns, which the server explicitly
9182
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
9183
+ // dispatch, where minting is correct.
9184
+ //
9185
+ // CT1261: a short `handle` creates a `TurnExecution` and runs it. The
9186
+ // supervisor seam hands the execution exactly the three cross-turn duties —
9187
+ // the abort registry, the session-write notice latch, the observer — and
9188
+ // nothing else.
9189
+ async handle(payload, opts = {}) {
9190
+ const key = runKey(payload.conversationId, payload.agentId);
9191
+ const supervisor = {
9192
+ registerAbort: (turnId, controller) => {
9193
+ let pairAborts = this.aborts.get(key);
9194
+ if (!pairAborts) {
9195
+ pairAborts = /* @__PURE__ */ new Map();
9196
+ this.aborts.set(key, pairAborts);
9197
+ }
9198
+ pairAborts.set(turnId, controller);
9199
+ },
9200
+ releaseAbort: (turnId, controller) => {
9201
+ const pair2 = this.aborts.get(key);
9202
+ if (pair2?.get(turnId) !== controller) return;
9203
+ pair2.delete(turnId);
9204
+ if (pair2.size === 0) this.aborts.delete(key);
9205
+ },
9206
+ claimSessionWriteNotice: () => {
9207
+ if (this.sessionWriteNotified.has(key)) return false;
9208
+ this.sessionWriteNotified.add(key);
9209
+ return true;
9210
+ },
9211
+ notifyStart: (info) => this.notifyStart(info),
9212
+ notifyEnd: (info) => this.notifyEnd(info)
9056
9213
  };
9214
+ return new TurnExecution(this.opts, supervisor, payload, opts).run();
9057
9215
  }
9058
9216
  // SJ383: cancel a specific (conversation, agent) run if one is in flight in
9059
9217
  // THIS companion process. Returns true if an in-flight run was aborted.
@@ -9070,17 +9228,6 @@ ${reason}`,
9070
9228
  return true;
9071
9229
  }
9072
9230
  };
9073
- function fingerprintSessionState(state) {
9074
- if (!state) return null;
9075
- let opaqueId = state;
9076
- try {
9077
- const parsed = JSON.parse(state);
9078
- const candidate = parsed.sdkSessionId ?? parsed.threadId ?? parsed.sessionId;
9079
- if (typeof candidate === "string" && candidate.length > 0) opaqueId = candidate;
9080
- } catch {
9081
- }
9082
- return createHash2("sha256").update(opaqueId).digest("hex").slice(0, 16);
9083
- }
9084
9231
 
9085
9232
  // src/opencode-models.ts
9086
9233
  var OPENCODE_RUNTIME = "opencode";
@@ -9125,7 +9272,7 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
9125
9272
 
9126
9273
  // src/outbox.ts
9127
9274
  import {
9128
- existsSync as existsSync11,
9275
+ existsSync as existsSync12,
9129
9276
  mkdirSync as mkdirSync10,
9130
9277
  readdirSync as readdirSync3,
9131
9278
  readFileSync as readFileSync8,
@@ -9181,7 +9328,7 @@ var Outbox = class {
9181
9328
  // wedging the drain.
9182
9329
  list() {
9183
9330
  const dir2 = this.dir();
9184
- if (!existsSync11(dir2)) return [];
9331
+ if (!existsSync12(dir2)) return [];
9185
9332
  let names;
9186
9333
  try {
9187
9334
  names = readdirSync3(dir2);
@@ -9217,7 +9364,7 @@ var Outbox = class {
9217
9364
  }
9218
9365
  size() {
9219
9366
  const dir2 = this.dir();
9220
- if (!existsSync11(dir2)) return 0;
9367
+ if (!existsSync12(dir2)) return 0;
9221
9368
  try {
9222
9369
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
9223
9370
  } catch {
@@ -10457,7 +10604,7 @@ function handleUncaught(log, err, origin) {
10457
10604
  }
10458
10605
 
10459
10606
  // src/crash-marker.ts
10460
- import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
10607
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
10461
10608
  import { join as join16 } from "path";
10462
10609
  function crashMarkerPath() {
10463
10610
  return join16(cabaneDir(), "last-error.json");
@@ -10472,7 +10619,7 @@ function recordCrash(rec2) {
10472
10619
  function clearCrash() {
10473
10620
  try {
10474
10621
  const path = crashMarkerPath();
10475
- if (existsSync12(path)) rmSync8(path, { force: true });
10622
+ if (existsSync13(path)) rmSync8(path, { force: true });
10476
10623
  } catch {
10477
10624
  }
10478
10625
  }
@@ -10647,11 +10794,11 @@ import { spawn as spawn4 } from "child_process";
10647
10794
  import { closeSync as closeSync3, mkdirSync as mkdirSync12, openSync as openSync3 } from "fs";
10648
10795
 
10649
10796
  // src/cli-entry.ts
10650
- import { existsSync as existsSync13 } from "fs";
10797
+ import { existsSync as existsSync14 } from "fs";
10651
10798
  import { fileURLToPath as fileURLToPath2 } from "url";
10652
10799
  var RELATIVE_CANDIDATES = ["./cli.js", "../dist/cli.js", "../cli.js"];
10653
10800
  function companionCliEntry(deps = {}) {
10654
- const exists = deps.exists ?? existsSync13;
10801
+ const exists = deps.exists ?? existsSync14;
10655
10802
  const candidates = deps.candidates ?? RELATIVE_CANDIDATES.map((rel) => fileURLToPath2(new URL(rel, import.meta.url)));
10656
10803
  for (const candidate of candidates) {
10657
10804
  if (exists(candidate)) return candidate;
@@ -11167,7 +11314,7 @@ function isAlive(kill, pid) {
11167
11314
  }
11168
11315
 
11169
11316
  // src/commands/transcript.ts
11170
- import { existsSync as existsSync14, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
11317
+ import { existsSync as existsSync15, readFileSync as readFileSync10, readdirSync as readdirSync5 } from "fs";
11171
11318
  import { isAbsolute, join as join17 } from "path";
11172
11319
  async function transcript(opts = {}) {
11173
11320
  const dir2 = transcriptsDir();
@@ -11342,11 +11489,11 @@ function peek(path) {
11342
11489
  }
11343
11490
  function resolveTarget(dir2, target) {
11344
11491
  if (isAbsolute(target) || target.includes("/")) {
11345
- if (existsSync14(target)) return target;
11492
+ if (existsSync15(target)) return target;
11346
11493
  throw new CompanionError(`no transcript at ${target}.`);
11347
11494
  }
11348
11495
  const exact = join17(dir2, target);
11349
- if (existsSync14(exact)) return exact;
11496
+ if (existsSync15(exact)) return exact;
11350
11497
  const matches = listFiles(dir2).filter((f) => f.includes(target));
11351
11498
  if (matches.length === 1) return join17(dir2, matches[0]);
11352
11499
  if (matches.length === 0) {