sortie-dogs 0.12.4 → 0.12.6

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.
@@ -67,8 +67,8 @@ export declare class FailureSwarmRuntime {
67
67
  probe_iterations: number;
68
68
  model_attempts: number;
69
69
  }>;
70
- capsule_id: string;
71
70
  proposal: string;
71
+ capsule_id: string;
72
72
  recovery_kind: Exclude<import("./run-flight-ledger.js").RecoveryKind, "read_only_diagnosis">;
73
73
  diagnosis_id: string;
74
74
  plan_id: string;
@@ -22,6 +22,8 @@ export interface OperatorMission {
22
22
  callID: string | null;
23
23
  dispatchOpen: boolean;
24
24
  runID: string | null;
25
+ /** A cancelled run replaced by a later real user turn; never reuse its acceptance or evidence. */
26
+ supersededRunID?: string;
25
27
  plans: number;
26
28
  progress: {
27
29
  unit: string;
@@ -51,6 +53,10 @@ export declare class OperatorMissionRuntime {
51
53
  constructor(projectRoot: string, profile: RuntimeProfile);
52
54
  private file;
53
55
  private load;
56
+ /** Recover the predecessor link for missions started before this fix, using one exact archived mission. */
57
+ private loadMission;
58
+ /** Find exactly one archived cancelled mission that owned this run; ambiguity never grants recovery. */
59
+ archivedRun(root: string, runID: string): Promise<OperatorMission | undefined>;
54
60
  private save;
55
61
  private serial;
56
62
  read(root: string): Promise<OperatorMission | undefined>;
@@ -1,5 +1,5 @@
1
1
  import { createHash, randomUUID } from "node:crypto";
2
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
2
+ import { mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
3
3
  import { join } from "node:path";
4
4
  import { normalizeRelativePath } from "./path.js";
5
5
  import { parseOperatorPlan } from "./operator-runtime.js";
@@ -40,6 +40,59 @@ export class OperatorMissionRuntime {
40
40
  throw error;
41
41
  }
42
42
  }
43
+ /** Recover the predecessor link for missions started before this fix, using one exact archived mission. */
44
+ async loadMission(root) {
45
+ const state = await this.load(this.file(root));
46
+ if (!state || state.supersededRunID !== undefined || state.runID !== null ||
47
+ !["open", "running", "submitted"].includes(state.phase) || state.requests.length === 0)
48
+ return state;
49
+ const directory = join(this.projectRoot, this.profile.stateDirectory, "missions");
50
+ let entries;
51
+ try {
52
+ entries = await readdir(directory);
53
+ }
54
+ catch (error) {
55
+ if (error.code === "ENOENT")
56
+ return state;
57
+ throw error;
58
+ }
59
+ const prefix = `${digest(root)}.mission-`;
60
+ const candidates = entries.filter(name => name.startsWith(prefix) && /^mission-[a-f0-9-]+\.json$/u.test(name.slice(digest(root).length + 1)));
61
+ const matches = [];
62
+ for (const name of candidates) {
63
+ const previous = await this.load(join(directory, name));
64
+ if (previous?.root === root && previous.phase === "cancelled" && previous.runID !== null &&
65
+ previous.requests[0]?.id !== state.requests[0].id &&
66
+ previous.requests.some(request => request.id === state.requests[0].id))
67
+ matches.push(previous);
68
+ }
69
+ if (matches.length === 1)
70
+ state.supersededRunID = matches[0].runID;
71
+ return state;
72
+ }
73
+ /** Find exactly one archived cancelled mission that owned this run; ambiguity never grants recovery. */
74
+ async archivedRun(root, runID) {
75
+ const directory = join(this.projectRoot, this.profile.stateDirectory, "missions");
76
+ let entries;
77
+ try {
78
+ entries = await readdir(directory);
79
+ }
80
+ catch (error) {
81
+ if (error.code === "ENOENT")
82
+ return undefined;
83
+ throw error;
84
+ }
85
+ const prefix = `${digest(root)}.mission-`;
86
+ const matches = [];
87
+ for (const name of entries.filter(name => name.startsWith(prefix) &&
88
+ /^mission-[a-f0-9-]+\.json$/u.test(name.slice(digest(root).length + 1)))) {
89
+ const state = await this.load(join(directory, name));
90
+ if (state?.root === root && state.phase === "cancelled" && state.runID === runID &&
91
+ state.coordinator !== null && state.callID !== null)
92
+ matches.push(state);
93
+ }
94
+ return matches.length === 1 ? matches[0] : undefined;
95
+ }
43
96
  async save(file, value) {
44
97
  const temporary = `${file}.${randomUUID()}.tmp`;
45
98
  await mkdir(join(this.projectRoot, this.profile.stateDirectory, "missions"), { recursive: true });
@@ -64,7 +117,7 @@ export class OperatorMissionRuntime {
64
117
  }
65
118
  async read(root) {
66
119
  await this.writes.get(root);
67
- const state = await this.load(this.file(root));
120
+ const state = await this.loadMission(root);
68
121
  if (state && (state.version !== "0.12" || state.root !== root))
69
122
  throw new Error("mission-state-invalid");
70
123
  return state;
@@ -81,7 +134,7 @@ export class OperatorMissionRuntime {
81
134
  await this.serial(root, async () => {
82
135
  // Captured before prompt rewriting, including exact whitespace. Never ask a model to recopy it.
83
136
  await this.save(this.file(root, ".request"), request);
84
- const state = await this.load(this.file(root));
137
+ const state = await this.loadMission(root);
85
138
  if (state && !["completed", "cancelled"].includes(state.phase) && !state.requests.some(item => item.id === request.id)) {
86
139
  state.requests.push(request);
87
140
  await this.save(this.file(root), state);
@@ -97,7 +150,7 @@ export class OperatorMissionRuntime {
97
150
  const request = await this.load(this.file(root, ".request"));
98
151
  if (!request)
99
152
  throw new Error("mission-original-request-unavailable");
100
- const previous = await this.load(this.file(root));
153
+ const previous = await this.loadMission(root);
101
154
  if (previous && !["completed", "cancelled"].includes(previous.phase)) {
102
155
  if (previous.requirements.some((item, index) => requirements[index] !== item.text)) {
103
156
  throw new Error("mission-requirements-preserved: keep the existing ordered requirements and append user additions");
@@ -110,14 +163,16 @@ export class OperatorMissionRuntime {
110
163
  await this.save(this.file(root, `.${previous.id}`), previous);
111
164
  const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
112
165
  requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
113
- coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null };
166
+ coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
167
+ ...(previous?.phase === "cancelled" && previous.runID !== null && request.id !== previous.requests[0]?.id
168
+ ? { supersededRunID: previous.runID } : {}) };
114
169
  await this.save(this.file(root), state);
115
170
  return state;
116
171
  });
117
172
  }
118
173
  update(root, change) {
119
174
  return this.serial(root, async () => {
120
- const state = await this.load(this.file(root));
175
+ const state = await this.loadMission(root);
121
176
  if (!state)
122
177
  throw new Error("mission-missing");
123
178
  change(state);
@@ -172,6 +172,8 @@ export interface OperatorState {
172
172
  readonly sourceRefs: readonly string[];
173
173
  readonly createdAt: string;
174
174
  readonly parentRunID: string | null;
175
+ /** Archived cancelled run replaced by a later user-authorized mission. */
176
+ readonly supersededRunID?: string;
175
177
  readonly priorAcceptedUnits: readonly OperatorAcceptanceAnchor[];
176
178
  readonly remediationParent: {
177
179
  readonly taskID: string;
@@ -243,7 +245,7 @@ export declare class OperatorRuntime {
243
245
  prepareMission(root: string, raw: unknown, dispatcher?: {
244
246
  sessionID: string;
245
247
  callID: string;
246
- }): Promise<OperatorState>;
248
+ }, supersededRunID?: string): Promise<OperatorState>;
247
249
  retireMissionRun(root: string): Promise<void>;
248
250
  private prepareOnce;
249
251
  operatorTask(state: OperatorState): OperatorTask;
@@ -299,7 +299,8 @@ export class OperatorRuntime {
299
299
  !identifier(state.runID) || !Array.isArray(state.units) || state.units.length > OPERATOR_LIMITS.units ||
300
300
  !strings(state.acceptance, true) || !Array.isArray(state.acceptanceProof) || state.acceptanceProof.length !== state.acceptance.length ||
301
301
  state.acceptanceProof.some(ids => !strings(ids, true)) || state.acceptanceFingerprint !== acceptanceContinuityFingerprint(state.acceptance) ||
302
- (state.parentRunID !== null && !identifier(state.parentRunID)) || !Array.isArray(state.priorAcceptedUnits) ||
302
+ (state.parentRunID !== null && !identifier(state.parentRunID)) ||
303
+ (state.supersededRunID !== undefined && !identifier(state.supersededRunID)) || !Array.isArray(state.priorAcceptedUnits) ||
303
304
  state.priorAcceptedUnits.some(anchor => !record(anchor) || !identifier(anchor.taskID) || typeof anchor.handoffPath !== "string" || !isAbsolute(anchor.handoffPath) ||
304
305
  typeof anchor.handoffHash !== "string" || !/^[a-f0-9]{64}$/u.test(anchor.handoffHash)) ||
305
306
  (state.remediationParent !== null && (!record(state.remediationParent) || !identifier(state.remediationParent.taskID) ||
@@ -677,8 +678,8 @@ export class OperatorRuntime {
677
678
  }
678
679
  }
679
680
  /** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
680
- prepareMission(root, raw, dispatcher) {
681
- return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher }));
681
+ prepareMission(root, raw, dispatcher, supersededRunID) {
682
+ return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID }));
682
683
  }
683
684
  retireMissionRun(root) {
684
685
  return this.serial(root, async () => {
@@ -718,18 +719,30 @@ export class OperatorRuntime {
718
719
  immutableReplacement = { ...immutableReplacement, goal_declaration: declaration };
719
720
  }
720
721
  const plan = parseOperatorPlan(immutableReplacement);
722
+ const validatedPlanHash = hash(JSON.stringify(plan));
723
+ if (mission?.supersededRunID !== undefined && previous?.supersededRunID === mission.supersededRunID &&
724
+ previous.planHash === validatedPlanHash && previous.phase !== "cancelled")
725
+ return previous;
726
+ const superseding = mission?.supersededRunID !== undefined && previous?.runID === mission.supersededRunID &&
727
+ previous.phase === "cancelled";
728
+ if (mission?.supersededRunID !== undefined && !superseding)
729
+ throw new Error("mission-superseded-run-mismatch");
730
+ if (superseding && previous && (previous.decision !== "explicit-cancellation" || previous.gitLifecycle !== null ||
731
+ previous.repairResidualPaths.length > 0 || previous.priorAcceptedUnits.length > 0 ||
732
+ previous.units.some(unit => unit.childSessionID !== null || unit.status === "succeeded"))) {
733
+ throw new Error("mission-superseded-run-has-work: reconcile prior workers and evidence before changing acceptance");
734
+ }
721
735
  if (plan.git_lifecycle !== undefined) {
722
736
  await ensureGitManagedStateExcluded(this.projectRoot, this.profile, this.gitPath);
723
737
  }
724
- const planHash = hash(JSON.stringify(plan));
725
738
  if (previous && !["completed", "cancelled"].includes(previous.phase)) {
726
- if (previous.planHash === planHash)
739
+ if (previous.planHash === validatedPlanHash)
727
740
  return previous;
728
741
  throw new Error("operator-active-contract-immutable");
729
742
  }
730
743
  // Cancellation stops execution, not the accepted user order. A replacement
731
744
  // plan must carry the original ordered criteria; only a completed root clears them.
732
- const parent = previous?.phase === "cancelled" ? previous : undefined;
745
+ const parent = !superseding && previous?.phase === "cancelled" ? previous : undefined;
733
746
  if (parent?.decision === "operator-contract-repair-unavailable-after-cancel") {
734
747
  // The diagnosed transients, not the cancellation itself, block a replacement. Once they are
735
748
  // gone the same accepted order may proceed; while they remain the refusal names them exactly.
@@ -878,9 +891,9 @@ export class OperatorRuntime {
878
891
  childSessionID: null, evidence: [], resultClass: null, repairValidationAttempts: 0, repairValidation: null });
879
892
  }
880
893
  const gitLifecycle = await this.createGitLifecycle(plan, remediationHead);
881
- const state = { schema_version: "0.1", profile: this.profile.id, rootSessionID: root, runID, planHash,
894
+ const state = { schema_version: "0.1", profile: this.profile.id, rootSessionID: root, runID, planHash: validatedPlanHash,
882
895
  acceptance: plan.acceptance, acceptanceProof: plan.acceptance_proof, acceptanceFingerprint, sourceRefs: plan.source_refs, createdAt: new Date().toISOString(),
883
- parentRunID: parent?.runID ?? null, priorAcceptedUnits,
896
+ parentRunID: parent?.runID ?? null, ...(superseding ? { supersededRunID: previous.runID } : {}), priorAcceptedUnits,
884
897
  remediationParent: remediationTaskID && remediationHead && parent?.gitLifecycle ? {
885
898
  taskID: remediationTaskID, committedHead: remediationHead, runID: parent.runID,
886
899
  acceptanceFingerprint: parent.acceptanceFingerprint, approvedWriteUnion: parent.gitLifecycle.writeUnion,
@@ -908,6 +921,17 @@ export class OperatorRuntime {
908
921
  await handle.close();
909
922
  }
910
923
  }
924
+ if (superseding && previous) {
925
+ const archive = `${this.file(root)}.${previous.runID}.archive`;
926
+ try {
927
+ await writeFile(archive, JSON.stringify(previous), { flag: "wx", mode: 0o600 });
928
+ }
929
+ catch (error) {
930
+ if (error.code !== "EEXIST" ||
931
+ await readFile(archive, "utf8") !== JSON.stringify(previous))
932
+ throw error;
933
+ }
934
+ }
911
935
  await this.save(state);
912
936
  return state;
913
937
  }
@@ -7,12 +7,13 @@ var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExte
7
7
  return path;
8
8
  };
9
9
  import { createHash, randomUUID } from "node:crypto";
10
- import { lstat, mkdir, open, readFile, readdir, realpath, rm, stat, writeFile } from "node:fs/promises";
10
+ import { lstat, mkdir, open, readFile, readdir, readlink, realpath, rm, stat, writeFile } from "node:fs/promises";
11
11
  import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
12
12
  import { RUNTIME_ASSET_VERSION } from "../asset-version.js";
13
13
  import { GOAL_DECLARATION_FORMAT, GOAL_DELIVERY_INTENTS, GOAL_DELIVERY_MODES, GOAL_CRITERION_ENUMS, expandGoalDeclaration } from "../core/goal-declaration-format.js";
14
14
  import { ACCEPTANCE_CONTINUITY_AUTHORITY, ACCEPTANCE_CONTINUITY_EXTENSION, ACCEPTANCE_CONTINUITY_SCHEMA_VERSION, acceptanceContinuityFingerprint, inspectAcceptanceContinuity, normalizeAcceptanceCriteria, } from "../core/acceptance-continuity.js";
15
15
  import { resolveGlobalConfigRoot } from "../core/initialize.js";
16
+ import { OperatorMissionRuntime } from "../core/operator-mission.js";
16
17
  import { admitLunaFabric } from "../core/luna-fabric-contract.js";
17
18
  import { summarizeExperienceEvidence } from "../core/experience-evidence-summary.js";
18
19
  import { selectExperienceRoute } from "../core/experience-route-policy.js";
@@ -234,6 +235,7 @@ async function durableScopeRoot(projectRoot) {
234
235
  }
235
236
  async function protectedScopeDigest(projectRoot, paths, manifestHash) {
236
237
  const entries = [];
238
+ const canonicalRoot = await realpath(projectRoot);
237
239
  const visit = async (absolute) => {
238
240
  const scoped = relative(projectRoot, absolute).replaceAll("\\", "/");
239
241
  if (scoped === ".." || scoped.startsWith("../") || isAbsolute(scoped))
@@ -247,8 +249,22 @@ async function protectedScopeDigest(projectRoot, paths, manifestHash) {
247
249
  entries.push([scoped, "missing"]);
248
250
  return true;
249
251
  }
250
- if (metadata.isSymbolicLink())
251
- return false;
252
+ if (metadata.isSymbolicLink()) {
253
+ // Package managers place executable links under node_modules/.bin. Record
254
+ // both the link and its target bytes, but never follow a link out of the
255
+ // project or recurse through a linked directory (which may form a cycle).
256
+ const target = await realpath(absolute).catch(() => undefined);
257
+ if (target === undefined)
258
+ return false;
259
+ const relativeTarget = relative(canonicalRoot, target);
260
+ if (relativeTarget === ".." || relativeTarget.startsWith("../") || isAbsolute(relativeTarget) ||
261
+ !(await stat(target)).isFile())
262
+ return false;
263
+ const link = await readlink(absolute);
264
+ const contents = await readFile(target);
265
+ entries.push([scoped, `symlink:${link}`, createHash("sha256").update(contents).digest("hex")]);
266
+ return true;
267
+ }
252
268
  if (metadata.isDirectory()) {
253
269
  entries.push([scoped, "directory"]);
254
270
  const children = await readdir(absolute);
@@ -1609,6 +1625,82 @@ export const SortieDogsPlugin = async (input, options) => {
1609
1625
  const data = isRecord(response) ? response.data : undefined;
1610
1626
  if (!Array.isArray(data))
1611
1627
  return;
1628
+ const rootHistory = data;
1629
+ async function terminalCancelledMissionTask(unitID, reservationID, goalID) {
1630
+ const runID = unitID.replace(/-[1-9][0-9]*$/u, "");
1631
+ if (runID === unitID || !/^operator-[a-f0-9-]+$/u.test(runID) ||
1632
+ input.client?.session?.get === undefined)
1633
+ return undefined;
1634
+ const sessionAPI = input.client.session;
1635
+ const childrenMethod = sessionAPI.children;
1636
+ const getMethod = sessionAPI.get;
1637
+ const mission = await new OperatorMissionRuntime(input.directory, runtimeProfile).archivedRun(root, runID);
1638
+ if (!mission || !rootHistory.some(message => isRecord(message) && isRecord(message.info) &&
1639
+ message.info.sessionID === root && message.info.role === "assistant" && Array.isArray(message.parts) &&
1640
+ message.parts.some(part => isRecord(part) && part.type === "tool" && part.tool === "task" &&
1641
+ part.callID === mission.callID && isRecord(part.state) && part.state.status === "completed" &&
1642
+ isRecord(part.state.input) && typeof part.state.input.prompt === "string" &&
1643
+ part.state.input.prompt.startsWith("SORTIE_MISSION_REF ") && (() => {
1644
+ try {
1645
+ const ref = JSON.parse(part.state.input.prompt.slice("SORTIE_MISSION_REF ".length));
1646
+ return isRecord(ref) && ref.r === root && ref.m === mission.id;
1647
+ }
1648
+ catch {
1649
+ return false;
1650
+ }
1651
+ })())))
1652
+ return undefined;
1653
+ const coordinatorResponse = await getMethod.call(sessionAPI, { path: { id: mission.coordinator }, query: { directory: input.directory } });
1654
+ const coordinatorInfo = isRecord(coordinatorResponse) ? coordinatorResponse.data : undefined;
1655
+ if (!isRecord(coordinatorInfo) || coordinatorInfo.id !== mission.coordinator ||
1656
+ coordinatorInfo.parentID !== root || coordinatorInfo.agent !== "dogs-coordinator" ||
1657
+ coordinatorInfo.outcome !== "succeeded")
1658
+ return undefined;
1659
+ if (childrenMethod !== undefined) {
1660
+ const childrenResponse = await childrenMethod.call(sessionAPI, { path: { id: root }, query: { directory: input.directory } });
1661
+ const children = isRecord(childrenResponse) && Array.isArray(childrenResponse.data) ? childrenResponse.data : [];
1662
+ if (!children.some(child => isRecord(child) && child.id === mission.coordinator &&
1663
+ child.parentID === root && child.agent === "dogs-coordinator"))
1664
+ return undefined;
1665
+ }
1666
+ const coordinator = mission.coordinator;
1667
+ const historyResponse = await messages.call(sessionAPI, { path: { id: coordinator }, query: { directory: input.directory } });
1668
+ const history = isRecord(historyResponse) && Array.isArray(historyResponse.data) ? historyResponse.data : [];
1669
+ const candidates = [];
1670
+ for (const message of history) {
1671
+ if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant" ||
1672
+ message.info.sessionID !== coordinator || !Array.isArray(message.parts))
1673
+ continue;
1674
+ for (const part of message.parts) {
1675
+ if (!isRecord(part) || part.type !== "tool" || part.tool !== "task" ||
1676
+ !isRecord(part.state) || part.state.status !== "error" || !isRecord(part.state.input) ||
1677
+ typeof part.state.input.prompt !== "string" || !part.state.input.prompt.startsWith("SORTIE_OPERATOR_TASK_REF ") ||
1678
+ typeof part.callID !== "string")
1679
+ continue;
1680
+ try {
1681
+ const ref = JSON.parse(part.state.input.prompt.slice("SORTIE_OPERATOR_TASK_REF ".length));
1682
+ if (isRecord(ref) && ref.r === root && ref.n === runID && ref.t === unitID &&
1683
+ /^[a-f0-9]{64}$/u.test(String(ref.p)) && /^[a-f0-9]{64}$/u.test(String(ref.h)) &&
1684
+ goalFingerprint({ goal_id: goalID, unit_id: unitID, call_id: part.callID }) === reservationID) {
1685
+ const error = isRecord(part.state.error) ? part.state.error : undefined;
1686
+ candidates.push({ callID: part.callID, depthRefused: error?.type === "tool.execution" &&
1687
+ error.message === 'Subagent depth limit reached (1). Increase "experimental.subagent_depth" to allow nested subagents.' });
1688
+ }
1689
+ }
1690
+ catch { /* malformed native reference cannot prove this reservation */ }
1691
+ }
1692
+ }
1693
+ if (candidates.length !== 1)
1694
+ return undefined;
1695
+ if (childrenMethod !== undefined) {
1696
+ const descendantsResponse = await childrenMethod.call(sessionAPI, { path: { id: coordinator }, query: { directory: input.directory } });
1697
+ if (!isRecord(descendantsResponse) || !Array.isArray(descendantsResponse.data) || descendantsResponse.data.length !== 0)
1698
+ return undefined;
1699
+ }
1700
+ else if (mission.submission?.status !== "blocked" || !candidates[0].depthRefused)
1701
+ return undefined;
1702
+ return candidates[0].callID;
1703
+ }
1612
1704
  for (const reservation of pending) {
1613
1705
  const matches = [];
1614
1706
  for (const message of data) {
@@ -1686,8 +1778,12 @@ export const SortieDogsPlugin = async (input, options) => {
1686
1778
  matches.push({ callID: part.callID, status: String(part.state.status), elapsed });
1687
1779
  }
1688
1780
  }
1689
- if (matches.length !== 1)
1690
- continue;
1781
+ if (matches.length !== 1) {
1782
+ const callID = matches.length === 0 ? await terminalCancelledMissionTask(reservation.unit_id, reservation.reservation_id, state.goal_id) : undefined;
1783
+ if (callID === undefined)
1784
+ continue;
1785
+ matches.push({ callID, status: "error", elapsed: null });
1786
+ }
1691
1787
  const match = matches[0];
1692
1788
  // Reconcile lifecycle accounting only. Lost in-memory validation bindings cannot be
1693
1789
  // reconstructed from worker prose and must not manufacture acceptance evidence.
@@ -7743,6 +7839,9 @@ export const SortieDogsPlugin = async (input, options) => {
7743
7839
  currentBudget: async (root) => {
7744
7840
  if (!isCoordinatorSession(root) && !await recoverCoordinatorRoot(root))
7745
7841
  throw new Error("operator-coordinator-required");
7842
+ // Planning must account for terminal native Tasks before reserving new units.
7843
+ // Otherwise a cancelled predecessor keeps a stale unit reservation across missions.
7844
+ await recoverCompletedGoalReservations(root);
7746
7845
  const state = await currentGoal(root);
7747
7846
  if (state.goal_id === null || state.budget === null)
7748
7847
  return null;
@@ -1139,7 +1139,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1139
1139
  if (budget && budget.remaining_units < plan.units.length && !same)
1140
1140
  throw new Error("mission-budget-exhausted: report the required cumulative extension to Operator");
1141
1141
  const state = await operators.prepareMission(root, plan, actor === root ? undefined
1142
- : { sessionID: actor, callID: mission.callID });
1142
+ : { sessionID: actor, callID: mission.callID }, mission.supersededRunID);
1143
1143
  await restorePriorAcceptance(root, state);
1144
1144
  await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
1145
1145
  control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
@@ -1149,6 +1149,7 @@ export function createProfiledPlugin(profile, assetVersion) {
1149
1149
  item.runID = state.runID;
1150
1150
  item.phase = "running";
1151
1151
  item.submission = null;
1152
+ delete item.supersededRunID;
1152
1153
  });
1153
1154
  return JSON.stringify(await operators.next(root, actor));
1154
1155
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.4",
3
+ "version": "0.12.6",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",