@cabane/companion 0.6.71 → 0.6.72

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/runtime.js CHANGED
@@ -6775,10 +6775,13 @@ var ConnectorHealthStore = class {
6775
6775
  };
6776
6776
 
6777
6777
  // src/dispatcher.ts
6778
- import { createHash as createHash2, randomUUID } from "crypto";
6779
- import { existsSync as existsSync10, readdirSync as readdirSync2, statSync } from "fs";
6778
+ import { existsSync as existsSync11, readdirSync as readdirSync2, statSync } from "fs";
6780
6779
  import { join as join14 } from "path";
6781
6780
 
6781
+ // src/turn-execution.ts
6782
+ import { createHash as createHash2, randomUUID } from "crypto";
6783
+ import { existsSync as existsSync10 } from "fs";
6784
+
6782
6785
  // src/turn-control-tools.ts
6783
6786
  import { z as z13 } from "zod";
6784
6787
  var COMPANION_LOCAL_MCP_SERVER = "cabane_companion";
@@ -7588,7 +7591,7 @@ var TurnCommitter = class {
7588
7591
  }
7589
7592
  };
7590
7593
 
7591
- // src/dispatcher.ts
7594
+ // src/turn-execution.ts
7592
7595
  var PREPARING_TOOL_NAME = "preparing";
7593
7596
  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:";
7594
7597
  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:";
@@ -7600,33 +7603,6 @@ var DEFAULT_PREPARING_ROW_DELAY_MS = 1500;
7600
7603
  var DEFAULT_AGENT_IDLE_TIMEOUT_MS = 10 * 6e4;
7601
7604
  var DEFAULT_AGENT_TOTAL_TIMEOUT_MS = 6 * 60 * 6e4;
7602
7605
  var DEFAULT_LEASE_RENEWAL_MS = 3e4;
7603
- function checkoutState(cwd) {
7604
- if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
7605
- if (!existsSync10(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
7606
- let entries;
7607
- try {
7608
- entries = readdirSync2(cwd);
7609
- } catch (error) {
7610
- return { ok: false, reason: `${cwd} is unreadable (${error.message})` };
7611
- }
7612
- if (entries.length === 0) {
7613
- return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
7614
- }
7615
- const gitPath = join14(cwd, ".git");
7616
- if (!existsSync10(gitPath)) return { ok: true, reason: "usable" };
7617
- let stat;
7618
- try {
7619
- stat = statSync(gitPath);
7620
- } catch (error) {
7621
- return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
7622
- }
7623
- if (stat.isDirectory() && !existsSync10(join14(gitPath, "HEAD")))
7624
- return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
7625
- return { ok: true, reason: "usable" };
7626
- }
7627
- function runKey(conversationId, agentId) {
7628
- return `${conversationId}|${agentId}`;
7629
- }
7630
7606
  var LEASE_REFUSALS = /* @__PURE__ */ new Set([
7631
7607
  "dispatch_not_admitted",
7632
7608
  "turn_already_ended",
@@ -7661,61 +7637,128 @@ function describeErrorBody(body) {
7661
7637
  if (text.length === 0) return void 0;
7662
7638
  return text.length > ERROR_BODY_LOG_CAP ? `${text.slice(0, ERROR_BODY_LOG_CAP)}\u2026` : text;
7663
7639
  }
7664
- var Dispatcher = class {
7665
- constructor(opts) {
7640
+ function initialOutcome() {
7641
+ return {
7642
+ sessionWritten: false,
7643
+ sessionDegraded: false,
7644
+ sessionWriteRejected: false,
7645
+ okResult: false,
7646
+ resultReason: void 0,
7647
+ turnUsage: void 0,
7648
+ turnResolvedModel: void 0,
7649
+ turnResolvedConfig: void 0,
7650
+ turnMcpInventory: void 0,
7651
+ eventCounts: { session: 0, text: 0, thinking: 0, tool: 0, result: 0 },
7652
+ runtimeResultKind: null,
7653
+ contentBearingEvents: 0,
7654
+ latestSessionState: null,
7655
+ settledDiagnostics: null,
7656
+ silentMarkerEmitted: false,
7657
+ timeoutReason: null,
7658
+ leaseLost: false
7659
+ };
7660
+ }
7661
+ var TurnConcluded = class {
7662
+ constructor(reason, errorReason) {
7663
+ this.reason = reason;
7664
+ this.errorReason = errorReason;
7665
+ }
7666
+ reason;
7667
+ errorReason;
7668
+ };
7669
+ var TurnExecution = class {
7670
+ constructor(opts, supervisor, payload, handleOpts = {}) {
7666
7671
  this.opts = opts;
7667
- this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
7672
+ this.supervisor = supervisor;
7673
+ this.payload = payload;
7674
+ this.dispatchId = payload.messageId;
7675
+ this.workspaceId = opts.workspaceId;
7676
+ this.turnLog = opts.log.child({
7677
+ workspaceId: opts.workspaceId,
7678
+ conversationId: payload.conversationId,
7679
+ agentId: payload.agentId,
7680
+ messageId: payload.messageId
7681
+ });
7682
+ this.turnId = handleOpts.turnId ?? randomUUID();
7668
7683
  }
7669
7684
  opts;
7670
- // SJ383: per-(conversation, agent) abort registry.
7671
- // CT1288: RunKey -> (turnId -> controller). This used to be one controller
7672
- // per (conversation, agent), which quietly encoded the invariant the whole
7673
- // task exists to enforce: that a pair can only ever have one live loop. When
7674
- // that assumption broke, the second `set` EVICTED the first controller and
7675
- // the first loop became permanently uncancellable — no other code path can
7676
- // reach into a running turn. So the registry that Stop depends on failed
7677
- // exactly when Stop was the thing you needed.
7678
- //
7679
- // Nesting by turn id costs nothing in the normal single-turn case and makes
7680
- // `cancel` total: it aborts every loop under the pair, not the newest one.
7681
- aborts;
7682
- // CT1109: pairs already told, in the conversation, that their session state is
7683
- // being refused. The failure repeats every single turn until someone fixes the
7684
- // payload, so without this the notice would be a per-turn drumbeat; one notice
7685
- // is the signal and the rest is noise. Deliberately in-memory and unbounded-free:
7686
- // a companion restart re-arms it, which is the behaviour we want — a restart is
7687
- // exactly when it's worth re-stating that memory is still being lost.
7688
- sessionWriteNotified = /* @__PURE__ */ new Set();
7689
- notifyStart(info) {
7690
- try {
7691
- this.opts.observer?.onStart(info);
7692
- } catch {
7693
- }
7694
- }
7695
- notifyEnd(info) {
7685
+ supervisor;
7686
+ payload;
7687
+ startedAt = Date.now();
7688
+ turnId;
7689
+ dispatchId;
7690
+ workspaceId;
7691
+ turnLog;
7692
+ abortController = new AbortController();
7693
+ outcome = initialOutcome();
7694
+ seqCounter = 0;
7695
+ // CT11: per-turn monotonic counter mirroring the in-process dispatcher's.
7696
+ nextSeq = () => ++this.seqCounter;
7697
+ turnContext;
7698
+ resolvedMcpServers;
7699
+ effectiveCwd;
7700
+ hookEnv;
7701
+ turnEnv;
7702
+ sendState;
7703
+ skipState;
7704
+ askState;
7705
+ wakeState;
7706
+ replyState;
7707
+ controlOrder;
7708
+ turnControlServer;
7709
+ request;
7710
+ adapter;
7711
+ committer = null;
7712
+ transcript = null;
7713
+ turnControlIntentFetched = false;
7714
+ applyRecordedTurnControlIntent = async () => {
7715
+ };
7716
+ // CT1292: the committer is built before the lease watchdog arms, so the
7717
+ // failed-commit hint reaches it through this slot. Safe as a no-op until the
7718
+ // watchdog replaces it — the committer only fires it from inside the adapter
7719
+ // loop, which runs after.
7720
+ noteCommitFailed = () => {
7721
+ };
7722
+ disarmWatchdogs = () => {
7723
+ };
7724
+ // Codo's stack review, blocking finding #2: set the moment `acquireLease`'s
7725
+ // PATCH returns — the mailroom now holds this turn as the conversation's
7726
+ // running one, so every exit after this point must settle THE TURN, not just
7727
+ // clear the participant flag.
7728
+ admitted = false;
7729
+ concluded(reason, errorReason) {
7730
+ return new TurnConcluded(reason, errorReason);
7731
+ }
7732
+ // The arc. Phases in order; every pre-loop exit throws `TurnConcluded` and
7733
+ // funnels through ONE conclusion (hand back the controller — idempotent and
7734
+ // identity-checked at the supervisor — then clear the active-run flag);
7735
+ // everything past the lease settles in `execute()`'s one `finally`.
7736
+ async run() {
7696
7737
  try {
7697
- this.opts.observer?.onEnd(info);
7698
- } catch {
7738
+ await this.fetchContext();
7739
+ this.gateTrigger();
7740
+ await this.resolveSecrets();
7741
+ await this.prepareEnvironment();
7742
+ this.buildRequest();
7743
+ await this.acquireLease();
7744
+ await this.selectAdapter();
7745
+ } catch (err) {
7746
+ if (err instanceof TurnConcluded) {
7747
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
7748
+ return this.admitted ? this.concludeAdmittedRun(err.reason, err.errorReason) : this.concludeBeforeRun(err.reason, err.errorReason);
7749
+ }
7750
+ if (this.admitted) {
7751
+ this.supervisor.releaseAbort(this.turnId, this.abortController);
7752
+ const msg = err instanceof Error ? err.message : String(err);
7753
+ await this.concludeAdmittedRun("setup_failed", `setup_failed: ${msg}`);
7754
+ }
7755
+ throw err;
7699
7756
  }
7757
+ await this.execute();
7758
+ return this.report();
7700
7759
  }
7701
- // CT138: shared pre-run teardown for every early-return that happens BEFORE
7702
- // the active-run flag is flipped (the `setActiveRun` working-flip below).
7703
- // The server lights the "X is replying…" indicator eagerly at dispatch
7704
- // (chat-dispatch.ts `scheduleRun`), and from that point only the companion can
7705
- // clear it — the SJ383 `finally` after the SDK loop is the one clear, and
7706
- // every pre-run exit returns before reaching it. So each pre-run failure has
7707
- // to clear `active_run_started_at` itself, mirroring that `finally`, or the
7708
- // indicator strands until the 12h age sweep.
7709
- //
7710
- // `errorReason` controls the server's duplicate-notice rule (the active-run
7711
- // PATCH handler in conversations.ts): a clear carrying `errorReason` makes the
7712
- // server post a `role:'system'` "X's reply failed: …" notice. Exits that
7713
- // already posted their own user-facing `final` (prepare-failed, missing-secret)
7714
- // pass NO errorReason, so the user doesn't see a second failure message; exits
7715
- // that posted nothing pass `errorReason` so the user still gets a notice. The
7716
- // clear is durable (CT93 outbox, last-writer-wins per pair), so a terminal
7717
- // failure here just logs and the server age-sweep backstops it.
7718
- async concludeBeforeRun(payload, turnLog, startedAt, reason, errorReason) {
7760
+ async concludeBeforeRun(reason, errorReason) {
7761
+ const { payload, turnLog, startedAt } = this;
7719
7762
  const body = {
7720
7763
  activeRunStartedAt: null,
7721
7764
  // CT1046: this dispatch is over before it ran — retire its evidence so it
@@ -7737,25 +7780,47 @@ var Dispatcher = class {
7737
7780
  );
7738
7781
  }
7739
7782
  const durationMs = Date.now() - startedAt;
7740
- this.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
7783
+ this.supervisor.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
7741
7784
  return { ok: false, durationMs, reason };
7742
7785
  }
7743
- // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
7744
- // under before the companion died. Passing it makes the replay ask the
7745
- // mailroom to re-admit the turn it already owns, which the server explicitly
7746
- // supports ("a replay reports, it never re-points"). Omitted on a fresh
7747
- // dispatch, where minting below is correct.
7748
- async handle(payload, opts = {}) {
7749
- const startedAt = Date.now();
7750
- const dispatchId = payload.messageId;
7751
- const workspaceId = this.opts.workspaceId;
7752
- const turnLog = this.opts.log.child({
7753
- workspaceId,
7754
- conversationId: payload.conversationId,
7755
- agentId: payload.agentId,
7756
- messageId: payload.messageId
7757
- });
7758
- const turnId = opts.turnId ?? randomUUID();
7786
+ // The post-admission analogue of `concludeBeforeRun` (Codo's stack review,
7787
+ // blocking finding #2): this turn WAS admitted the mailroom holds it as
7788
+ // the conversation's running turn so the clear must NAME the turn and
7789
+ // DECLARE its terminal outcome. A bare pre-run clear here would reset the
7790
+ // participant flag while the turn metadata stayed running, the dispatch
7791
+ // stayed live, and the queue stayed held until the reaper. `outcome`
7792
+ // follows the settle wire's rule: a failure names its reason; without one
7793
+ // the turn settled — the runtime-unavailable exit posts its user-facing
7794
+ // `final` before concluding, so the turn genuinely produced its terminal
7795
+ // output and `settled` releases the queue honestly.
7796
+ async concludeAdmittedRun(reason, errorReason) {
7797
+ const { payload, turnLog, startedAt, turnId } = this;
7798
+ const body = {
7799
+ activeRunStartedAt: null,
7800
+ turnId,
7801
+ settledMessageId: payload.messageId,
7802
+ outcome: errorReason ? "failed" : "settled"
7803
+ };
7804
+ if (errorReason) body.errorReason = errorReason.slice(0, 200);
7805
+ try {
7806
+ await this.opts.api.setActiveRun(
7807
+ this.opts.workspaceId,
7808
+ payload.conversationId,
7809
+ payload.agentId,
7810
+ body
7811
+ );
7812
+ } catch (err) {
7813
+ turnLog.warn(
7814
+ { err: err instanceof Error ? err.message : String(err), turnId },
7815
+ "dispatcher: admitted-run settle failed terminally; server age-sweep is the backstop"
7816
+ );
7817
+ }
7818
+ const durationMs = Date.now() - startedAt;
7819
+ this.supervisor.notifyEnd({ id: payload.messageId, ok: false, durationMs, reason });
7820
+ return { ok: false, durationMs, reason };
7821
+ }
7822
+ async fetchContext() {
7823
+ const { payload, turnId, turnLog } = this;
7759
7824
  let turnContext;
7760
7825
  try {
7761
7826
  turnContext = await this.opts.api.getTurnContext(
@@ -7770,35 +7835,36 @@ var Dispatcher = class {
7770
7835
  { err: err instanceof Error ? err.message : String(err) },
7771
7836
  "dispatcher: turn-context 404 (stale event \u2014 conversation/message/participant gone); skipping"
7772
7837
  );
7773
- return this.concludeBeforeRun(payload, turnLog, startedAt, "turn_context_not_found");
7838
+ throw this.concluded("turn_context_not_found");
7774
7839
  }
7775
7840
  const reason = err instanceof Error ? err.message : String(err);
7776
7841
  turnLog.error({ err: reason }, "dispatcher: failed to fetch turn context");
7777
7842
  const fetchReason = `fetch_failed: ${reason}`;
7778
- return this.concludeBeforeRun(payload, turnLog, startedAt, fetchReason, fetchReason);
7843
+ throw this.concluded(fetchReason, fetchReason);
7779
7844
  }
7780
- const message = turnContext.message;
7845
+ this.turnContext = turnContext;
7846
+ }
7847
+ gateTrigger() {
7848
+ const { payload, turnLog } = this;
7849
+ const message = this.turnContext.message;
7781
7850
  const isDispatchableTrigger = message.role === "user" || message.role === "agent" || message.role === "system" && message.hasPrimaryDispatch === true;
7782
7851
  if (!isDispatchableTrigger) {
7783
7852
  turnLog.warn({ role: message.role }, "dispatcher: trigger role not dispatchable \u2014 skipping");
7784
- return this.concludeBeforeRun(
7785
- payload,
7786
- turnLog,
7787
- startedAt,
7788
- "unexpected_role",
7789
- UNEXPECTED_ROLE_REASON
7790
- );
7853
+ throw this.concluded("unexpected_role", UNEXPECTED_ROLE_REASON);
7791
7854
  }
7792
- this.notifyStart({
7793
- id: dispatchId,
7855
+ this.supervisor.notifyStart({
7856
+ id: this.dispatchId,
7794
7857
  conversationId: payload.conversationId,
7795
7858
  message: message.body
7796
7859
  });
7860
+ }
7861
+ async resolveSecrets() {
7862
+ const { payload, workspaceId, turnLog } = this;
7797
7863
  const secretStore = this.opts.secretStore ?? loadSecretStoreTolerant((m) => turnLog.warn(m));
7798
7864
  const { servers: resolvedMcpServers, missing } = resolveMcpSecrets(
7799
7865
  // CT262: the user MCP DEFINITIONS (placeholder form) come from the turn
7800
7866
  // context now, not a separate `getAgentSelf` run-config fetch.
7801
- turnContext.mcpServers ?? {},
7867
+ this.turnContext.mcpServers ?? {},
7802
7868
  secretStore
7803
7869
  );
7804
7870
  if (missing.length > 0) {
@@ -7819,13 +7885,15 @@ var Dispatcher = class {
7819
7885
  );
7820
7886
  }
7821
7887
  const reason = `missing_secret: ${missing.join(",")}`;
7822
- return this.concludeBeforeRun(payload, turnLog, startedAt, reason);
7888
+ throw this.concluded(reason);
7823
7889
  }
7890
+ this.resolvedMcpServers = resolvedMcpServers;
7891
+ }
7892
+ async prepareEnvironment() {
7893
+ const { payload, workspaceId, turnId, turnLog } = this;
7824
7894
  const localCwd = this.opts.local.cwd;
7825
7895
  const prepareHook = this.opts.local.prepareHook;
7826
- const cabaneCwd = turnContext.cwd;
7827
- let seqCounter = 0;
7828
- const nextSeq = () => ++seqCounter;
7896
+ const cabaneCwd = this.turnContext.cwd;
7829
7897
  let effectiveCwd = localCwd ?? cabaneCwd;
7830
7898
  if (effectiveCwd && !existsSync10(effectiveCwd)) {
7831
7899
  turnLog.warn(
@@ -7835,9 +7903,9 @@ var Dispatcher = class {
7835
7903
  effectiveCwd = void 0;
7836
7904
  }
7837
7905
  let hookEnv;
7838
- const triggerIsPrepareFailure = message.body.startsWith(PREPARE_FAILED_PREFIX);
7839
- const prepareFailureDispatch = turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
7840
- dispatch: turnContext.dispatchedByAgentId,
7906
+ const triggerIsPrepareFailure = this.turnContext.message.body.startsWith(PREPARE_FAILED_PREFIX);
7907
+ const prepareFailureDispatch = this.turnContext.dispatchedByAgentId && !triggerIsPrepareFailure ? {
7908
+ dispatch: this.turnContext.dispatchedByAgentId,
7841
7909
  dispatchBody: `${PREPARE_FAILED_PREFIX}
7842
7910
 
7843
7911
  The dispatched turn could not start. Re-dispatch it after repairing the preparation failure shown in this conversation.`
@@ -7863,11 +7931,11 @@ The dispatched turn could not start. Re-dispatch it after repairing the preparat
7863
7931
  conversationId: payload.conversationId,
7864
7932
  agentId: payload.agentId,
7865
7933
  agentUsername: this.opts.agentUsername,
7866
- runtime: turnContext.runtime,
7867
- hostAccess: turnContext.policy.hostFs,
7868
- triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
7869
- title: turnContext.conversation.title,
7870
- messageBody: message.body,
7934
+ runtime: this.turnContext.runtime,
7935
+ hostAccess: this.turnContext.policy.hostFs,
7936
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
7937
+ title: this.turnContext.conversation.title,
7938
+ messageBody: this.turnContext.message.body,
7871
7939
  prepared: cached2
7872
7940
  });
7873
7941
  } catch (err) {
@@ -7889,7 +7957,7 @@ ${reason}`,
7889
7957
  "dispatcher: prepare-rejection post failed"
7890
7958
  );
7891
7959
  }
7892
- return this.concludeBeforeRun(payload, turnLog, startedAt, `prepare_failed: ${reason}`);
7960
+ throw this.concluded(`prepare_failed: ${reason}`);
7893
7961
  }
7894
7962
  }
7895
7963
  } else {
@@ -7897,7 +7965,7 @@ ${reason}`,
7897
7965
  let preparingStarted = false;
7898
7966
  let preparingSeq = null;
7899
7967
  const reportPreparing = (phase) => {
7900
- if (preparingSeq === null) preparingSeq = nextSeq();
7968
+ if (preparingSeq === null) preparingSeq = this.nextSeq();
7901
7969
  const seq = preparingSeq;
7902
7970
  void this.opts.api.reportActivity(workspaceId, payload.conversationId, payload.agentId, {
7903
7971
  turnId,
@@ -7925,18 +7993,18 @@ ${reason}`,
7925
7993
  conversationId: payload.conversationId,
7926
7994
  agentId: payload.agentId,
7927
7995
  agentUsername: this.opts.agentUsername,
7928
- runtime: turnContext.runtime,
7929
- hostAccess: turnContext.policy.hostFs,
7996
+ runtime: this.turnContext.runtime,
7997
+ hostAccess: this.turnContext.policy.hostFs,
7930
7998
  // CT317/CT319: the trigger message's referenced-entry paths — what the
7931
7999
  // tasker prepare hook keys its per-task env off. Defaults to `[]` for
7932
8000
  // an older API. The conversation anchor is gone (CT319).
7933
- triggerEntryPaths: turnContext.conversation.triggerEntryPaths ?? [],
7934
- title: turnContext.conversation.title,
8001
+ triggerEntryPaths: this.turnContext.conversation.triggerEntryPaths ?? [],
8002
+ title: this.turnContext.conversation.title,
7935
8003
  // CT943: the dispatching message's text — where an `env:` directive
7936
8004
  // rides. The server has always sent the trigger body on the turn
7937
8005
  // context (for the live feed); this is the first thing to read it as
7938
8006
  // an INPUT, so a dispatch can ask for its environment in words.
7939
- messageBody: message.body
8007
+ messageBody: this.turnContext.message.body
7940
8008
  });
7941
8009
  clearTimeout(preparingTimer);
7942
8010
  if (preparingStarted) reportPreparing("done");
@@ -7966,31 +8034,23 @@ ${reason}`,
7966
8034
  );
7967
8035
  }
7968
8036
  const failReason = `prepare_failed: ${reason}`;
7969
- return this.concludeBeforeRun(payload, turnLog, startedAt, failReason);
8037
+ throw this.concluded(failReason);
7970
8038
  }
7971
8039
  }
7972
8040
  }
7973
8041
  const turnEnv = {
7974
8042
  ...hookEnv,
7975
- CABANE_HOST_ACCESS: turnContext.policy.hostFs ? "1" : "0",
8043
+ CABANE_HOST_ACCESS: this.turnContext.policy.hostFs ? "1" : "0",
7976
8044
  CABANE_COMPANION_HOME: agentCompanionHome()
7977
8045
  };
7978
- const key = runKey(payload.conversationId, payload.agentId);
7979
- const abortController = new AbortController();
7980
- let pairAborts = this.aborts.get(key);
7981
- if (!pairAborts) {
7982
- pairAborts = /* @__PURE__ */ new Map();
7983
- this.aborts.set(key, pairAborts);
7984
- }
7985
- pairAborts.set(turnId, abortController);
7986
- const releaseAbort = () => {
7987
- const pair = this.aborts.get(key);
7988
- if (pair?.get(turnId) !== abortController) return;
7989
- pair.delete(turnId);
7990
- if (pair.size === 0) this.aborts.delete(key);
7991
- };
7992
- let timeoutReason = null;
7993
- let leaseLost = false;
8046
+ this.effectiveCwd = effectiveCwd;
8047
+ this.hookEnv = hookEnv;
8048
+ this.turnEnv = turnEnv;
8049
+ }
8050
+ async acquireLease() {
8051
+ const { payload, workspaceId, turnId, turnLog } = this;
8052
+ const abortController = this.abortController;
8053
+ this.supervisor.registerAbort(turnId, abortController);
7994
8054
  try {
7995
8055
  await this.opts.api.setActiveRun(workspaceId, payload.conversationId, payload.agentId, {
7996
8056
  activeRunStartedAt: (/* @__PURE__ */ new Date()).toISOString(),
@@ -8003,27 +8063,30 @@ ${reason}`,
8003
8063
  });
8004
8064
  } catch (err) {
8005
8065
  const refusal = leaseRefusal(err);
8006
- releaseAbort();
8007
8066
  if (refusal === "dispatch_not_admitted" || refusal === "turn_already_ended") {
8008
8067
  turnLog.error(
8009
8068
  { refusal, turnId },
8010
8069
  "dispatcher: refused a turn lease; not running the model"
8011
8070
  );
8012
- return this.concludeBeforeRun(payload, turnLog, startedAt, `lease_refused: ${refusal}`);
8071
+ throw this.concluded(`lease_refused: ${refusal}`);
8013
8072
  }
8014
8073
  const reason = refusal ? `lease_refused: ${refusal}` : "lease_unconfirmed";
8015
8074
  turnLog.error(
8016
8075
  { refusal, turnId, err: err instanceof Error ? err.message : String(err) },
8017
8076
  "dispatcher: turn lease not confirmed; not running the model"
8018
8077
  );
8019
- return this.concludeBeforeRun(payload, turnLog, startedAt, reason, reason);
8020
- }
8021
- const sendState = createSendState();
8022
- const skipState = createSkipState();
8023
- const askState = createAskState();
8024
- const wakeState = createWakeState();
8025
- const replyState = createReplyState();
8026
- const controlOrder = createTurnControlOrder();
8078
+ throw this.concluded(reason, reason);
8079
+ }
8080
+ this.admitted = true;
8081
+ }
8082
+ buildRequest() {
8083
+ const { payload, workspaceId } = this;
8084
+ const sendState = this.sendState = createSendState();
8085
+ const skipState = this.skipState = createSkipState();
8086
+ const askState = this.askState = createAskState();
8087
+ const wakeState = this.wakeState = createWakeState();
8088
+ const replyState = this.replyState = createReplyState();
8089
+ const controlOrder = this.controlOrder = createTurnControlOrder();
8027
8090
  const turnControlServer = createTurnControlMcpServer(
8028
8091
  sendState,
8029
8092
  skipState,
@@ -8032,18 +8095,18 @@ ${reason}`,
8032
8095
  replyState,
8033
8096
  controlOrder
8034
8097
  );
8035
- const request = buildCompanionTurnRequest({
8036
- turnContext,
8098
+ this.request = buildCompanionTurnRequest({
8099
+ turnContext: this.turnContext,
8037
8100
  baseUrl: this.opts.baseUrl,
8038
8101
  agentPat: this.opts.credential,
8039
8102
  // CT306: the per-turn OBO credential when the API minted one; falls back to
8040
8103
  // the companion PAT (`agentPat`) inside `buildCompanionTurnRequest` otherwise.
8041
- ...turnContext.turnToken ? { turnToken: turnContext.turnToken } : {},
8104
+ ...this.turnContext.turnToken ? { turnToken: this.turnContext.turnToken } : {},
8042
8105
  // SJ524: the hook-resolved cwd overrides the static local cwd.
8043
- ...effectiveCwd ? { cwd: effectiveCwd } : {},
8106
+ ...this.effectiveCwd ? { cwd: this.effectiveCwd } : {},
8044
8107
  // CT1103: the prepare-hook env plus the authoritative host-access token.
8045
- env: turnEnv,
8046
- mcpServers: resolvedMcpServers,
8108
+ env: this.turnEnv,
8109
+ mcpServers: this.resolvedMcpServers,
8047
8110
  turnControlServer,
8048
8111
  // CT238: this turn's conversation, forwarded as the active-conversation
8049
8112
  // header so a cross-thread post/spawn stamps its origin.
@@ -8054,6 +8117,9 @@ ${reason}`,
8054
8117
  // CT289: the operator's auto-memory escape hatch (machine-local), when set.
8055
8118
  ...this.opts.local.claudeCode ? { claudeCode: this.opts.local.claudeCode } : {}
8056
8119
  });
8120
+ }
8121
+ async selectAdapter() {
8122
+ const { payload, workspaceId, turnId, turnLog } = this;
8057
8123
  const onWarn = (msg, meta) => turnLog.warn(meta ?? {}, msg);
8058
8124
  const adapters = [];
8059
8125
  if (this.opts.claudeCodeAvailable?.() ?? true) {
@@ -8072,9 +8138,8 @@ ${reason}`,
8072
8138
  );
8073
8139
  }
8074
8140
  const registry = createAdapterRegistry(adapters);
8075
- let adapter;
8076
8141
  try {
8077
- adapter = selectAdapter(registry, turnContext.runtime);
8142
+ this.adapter = selectAdapter(registry, this.turnContext.runtime);
8078
8143
  } catch (err) {
8079
8144
  if (!(err instanceof RuntimeUnavailableError)) throw err;
8080
8145
  turnLog.error(
@@ -8093,17 +8158,18 @@ ${reason}`,
8093
8158
  { err: postErr instanceof Error ? postErr.message : String(postErr) },
8094
8159
  "dispatcher: runtime-unavailable notice post failed"
8095
8160
  );
8161
+ const reason = `runtime_unavailable:${err.runtime}`;
8162
+ throw this.concluded(reason, reason);
8096
8163
  }
8097
- releaseAbort();
8098
- return this.concludeBeforeRun(
8099
- payload,
8100
- turnLog,
8101
- startedAt,
8102
- `runtime_unavailable:${err.runtime}`
8103
- );
8164
+ throw this.concluded(`runtime_unavailable:${err.runtime}`);
8104
8165
  }
8166
+ }
8167
+ async execute() {
8168
+ const { payload, workspaceId, turnId, turnLog, startedAt } = this;
8169
+ const o = this.outcome;
8170
+ const abortController = this.abortController;
8105
8171
  const totalTimeoutMs = this.opts.totalTimeoutMs ?? DEFAULT_AGENT_TOTAL_TIMEOUT_MS;
8106
- const transcript = this.opts.transcriptDir ? new TranscriptWriter(
8172
+ const transcript = this.transcript = this.opts.transcriptDir ? new TranscriptWriter(
8107
8173
  this.opts.transcriptDir,
8108
8174
  {
8109
8175
  ts: new Date(startedAt).toISOString(),
@@ -8111,36 +8177,13 @@ ${reason}`,
8111
8177
  workspaceId,
8112
8178
  conversationId: payload.conversationId,
8113
8179
  agentId: payload.agentId,
8114
- dispatchId,
8115
- message: message.body
8180
+ dispatchId: this.dispatchId,
8181
+ message: this.turnContext.message.body
8116
8182
  },
8117
8183
  (m) => turnLog.warn(m)
8118
8184
  ) : null;
8119
- let sessionWritten = false;
8120
- let sessionDegraded = false;
8121
- let sessionWriteRejected = false;
8122
- let okResult = false;
8123
- let resultReason;
8124
- const turnRuntime = turnContext.runtime;
8125
- let turnUsage;
8126
- let turnResolvedModel;
8127
- let turnResolvedConfig;
8128
- let turnMcpInventory;
8129
- const eventCounts = {
8130
- session: 0,
8131
- text: 0,
8132
- thinking: 0,
8133
- tool: 0,
8134
- result: 0
8135
- };
8136
- let runtimeResultKind = null;
8137
- let contentBearingEvents = 0;
8138
- let latestSessionState = request.session;
8139
- let settledDiagnostics = null;
8140
- let silentMarkerEmitted = false;
8141
- let noteCommitFailed = () => {
8142
- };
8143
- const committer = new TurnCommitter({
8185
+ const turnRuntime = this.turnContext.runtime;
8186
+ const committer = this.committer = new TurnCommitter({
8144
8187
  api: this.opts.api,
8145
8188
  workspaceId,
8146
8189
  conversationId: payload.conversationId,
@@ -8151,29 +8194,32 @@ ${reason}`,
8151
8194
  parentMessageId: payload.messageId,
8152
8195
  signal: abortController.signal,
8153
8196
  log: turnLog,
8154
- nextSeq,
8197
+ nextSeq: this.nextSeq,
8155
8198
  // The committer reads this at commit to carry the addressed send on the
8156
8199
  // terminal row; the server writes the send itself as a distinct message.
8157
- sendState,
8200
+ sendState: this.sendState,
8158
8201
  // CT326: likewise the ask payload. CT1281: the server writes the ask as its
8159
8202
  // own addressed message to the human — which ENQUEUES like any other send —
8160
8203
  // and creates the `asks` row against that carrier, not against turn speech.
8161
- askState,
8204
+ askState: this.askState,
8162
8205
  // CT442: likewise the wake payload — attached to the `final` row so the
8163
8206
  // server arms the wake schedule atomically with the reply it rode on.
8164
- wakeState,
8165
- replyState,
8207
+ wakeState: this.wakeState,
8208
+ replyState: this.replyState,
8166
8209
  // The ledger-derived owed reply, for the runtime's own declaration when
8167
8210
  // the agent doesn't call `reply_to` (resolveDeclaredReplyField).
8168
- owedReplyMessageId: turnContext.owedReplyMessageId ?? null,
8211
+ owedReplyMessageId: this.turnContext.owedReplyMessageId ?? null,
8169
8212
  // CT1292: a commit that didn't land may mean this turn's lease is gone.
8170
- onCommitFailed: (err) => noteCommitFailed(err)
8213
+ // `noteCommitFailed` is a field slot the lease watchdog fills when it
8214
+ // arms (it starts as a no-op) — the committer only fires it from inside
8215
+ // the adapter loop, which runs after the watchdog has replaced it.
8216
+ onCommitFailed: (err) => this.noteCommitFailed(err)
8171
8217
  });
8172
8218
  const usesHttpTurnControl = turnRuntime === "codex" || turnRuntime === "opencode";
8173
- let turnControlIntentFetched = false;
8174
- const applyRecordedTurnControlIntent = async () => {
8175
- if (turnControlIntentFetched || !usesHttpTurnControl || !turnContext.turnToken) return;
8176
- turnControlIntentFetched = true;
8219
+ this.applyRecordedTurnControlIntent = async () => {
8220
+ if (this.turnControlIntentFetched || !usesHttpTurnControl || !this.turnContext.turnToken)
8221
+ return;
8222
+ this.turnControlIntentFetched = true;
8177
8223
  try {
8178
8224
  const intent = await this.opts.api.getTurnIntent(
8179
8225
  workspaceId,
@@ -8182,44 +8228,44 @@ ${reason}`,
8182
8228
  turnId
8183
8229
  );
8184
8230
  if (intent.ask) {
8185
- askState.targetUserId = intent.ask.targetUserId;
8231
+ this.askState.targetUserId = intent.ask.targetUserId;
8186
8232
  if (intent.ask.questions && intent.ask.questions.length > 0) {
8187
- askState.questions = intent.ask.questions;
8188
- askState.question = null;
8189
- askState.headline = null;
8190
- askState.options = null;
8233
+ this.askState.questions = intent.ask.questions;
8234
+ this.askState.question = null;
8235
+ this.askState.headline = null;
8236
+ this.askState.options = null;
8191
8237
  } else {
8192
- askState.question = intent.ask.question ?? null;
8193
- askState.headline = intent.ask.headline ?? null;
8194
- askState.options = intent.ask.options ?? null;
8195
- askState.questions = null;
8238
+ this.askState.question = intent.ask.question ?? null;
8239
+ this.askState.headline = intent.ask.headline ?? null;
8240
+ this.askState.options = intent.ask.options ?? null;
8241
+ this.askState.questions = null;
8196
8242
  }
8197
8243
  }
8198
8244
  if (intent.wake) {
8199
8245
  if ("cancel" in intent.wake) {
8200
- wakeState.cancelled = true;
8201
- wakeState.afterSeconds = null;
8202
- wakeState.at = null;
8203
- wakeState.note = null;
8246
+ this.wakeState.cancelled = true;
8247
+ this.wakeState.afterSeconds = null;
8248
+ this.wakeState.at = null;
8249
+ this.wakeState.note = null;
8204
8250
  } else {
8205
- wakeState.cancelled = false;
8206
- wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
8207
- wakeState.at = intent.wake.at ?? null;
8208
- wakeState.note = intent.wake.note;
8251
+ this.wakeState.cancelled = false;
8252
+ this.wakeState.afterSeconds = intent.wake.afterSeconds ?? null;
8253
+ this.wakeState.at = intent.wake.at ?? null;
8254
+ this.wakeState.note = intent.wake.note;
8209
8255
  }
8210
8256
  }
8211
8257
  if (intent.sendAgentId && intent.sendBody) {
8212
- sendState.agentId = intent.sendAgentId;
8213
- sendState.message = intent.sendBody;
8214
- sendState.order = intent.sendOrder ?? null;
8258
+ this.sendState.agentId = intent.sendAgentId;
8259
+ this.sendState.message = intent.sendBody;
8260
+ this.sendState.order = intent.sendOrder ?? null;
8215
8261
  }
8216
8262
  if (intent.answersMessageId) {
8217
- replyState.answersMessageId = intent.answersMessageId;
8218
- replyState.order = intent.replyOrder ?? null;
8263
+ this.replyState.answersMessageId = intent.answersMessageId;
8264
+ this.replyState.order = intent.replyOrder ?? null;
8219
8265
  }
8220
8266
  if (intent.skipped) {
8221
- skipState.skipped = true;
8222
- skipState.reason = intent.skipReason;
8267
+ this.skipState.skipped = true;
8268
+ this.skipState.reason = intent.skipReason;
8223
8269
  }
8224
8270
  } catch (err) {
8225
8271
  turnLog.warn(
@@ -8231,7 +8277,7 @@ ${reason}`,
8231
8277
  const idleTimeoutMs = this.opts.idleTimeoutMs ?? DEFAULT_AGENT_IDLE_TIMEOUT_MS;
8232
8278
  const fireTimeout = (reason) => {
8233
8279
  if (abortController.signal.aborted) return;
8234
- timeoutReason = reason;
8280
+ o.timeoutReason = reason;
8235
8281
  turnLog.warn(
8236
8282
  { reason, idleTimeoutMs, totalTimeoutMs },
8237
8283
  "dispatcher: turn timeout \u2014 aborting"
@@ -8250,7 +8296,7 @@ ${reason}`,
8250
8296
  const leaseRenewalMs = this.opts.leaseRenewalMs ?? DEFAULT_LEASE_RENEWAL_MS;
8251
8297
  let leaseCheckInFlight = false;
8252
8298
  const checkLease = async (trigger) => {
8253
- if (leaseLost || leaseCheckInFlight || abortController.signal.aborted) return;
8299
+ if (o.leaseLost || leaseCheckInFlight || abortController.signal.aborted) return;
8254
8300
  leaseCheckInFlight = true;
8255
8301
  try {
8256
8302
  const state = await this.opts.api.checkTurnLease(
@@ -8261,7 +8307,7 @@ ${reason}`,
8261
8307
  );
8262
8308
  if (state !== "ended") return;
8263
8309
  if (abortController.signal.aborted) return;
8264
- leaseLost = true;
8310
+ o.leaseLost = true;
8265
8311
  turnLog.warn(
8266
8312
  { turnId, trigger },
8267
8313
  "dispatcher: this turn is no longer running server-side \u2014 aborting the loop"
@@ -8273,26 +8319,31 @@ ${reason}`,
8273
8319
  };
8274
8320
  const leaseTimer = setInterval(() => void checkLease("cadence"), leaseRenewalMs);
8275
8321
  leaseTimer.unref?.();
8276
- noteCommitFailed = (err) => {
8322
+ this.noteCommitFailed = (err) => {
8277
8323
  if (isWriteFenceRefusal(err)) void checkLease("commit_refused");
8278
8324
  };
8325
+ this.disarmWatchdogs = () => {
8326
+ if (idleTimer) clearTimeout(idleTimer);
8327
+ clearTimeout(totalTimer);
8328
+ clearInterval(leaseTimer);
8329
+ };
8279
8330
  try {
8280
- for await (const event of adapter.runTurn(request, abortController.signal)) {
8331
+ for await (const event of this.adapter.runTurn(this.request, abortController.signal)) {
8281
8332
  transcript?.write(event);
8282
- eventCounts[event.type] += 1;
8283
- if (isContentBearingEvent(event)) contentBearingEvents += 1;
8333
+ o.eventCounts[event.type] += 1;
8334
+ if (isContentBearingEvent(event)) o.contentBearingEvents += 1;
8284
8335
  armIdle();
8285
8336
  if (abortController.signal.aborted) {
8286
8337
  turnLog.info("dispatcher: aborted mid-turn");
8287
- okResult = false;
8288
- resultReason = timeoutReason ?? "cancelled";
8338
+ o.okResult = false;
8339
+ o.resultReason = o.timeoutReason ?? "cancelled";
8289
8340
  break;
8290
8341
  }
8291
8342
  if (event.type === "session") {
8292
- latestSessionState = event.state;
8293
- if (event.degraded) sessionDegraded = true;
8294
- if (!sessionWritten) {
8295
- sessionWritten = true;
8343
+ o.latestSessionState = event.state;
8344
+ if (event.degraded) o.sessionDegraded = true;
8345
+ if (!o.sessionWritten) {
8346
+ o.sessionWritten = true;
8296
8347
  try {
8297
8348
  await this.opts.api.setActiveRun(
8298
8349
  workspaceId,
@@ -8303,7 +8354,7 @@ ${reason}`,
8303
8354
  } catch (err) {
8304
8355
  const status = err instanceof ApiError ? err.status : void 0;
8305
8356
  if (status !== void 0 && status >= 400 && status < 500) {
8306
- sessionWriteRejected = true;
8357
+ o.sessionWriteRejected = true;
8307
8358
  turnLog.error(
8308
8359
  {
8309
8360
  err: err instanceof Error ? err.message : String(err),
@@ -8323,48 +8374,52 @@ ${reason}`,
8323
8374
  }
8324
8375
  }
8325
8376
  } else if (event.type === "result") {
8326
- okResult = event.ok;
8327
- resultReason = event.reason;
8328
- turnUsage = event.usage;
8329
- turnResolvedModel = event.resolvedModel;
8330
- turnResolvedConfig = event.resolvedConfig;
8331
- turnMcpInventory = event.mcpInventory;
8332
- runtimeResultKind = event.ok ? "success" : event.reason === "no_terminal" ? "no_terminal" : "error";
8333
- } else if (event.type === "text" && skipState.skipped) {
8377
+ o.okResult = event.ok;
8378
+ o.resultReason = event.reason;
8379
+ o.turnUsage = event.usage;
8380
+ o.turnResolvedModel = event.resolvedModel;
8381
+ o.turnResolvedConfig = event.resolvedConfig;
8382
+ o.turnMcpInventory = event.mcpInventory;
8383
+ o.runtimeResultKind = event.ok ? "success" : event.reason === "no_terminal" ? "no_terminal" : "error";
8384
+ } else if (event.type === "text" && this.skipState.skipped) {
8334
8385
  } else {
8335
8386
  if (event.type === "text" && event.terminal) {
8336
- await applyRecordedTurnControlIntent();
8387
+ await this.applyRecordedTurnControlIntent();
8337
8388
  }
8338
8389
  await committer.ingestEvent(event);
8339
8390
  }
8340
8391
  }
8341
8392
  if (abortController.signal.aborted) {
8342
- okResult = false;
8343
- resultReason = leaseLost ? "lease_lost" : timeoutReason ?? "cancelled";
8393
+ o.okResult = false;
8394
+ o.resultReason = o.leaseLost ? "lease_lost" : o.timeoutReason ?? "cancelled";
8344
8395
  }
8345
- const emptyResultReason = !skipState.skipped && classifyEmptyResult({ ok: okResult, contentBearingEvents, usage: turnUsage });
8396
+ const emptyResultReason = !this.skipState.skipped && classifyEmptyResult({
8397
+ ok: o.okResult,
8398
+ contentBearingEvents: o.contentBearingEvents,
8399
+ usage: o.turnUsage
8400
+ });
8346
8401
  if (emptyResultReason) {
8347
- okResult = false;
8348
- resultReason = emptyResultReason;
8402
+ o.okResult = false;
8403
+ o.resultReason = emptyResultReason;
8349
8404
  }
8350
- if (!okResult && !resultReason) {
8351
- resultReason = "no_result";
8405
+ if (!o.okResult && !o.resultReason) {
8406
+ o.resultReason = "no_result";
8352
8407
  }
8353
8408
  if (!abortController.signal.aborted) {
8354
- await applyRecordedTurnControlIntent();
8409
+ await this.applyRecordedTurnControlIntent();
8355
8410
  }
8356
- if (!abortController.signal.aborted && skipState.skipped) {
8411
+ if (!abortController.signal.aborted && this.skipState.skipped) {
8357
8412
  turnLog.info(
8358
- { reason: skipState.reason, turnId, ok: okResult },
8413
+ { reason: this.skipState.reason, turnId, ok: o.okResult },
8359
8414
  "agent skipped turn (skip_turn)"
8360
8415
  );
8361
- const { wake: skipWake } = wakeCommitField(wakeState);
8416
+ const { wake: skipWake } = wakeCommitField(this.wakeState);
8362
8417
  try {
8363
8418
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8364
8419
  body: SKIPPED_MARKER_BODY,
8365
8420
  kind: "skipped",
8366
8421
  turnId,
8367
- seq: nextSeq(),
8422
+ seq: this.nextSeq(),
8368
8423
  parentMessageId: payload.messageId,
8369
8424
  ...skipWake ? { wake: skipWake } : {}
8370
8425
  });
@@ -8375,21 +8430,21 @@ ${reason}`,
8375
8430
  );
8376
8431
  }
8377
8432
  } else {
8378
- await committer.finalize(okResult);
8379
- if (!abortController.signal.aborted && okResult && !committer.finalEmitted) {
8433
+ await committer.finalize(o.okResult);
8434
+ if (!abortController.signal.aborted && o.okResult && !committer.finalEmitted) {
8380
8435
  try {
8381
8436
  await this.opts.api.postTurnMessage(workspaceId, payload.conversationId, {
8382
8437
  body: SILENT_MARKER_BODY,
8383
8438
  kind: "silent",
8384
8439
  turnId,
8385
- seq: nextSeq(),
8440
+ seq: this.nextSeq(),
8386
8441
  parentMessageId: payload.messageId,
8387
8442
  // ask, wake and send must survive a wordless turn exactly as
8388
8443
  // they survive a textual final; dropping one can strand a person
8389
8444
  // or the next actor with no visible failure.
8390
8445
  ...committer.turnControlFields("silent")
8391
8446
  });
8392
- silentMarkerEmitted = true;
8447
+ o.silentMarkerEmitted = true;
8393
8448
  } catch (err) {
8394
8449
  turnLog.warn(
8395
8450
  { err: err instanceof Error ? err.message : String(err) },
@@ -8399,27 +8454,25 @@ ${reason}`,
8399
8454
  }
8400
8455
  }
8401
8456
  } catch (err) {
8402
- okResult = false;
8403
- resultReason = err instanceof Error ? err.message : String(err);
8404
- turnLog.error({ err: resultReason }, "dispatcher: SDK query threw");
8457
+ o.okResult = false;
8458
+ o.resultReason = err instanceof Error ? err.message : String(err);
8459
+ turnLog.error({ err: o.resultReason }, "dispatcher: SDK query threw");
8405
8460
  } finally {
8406
- if (idleTimer) clearTimeout(idleTimer);
8407
- clearTimeout(totalTimer);
8408
- clearInterval(leaseTimer);
8409
- if (leaseLost) {
8410
- resultReason = "lease_lost";
8411
- okResult = false;
8461
+ this.disarmWatchdogs();
8462
+ if (o.leaseLost) {
8463
+ o.resultReason = "lease_lost";
8464
+ o.okResult = false;
8412
8465
  }
8413
- const userCancelled = abortController.signal.aborted && timeoutReason === null && !leaseLost;
8414
- if (timeoutReason !== null) {
8415
- resultReason = timeoutReason;
8416
- okResult = false;
8417
- if (timeoutReason === "timeout_idle") {
8466
+ const userCancelled = abortController.signal.aborted && o.timeoutReason === null && !o.leaseLost;
8467
+ if (o.timeoutReason !== null) {
8468
+ o.resultReason = o.timeoutReason;
8469
+ o.okResult = false;
8470
+ if (o.timeoutReason === "timeout_idle") {
8418
8471
  const health = this.opts.connectorHealth?.lookup(turnRuntime);
8419
8472
  if (health?.quotaState === "limited" && health.limitedUntil) {
8420
8473
  const resetMs = Date.parse(health.limitedUntil);
8421
8474
  if (Number.isFinite(resetMs) && resetMs > Date.now()) {
8422
- resultReason = encodeFailureReason({
8475
+ o.resultReason = encodeFailureReason({
8423
8476
  kind: "usage_capped",
8424
8477
  resetsAt: health.limitedUntil
8425
8478
  });
@@ -8443,42 +8496,41 @@ ${reason}`,
8443
8496
  // turn is just as over, and its evidence must die with it.
8444
8497
  settledMessageId: payload.messageId
8445
8498
  };
8446
- if (turnUsage) {
8447
- body.usage = turnUsage;
8499
+ if (o.turnUsage) {
8500
+ body.usage = o.turnUsage;
8448
8501
  }
8449
- if (turnResolvedModel) {
8450
- body.resolvedModel = turnResolvedModel;
8502
+ if (o.turnResolvedModel) {
8503
+ body.resolvedModel = o.turnResolvedModel;
8451
8504
  }
8452
- if (turnResolvedConfig && Object.keys(turnResolvedConfig).length > 0) {
8453
- body.resolvedConfig = turnResolvedConfig;
8505
+ if (o.turnResolvedConfig && Object.keys(o.turnResolvedConfig).length > 0) {
8506
+ body.resolvedConfig = o.turnResolvedConfig;
8454
8507
  }
8455
- if (!okResult && resultReason && resultReason !== "cancelled" && !userCancelled && !leaseLost && !skipState.skipped) {
8456
- body.errorReason = resultReason.slice(0, 200);
8508
+ if (!o.okResult && o.resultReason && o.resultReason !== "cancelled" && !userCancelled && !o.leaseLost && !this.skipState.skipped) {
8509
+ body.errorReason = o.resultReason.slice(0, 200);
8457
8510
  }
8458
- if (okResult) {
8511
+ if (o.okResult) {
8459
8512
  body.lastSeenMessageId = payload.messageId;
8460
8513
  }
8461
- body.outcome = okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
8462
- if (sessionDegraded) {
8514
+ body.outcome = o.okResult ? "settled" : body.errorReason ? "failed" : "interrupted";
8515
+ if (o.sessionDegraded) {
8463
8516
  body.degraded = true;
8464
8517
  }
8465
- if (sessionWriteRejected && !this.sessionWriteNotified.has(key)) {
8518
+ if (o.sessionWriteRejected && this.supervisor.claimSessionWriteNotice()) {
8466
8519
  body.sessionWriteRejected = true;
8467
- this.sessionWriteNotified.add(key);
8468
8520
  }
8469
- const outcome = skipState.skipped ? "skipped" : userCancelled || leaseLost ? "cancelled" : okResult ? "success" : "failure";
8470
- const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? leaseLost ? { kind: "lease_lost" } : { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(resultReason) : null;
8471
- settledDiagnostics = {
8521
+ const outcome = this.skipState.skipped ? "skipped" : userCancelled || o.leaseLost ? "cancelled" : o.okResult ? "success" : "failure";
8522
+ const diagnosticReason = outcome === "skipped" ? { kind: "skipped" } : outcome === "cancelled" ? o.leaseLost ? { kind: "lease_lost" } : { kind: "cancelled" } : outcome === "failure" ? normalizeTurnResultReason(o.resultReason) : null;
8523
+ o.settledDiagnostics = {
8472
8524
  outcome,
8473
8525
  resultReason: diagnosticReason,
8474
- sessionMode: sessionDegraded ? "degraded" : request.session ? "resumed" : "fresh",
8475
- sessionFingerprint: fingerprintSessionState(latestSessionState),
8476
- eventCounts,
8477
- runtimeResultKind,
8478
- ...turnMcpInventory ? { mcpInventory: turnMcpInventory } : {},
8479
- finalSource: outcome === "skipped" || outcome === "cancelled" || silentMarkerEmitted ? "marker" : committer.finalSource
8526
+ sessionMode: o.sessionDegraded ? "degraded" : this.request.session ? "resumed" : "fresh",
8527
+ sessionFingerprint: fingerprintSessionState(o.latestSessionState),
8528
+ eventCounts: o.eventCounts,
8529
+ runtimeResultKind: o.runtimeResultKind,
8530
+ ...o.turnMcpInventory ? { mcpInventory: o.turnMcpInventory } : {},
8531
+ finalSource: outcome === "skipped" || outcome === "cancelled" || o.silentMarkerEmitted ? "marker" : committer.finalSource
8480
8532
  };
8481
- body.diagnostics = settledDiagnostics;
8533
+ body.diagnostics = o.settledDiagnostics;
8482
8534
  if (diagnosticReason && !["usage_capped", "rate_limited", "auth_expired", "cancelled", "skipped"].includes(
8483
8535
  diagnosticReason.kind
8484
8536
  )) {
@@ -8489,16 +8541,16 @@ ${reason}`,
8489
8541
  conversationId: payload.conversationId,
8490
8542
  agentId: payload.agentId,
8491
8543
  runtime: turnRuntime,
8492
- model: turnResolvedModel ?? null,
8493
- usage: turnUsage ?? null,
8494
- hadStoredSession: request.session != null,
8495
- diagnostics: settledDiagnostics
8544
+ model: o.turnResolvedModel ?? null,
8545
+ usage: o.turnUsage ?? null,
8546
+ hadStoredSession: this.request.session != null,
8547
+ diagnostics: o.settledDiagnostics
8496
8548
  },
8497
8549
  "dispatcher: anomalous turn settled"
8498
8550
  );
8499
8551
  }
8500
8552
  this.opts.connectorHealth?.recordSettle(turnRuntime, {
8501
- ok: okResult,
8553
+ ok: o.okResult,
8502
8554
  errorReason: body.errorReason ?? null
8503
8555
  });
8504
8556
  try {
@@ -8514,45 +8566,151 @@ ${reason}`,
8514
8566
  "dispatcher: active-run clear failed terminally; server age-sweep is the backstop"
8515
8567
  );
8516
8568
  }
8517
- releaseAbort();
8569
+ this.supervisor.releaseAbort(turnId, abortController);
8518
8570
  }
8571
+ }
8572
+ report() {
8573
+ const o = this.outcome;
8574
+ const { startedAt, turnLog } = this;
8519
8575
  const durationMs = Date.now() - startedAt;
8520
- const finalReplyBody = committer.replyBody;
8521
- if (transcript) {
8522
- transcript.close({
8523
- ok: okResult,
8524
- ...resultReason ? { reason: resultReason } : {},
8576
+ const finalReplyBody = this.committer.replyBody;
8577
+ if (this.transcript) {
8578
+ this.transcript.close({
8579
+ ok: o.okResult,
8580
+ ...o.resultReason ? { reason: o.resultReason } : {},
8525
8581
  durationMs
8526
8582
  });
8527
- if (!okResult && resultReason !== "cancelled") {
8528
- turnLog.info(`turn failed \u2014 full transcript: ${transcript.path}`);
8583
+ if (!o.okResult && o.resultReason !== "cancelled") {
8584
+ turnLog.info(`turn failed \u2014 full this.transcript: ${this.transcript.path}`);
8529
8585
  }
8530
- if (settledDiagnostics?.resultReason?.kind === "empty_result" || settledDiagnostics?.resultReason?.kind === "empty_result_unverified") {
8531
- transcript.preserveAnomaly();
8586
+ if (o.settledDiagnostics?.resultReason?.kind === "empty_result" || o.settledDiagnostics?.resultReason?.kind === "empty_result_unverified") {
8587
+ this.transcript.preserveAnomaly();
8532
8588
  }
8533
8589
  }
8534
- if (okResult) {
8590
+ if (o.okResult) {
8535
8591
  turnLog.debug({ durationMs }, "dispatcher: turn end (ok)");
8536
- this.notifyEnd({
8537
- id: dispatchId,
8592
+ this.supervisor.notifyEnd({
8593
+ id: this.dispatchId,
8538
8594
  ok: true,
8539
8595
  durationMs,
8540
8596
  ...finalReplyBody ? { reply: finalReplyBody.trim() } : {}
8541
8597
  });
8542
8598
  return { ok: true, durationMs };
8543
8599
  }
8544
- turnLog.debug({ durationMs, reason: resultReason }, "dispatcher: turn end (not ok)");
8545
- this.notifyEnd({
8546
- id: dispatchId,
8600
+ turnLog.debug({ durationMs, reason: o.resultReason }, "dispatcher: turn end (not ok)");
8601
+ this.supervisor.notifyEnd({
8602
+ id: this.dispatchId,
8547
8603
  ok: false,
8548
8604
  durationMs,
8549
- ...resultReason ? { reason: resultReason } : {}
8605
+ ...o.resultReason ? { reason: o.resultReason } : {}
8550
8606
  });
8551
8607
  return {
8552
8608
  ok: false,
8553
8609
  durationMs,
8554
- ...resultReason ? { reason: resultReason } : {}
8610
+ ...o.resultReason ? { reason: o.resultReason } : {}
8611
+ };
8612
+ }
8613
+ };
8614
+ function fingerprintSessionState(state) {
8615
+ if (!state) return null;
8616
+ let opaqueId = state;
8617
+ try {
8618
+ const parsed = JSON.parse(state);
8619
+ const candidate = parsed.sdkSessionId ?? parsed.threadId ?? parsed.sessionId;
8620
+ if (typeof candidate === "string" && candidate.length > 0) opaqueId = candidate;
8621
+ } catch {
8622
+ }
8623
+ return createHash2("sha256").update(opaqueId).digest("hex").slice(0, 16);
8624
+ }
8625
+
8626
+ // src/dispatcher.ts
8627
+ function checkoutState(cwd) {
8628
+ if (!cwd) return { ok: false, reason: "no working directory was resolved for this turn" };
8629
+ if (!existsSync11(cwd)) return { ok: false, reason: `the working directory is gone (${cwd})` };
8630
+ let entries;
8631
+ try {
8632
+ entries = readdirSync2(cwd);
8633
+ } catch (error) {
8634
+ return { ok: false, reason: `${cwd} is unreadable (${error.message})` };
8635
+ }
8636
+ if (entries.length === 0) {
8637
+ return { ok: false, reason: `${cwd} is empty \u2014 a recreated shell, not a prepared directory` };
8638
+ }
8639
+ const gitPath = join14(cwd, ".git");
8640
+ if (!existsSync11(gitPath)) return { ok: true, reason: "usable" };
8641
+ let stat;
8642
+ try {
8643
+ stat = statSync(gitPath);
8644
+ } catch (error) {
8645
+ return { ok: false, reason: `${gitPath} is unreadable (${error.message})` };
8646
+ }
8647
+ if (stat.isDirectory() && !existsSync11(join14(gitPath, "HEAD")))
8648
+ return { ok: false, reason: `${gitPath} has no HEAD \u2014 an empty shell, not a checkout` };
8649
+ return { ok: true, reason: "usable" };
8650
+ }
8651
+ function runKey(conversationId, agentId) {
8652
+ return `${conversationId}|${agentId}`;
8653
+ }
8654
+ var Dispatcher = class {
8655
+ constructor(opts) {
8656
+ this.opts = opts;
8657
+ this.aborts = opts.aborts ?? /* @__PURE__ */ new Map();
8658
+ }
8659
+ opts;
8660
+ // CT1288: keyed (conversation|agent) → per-TURN controllers. Stop is
8661
+ // pair-scoped and must reach every live loop for the pair.
8662
+ aborts;
8663
+ // CT1109: pairs already told "your session couldn't be saved" — the notice is
8664
+ // once per (conversation, agent), while the rejection repeats every turn.
8665
+ sessionWriteNotified = /* @__PURE__ */ new Set();
8666
+ notifyStart(info) {
8667
+ try {
8668
+ this.opts.observer?.onStart(info);
8669
+ } catch {
8670
+ }
8671
+ }
8672
+ notifyEnd(info) {
8673
+ try {
8674
+ this.opts.observer?.onEnd(info);
8675
+ } catch {
8676
+ }
8677
+ }
8678
+ // CT1288: `opts.turnId` is a RESUMED turn id — the id this event already ran
8679
+ // under before the companion died. Passing it makes the replay ask the
8680
+ // mailroom to re-admit the turn it already owns, which the server explicitly
8681
+ // supports ("a replay reports, it never re-points"). Omitted on a fresh
8682
+ // dispatch, where minting is correct.
8683
+ //
8684
+ // CT1261: a short `handle` creates a `TurnExecution` and runs it. The
8685
+ // supervisor seam hands the execution exactly the three cross-turn duties —
8686
+ // the abort registry, the session-write notice latch, the observer — and
8687
+ // nothing else.
8688
+ async handle(payload, opts = {}) {
8689
+ const key = runKey(payload.conversationId, payload.agentId);
8690
+ const supervisor = {
8691
+ registerAbort: (turnId, controller) => {
8692
+ let pairAborts = this.aborts.get(key);
8693
+ if (!pairAborts) {
8694
+ pairAborts = /* @__PURE__ */ new Map();
8695
+ this.aborts.set(key, pairAborts);
8696
+ }
8697
+ pairAborts.set(turnId, controller);
8698
+ },
8699
+ releaseAbort: (turnId, controller) => {
8700
+ const pair = this.aborts.get(key);
8701
+ if (pair?.get(turnId) !== controller) return;
8702
+ pair.delete(turnId);
8703
+ if (pair.size === 0) this.aborts.delete(key);
8704
+ },
8705
+ claimSessionWriteNotice: () => {
8706
+ if (this.sessionWriteNotified.has(key)) return false;
8707
+ this.sessionWriteNotified.add(key);
8708
+ return true;
8709
+ },
8710
+ notifyStart: (info) => this.notifyStart(info),
8711
+ notifyEnd: (info) => this.notifyEnd(info)
8555
8712
  };
8713
+ return new TurnExecution(this.opts, supervisor, payload, opts).run();
8556
8714
  }
8557
8715
  // SJ383: cancel a specific (conversation, agent) run if one is in flight in
8558
8716
  // THIS companion process. Returns true if an in-flight run was aborted.
@@ -8569,17 +8727,6 @@ ${reason}`,
8569
8727
  return true;
8570
8728
  }
8571
8729
  };
8572
- function fingerprintSessionState(state) {
8573
- if (!state) return null;
8574
- let opaqueId = state;
8575
- try {
8576
- const parsed = JSON.parse(state);
8577
- const candidate = parsed.sdkSessionId ?? parsed.threadId ?? parsed.sessionId;
8578
- if (typeof candidate === "string" && candidate.length > 0) opaqueId = candidate;
8579
- } catch {
8580
- }
8581
- return createHash2("sha256").update(opaqueId).digest("hex").slice(0, 16);
8582
- }
8583
8730
 
8584
8731
  // src/opencode-models.ts
8585
8732
  var OPENCODE_RUNTIME = "opencode";
@@ -8624,7 +8771,7 @@ async function enumerateOpencodeModels(serverUrl, fetchImpl = fetch) {
8624
8771
 
8625
8772
  // src/outbox.ts
8626
8773
  import {
8627
- existsSync as existsSync11,
8774
+ existsSync as existsSync12,
8628
8775
  mkdirSync as mkdirSync10,
8629
8776
  readdirSync as readdirSync3,
8630
8777
  readFileSync as readFileSync8,
@@ -8680,7 +8827,7 @@ var Outbox = class {
8680
8827
  // wedging the drain.
8681
8828
  list() {
8682
8829
  const dir2 = this.dir();
8683
- if (!existsSync11(dir2)) return [];
8830
+ if (!existsSync12(dir2)) return [];
8684
8831
  let names;
8685
8832
  try {
8686
8833
  names = readdirSync3(dir2);
@@ -8716,7 +8863,7 @@ var Outbox = class {
8716
8863
  }
8717
8864
  size() {
8718
8865
  const dir2 = this.dir();
8719
- if (!existsSync11(dir2)) return 0;
8866
+ if (!existsSync12(dir2)) return 0;
8720
8867
  try {
8721
8868
  return readdirSync3(dir2).filter((n) => n.endsWith(".json")).length;
8722
8869
  } catch {
@@ -9956,7 +10103,7 @@ function handleUncaught(log, err, origin) {
9956
10103
  }
9957
10104
 
9958
10105
  // src/crash-marker.ts
9959
- import { existsSync as existsSync12, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
10106
+ import { existsSync as existsSync13, mkdirSync as mkdirSync11, readFileSync as readFileSync9, rmSync as rmSync8, writeFileSync as writeFileSync8 } from "fs";
9960
10107
  import { join as join16 } from "path";
9961
10108
  function crashMarkerPath() {
9962
10109
  return join16(cabaneDir(), "last-error.json");
@@ -9971,7 +10118,7 @@ function recordCrash(rec) {
9971
10118
  function clearCrash() {
9972
10119
  try {
9973
10120
  const path = crashMarkerPath();
9974
- if (existsSync12(path)) rmSync8(path, { force: true });
10121
+ if (existsSync13(path)) rmSync8(path, { force: true });
9975
10122
  } catch {
9976
10123
  }
9977
10124
  }