sortie-dogs 0.12.7 → 0.12.8

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.
@@ -3,5 +3,5 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export declare const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export declare const V010_RUNTIME_ASSET_VERSION = "0.12.7-review-convergence-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.12.8-mission-lineage-v1";
7
7
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
@@ -3,4 +3,4 @@
3
3
  * installed project marker without importing every asset body.
4
4
  */
5
5
  export const RUNTIME_ASSET_VERSION = "0.3.89-completion-proof-v1";
6
- export const V010_RUNTIME_ASSET_VERSION = "0.12.7-review-convergence-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.12.8-mission-lineage-v1";
@@ -75,6 +75,8 @@ export declare class OperatorMissionRuntime {
75
75
  capture(root: string, request: MissionRequest): Promise<void>;
76
76
  start(root: string, requirements: unknown): Promise<OperatorMission>;
77
77
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
78
+ /** Keep a cancelled same-turn run's host-accepted criteria ahead of new mission text. */
79
+ carryForward(root: string, missionID: string, acceptance: readonly string[]): Promise<OperatorMission>;
78
80
  task(state: OperatorMission): OperatorTask;
79
81
  admit(root: string, callID: string, args: Record<string, unknown>): Promise<OperatorMission>;
80
82
  /** Reopen only the exact dispatch whose native Task has a terminal record after a server restart. */
@@ -58,7 +58,7 @@ export class OperatorMissionRuntime {
58
58
  async loadMission(root) {
59
59
  const state = await this.load(this.file(root));
60
60
  if (!state || state.supersededRunID !== undefined || state.runID !== null ||
61
- !["open", "running", "submitted"].includes(state.phase) || state.requests.length === 0)
61
+ !["open", "running", "submitted", "cancelled"].includes(state.phase) || state.requests.length === 0)
62
62
  return state;
63
63
  const directory = join(this.projectRoot, this.profile.stateDirectory, "missions");
64
64
  let entries;
@@ -75,13 +75,18 @@ export class OperatorMissionRuntime {
75
75
  const matches = [];
76
76
  for (const name of candidates) {
77
77
  const previous = await this.load(join(directory, name));
78
- if (previous?.root === root && previous.phase === "cancelled" && previous.runID !== null &&
79
- previous.requests[0]?.id !== state.requests[0].id &&
78
+ if (previous?.root === root && previous.phase === "cancelled" &&
79
+ (previous.runID !== null || previous.supersededRunID !== undefined) &&
80
+ (previous.runID === null || previous.requests[0]?.id !== state.requests[0].id) &&
80
81
  previous.requests.some(request => request.id === state.requests[0].id))
81
82
  matches.push(previous);
82
83
  }
83
- if (matches.length === 1)
84
- state.supersededRunID = matches[0].runID;
84
+ if (matches.length === 1) {
85
+ const predecessor = matches[0].runID ?? matches[0].supersededRunID;
86
+ if (predecessor && (matches[0].runID !== null || await this.archivedRun(root, predecessor))) {
87
+ state.supersededRunID = predecessor;
88
+ }
89
+ }
85
90
  return state;
86
91
  }
87
92
  /** Find exactly one archived cancelled mission that owned this run; ambiguity never grants recovery. */
@@ -178,8 +183,10 @@ export class OperatorMissionRuntime {
178
183
  const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
179
184
  requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
180
185
  coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
181
- ...(previous?.phase === "cancelled" && previous.runID !== null && request.id !== previous.requests[0]?.id
182
- ? { supersededRunID: previous.runID } : {}) };
186
+ ...(previous?.phase === "cancelled" &&
187
+ (previous.runID === null || request.id !== previous.requests[0]?.id) &&
188
+ (previous.runID ?? previous.supersededRunID)
189
+ ? { supersededRunID: (previous.runID ?? previous.supersededRunID) } : {}) };
183
190
  await this.save(this.file(root), state);
184
191
  return state;
185
192
  });
@@ -194,6 +201,23 @@ export class OperatorMissionRuntime {
194
201
  return state;
195
202
  });
196
203
  }
204
+ /** Keep a cancelled same-turn run's host-accepted criteria ahead of new mission text. */
205
+ carryForward(root, missionID, acceptance) {
206
+ return this.update(root, state => {
207
+ if (state.id !== missionID || state.supersededRunID !== undefined || state.runID !== null ||
208
+ !["open", "running"].includes(state.phase) || (state.dispatchOpen && state.coordinator === null) ||
209
+ acceptance.length === 0 || acceptance.some(text => typeof text !== "string" || !text.trim())) {
210
+ throw new Error("mission-acceptance-carry-forward-unavailable");
211
+ }
212
+ const existing = state.requirements.map(item => item.text);
213
+ if (acceptance.every((text, index) => existing[index] === text))
214
+ return;
215
+ const additions = existing.filter(text => !acceptance.includes(text));
216
+ if (acceptance.length + additions.length > 64)
217
+ throw new Error("mission-requirements-limit: carried acceptance and additions exceed 64");
218
+ state.requirements = [...acceptance, ...additions].map((text, index) => ({ id: `R${index + 1}`, text }));
219
+ });
220
+ }
197
221
  task(state) {
198
222
  return { subagent_type: profileAgent(this.profile, "dog-operator"), description: state.requirements[0].text.slice(0, 100),
199
223
  prompt: `${MISSION_REFERENCE}${JSON.stringify({ r: state.root, m: state.id, h: digest(JSON.stringify(state.requirements)) })}`,
@@ -245,7 +245,7 @@ export declare class OperatorRuntime {
245
245
  prepareMission(root: string, raw: unknown, dispatcher?: {
246
246
  sessionID: string;
247
247
  callID: string;
248
- }, supersededRunID?: string): Promise<OperatorState>;
248
+ }, supersededRunID?: string, terminalChildren?: readonly string[]): Promise<OperatorState>;
249
249
  retireMissionRun(root: string): Promise<void>;
250
250
  private prepareOnce;
251
251
  operatorTask(state: OperatorState): OperatorTask;
@@ -678,8 +678,8 @@ export class OperatorRuntime {
678
678
  }
679
679
  }
680
680
  /** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
681
- prepareMission(root, raw, dispatcher, supersededRunID) {
682
- return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID }));
681
+ prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = []) {
682
+ return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren }));
683
683
  }
684
684
  retireMissionRun(root) {
685
685
  return this.serial(root, async () => {
@@ -727,9 +727,15 @@ export class OperatorRuntime {
727
727
  previous.phase === "cancelled";
728
728
  if (mission?.supersededRunID !== undefined && !superseding)
729
729
  throw new Error("mission-superseded-run-mismatch");
730
+ const terminalChildren = mission?.terminalChildren ?? [];
731
+ const cancelledChildren = previous?.units.flatMap(unit => unit.status === "cancelled" && unit.childSessionID !== null
732
+ ? [unit.childSessionID] : []) ?? [];
730
733
  if (superseding && previous && (previous.decision !== "explicit-cancellation" || previous.gitLifecycle !== null ||
731
734
  previous.repairResidualPaths.length > 0 || previous.priorAcceptedUnits.length > 0 ||
732
- previous.units.some(unit => unit.childSessionID !== null || unit.status === "succeeded"))) {
735
+ terminalChildren.length !== cancelledChildren.length || new Set(terminalChildren).size !== terminalChildren.length ||
736
+ cancelledChildren.some(id => !terminalChildren.includes(id)) || previous.units.some(unit => !["pending", "cancelled"].includes(unit.status) || unit.evidence.length > 0 || unit.resultClass !== null ||
737
+ unit.repairValidation !== null || (unit.status === "pending" && (unit.callID !== null || unit.childSessionID !== null)) ||
738
+ (unit.status === "cancelled" && (unit.childSessionID === null || unit.callID === null))))) {
733
739
  throw new Error("mission-superseded-run-has-work: reconcile prior workers and evidence before changing acceptance");
734
740
  }
735
741
  if (plan.git_lifecycle !== undefined) {
@@ -743,6 +749,14 @@ export class OperatorRuntime {
743
749
  // Cancellation stops execution, not the accepted user order. A replacement
744
750
  // plan must carry the original ordered criteria; only a completed root clears them.
745
751
  const parent = !superseding && previous?.phase === "cancelled" ? previous : undefined;
752
+ if (parent?.decision === "explicit-cancellation") {
753
+ const cancelled = parent.units.flatMap(unit => unit.status === "cancelled" && unit.childSessionID !== null
754
+ ? [unit.childSessionID] : []);
755
+ if (cancelled.length > 0 && (new Set(terminalChildren).size !== terminalChildren.length ||
756
+ cancelled.some(id => !terminalChildren.includes(id)))) {
757
+ throw new Error("mission-cancelled-run-worker-not-terminal");
758
+ }
759
+ }
746
760
  if (parent?.decision === "operator-contract-repair-unavailable-after-cancel") {
747
761
  // The diagnosed transients, not the cancellation itself, block a replacement. Once they are
748
762
  // gone the same accepted order may proceed; while they remain the refusal names them exactly.
@@ -1,3 +1,4 @@
1
+ import { type OperatorState } from "../core/operator-runtime.js";
1
2
  import { type RuntimeProfile } from "../core/runtime-profile.js";
2
3
  import { type OpenCodePlugin } from "./index.js";
3
4
  import { type CatalogModel } from "./model-routing.js";
@@ -20,6 +21,13 @@ export declare function previewModelCatalog(routes?: readonly {
20
21
  readonly model: string;
21
22
  readonly variant: string;
22
23
  }[], base?: readonly CatalogModel[]): readonly CatalogModel[];
24
+ /** Native V2 termination and lineage are required before a cancelled Worker loses its old acceptance. */
25
+ export declare function terminalCancelledMissionChildren(profile: RuntimeProfile, root: string, previous: OperatorState, budget: {
26
+ reserved_units: number;
27
+ } | null, host: {
28
+ get(id: string): Promise<unknown>;
29
+ children(id: string): Promise<unknown>;
30
+ }): Promise<string[]>;
23
31
  /** One transport namespace around the existing MkII engine; no second write gate or acceptance engine. */
24
32
  export declare function createProfiledPlugin(profile: RuntimeProfile, assetVersion: string): OpenCodePlugin;
25
33
  export declare const SortieDogsV010Plugin: OpenCodePlugin;
@@ -82,6 +82,34 @@ export function previewModelCatalog(routes = PREVIEW_ROUTES, base = BUILT_IN_MOD
82
82
  }
83
83
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
84
84
  const payload = (value) => record(value) && "data" in value ? value.data : value;
85
+ /** Native V2 termination and lineage are required before a cancelled Worker loses its old acceptance. */
86
+ export async function terminalCancelledMissionChildren(profile, root, previous, budget, host) {
87
+ if (!budget || budget.reserved_units !== 0)
88
+ throw new Error("mission-superseded-run-reservations-pending");
89
+ const oldWorkers = previous.units.flatMap(unit => unit.childSessionID === null ? [] : [unit.childSessionID]);
90
+ if (oldWorkers.length === 0)
91
+ return [];
92
+ const coordinatorID = previous.operatorSessionID;
93
+ const coordinator = coordinatorID && await host.get(coordinatorID);
94
+ if (coordinatorID === null || !record(coordinator) || coordinator.parentID !== root ||
95
+ canonicalAgent(profile, coordinator.agent) !== "dog-operator" || coordinator.outcome !== "interrupted") {
96
+ throw new Error("mission-superseded-coordinator-not-terminal");
97
+ }
98
+ const children = await host.children(coordinatorID);
99
+ if (!Array.isArray(children) || children.length !== oldWorkers.length ||
100
+ children.some(child => !record(child) || !oldWorkers.includes(child.id))) {
101
+ throw new Error("mission-superseded-worker-lineage-unproven");
102
+ }
103
+ for (const id of oldWorkers) {
104
+ const worker = await host.get(id), descendants = await host.children(id);
105
+ if (!record(worker) || worker.parentID !== coordinatorID ||
106
+ !["dog-worker", "dog-luna-worker"].includes(canonicalAgent(profile, worker.agent) ?? "") ||
107
+ worker.outcome !== "interrupted" || !Array.isArray(descendants) || descendants.length !== 0) {
108
+ throw new Error("mission-superseded-worker-not-terminal");
109
+ }
110
+ }
111
+ return oldWorkers;
112
+ }
85
113
  const RUNTIME_PROFILE_SESSION_INACTIVE = "runtime-profile-session-inactive: non-profile agents retain native read, edit, patch, shell, and task tools; continue directly without Sortie profile tools";
86
114
  const fallbackOptionalSchema = (schema) => record(schema) && schema.type === "string" && typeof schema.optional !== "function"
87
115
  ? { ...schema, "x-sortie-optional": true }
@@ -406,6 +434,19 @@ export function createProfiledPlugin(profile, assetVersion) {
406
434
  }
407
435
  return packet;
408
436
  }
437
+ async function retainCancelledMissionAcceptance(root, mission, prior) {
438
+ const previous = prior ?? await operators.read(root);
439
+ if (previous?.phase !== "cancelled" || mission.supersededRunID !== undefined || mission.runID !== null)
440
+ return mission;
441
+ // A different original user request needs the explicit, host-proven supersession path.
442
+ // Only the same source can retain a cancelled run's acceptance as a prefix.
443
+ if (previous.sourceRefs[0] !== `user:${mission.requests[0]?.id}` ||
444
+ !previous.sourceRefs.every(ref => mission.requests.some(request => ref === `user:${request.id}`))) {
445
+ throw new Error("mission-cancelled-source-unproven");
446
+ }
447
+ return previous.acceptance.every((text, index) => mission.requirements[index]?.text === text)
448
+ ? mission : missions.carryForward(root, mission.id, previous.acceptance);
449
+ }
409
450
  async function restorePriorAcceptance(root, state) {
410
451
  const succeeded = [...state.units].reverse().find(unit => unit.status === "succeeded");
411
452
  const current = succeeded === undefined ? undefined : {
@@ -1115,21 +1156,24 @@ export function createProfiledPlugin(profile, assetVersion) {
1115
1156
  } };
1116
1157
  const startMission = `${profile.toolPrefix}start_mission`, planUnits = `${profile.toolPrefix}plan_units`, reviewMission = `${profile.toolPrefix}review_mission`, submitMission = `${profile.toolPrefix}submit_mission`, completeMission = `${profile.toolPrefix}complete_mission`, expandUnit = `${profile.toolPrefix}expand_unit`;
1117
1158
  const stringList = { type: "array", items: { type: "string" } };
1118
- tools[startMission] = { description: "Operator: save a few one-line requirements/negative constraints. The original user message, IDs and ownership are captured automatically. Dispatch the returned Coordinator task immediately, or use plan_units for a simple single-unit Fast-lane task.",
1159
+ tools[startMission] = { description: "Operator: save a few one-line requirements/negative constraints. The original user message, IDs and ownership are captured automatically. A cancelled same-turn run's accepted criteria are restored from host state in their exact order before dispatch. Dispatch the returned Coordinator task immediately, or use plan_units for a simple single-unit Fast-lane task.",
1119
1160
  args: { requirements: { ...stringList, minItems: 1, maxItems: 64 } }, execute: async (args, context) => {
1120
1161
  await requireRoot(context.sessionID);
1121
- await reconcileMissionDispatch(context.sessionID);
1122
- const mission = await missions.start(context.sessionID, args.requirements);
1123
- return JSON.stringify(mission.dispatchOpen
1124
- ? missionDispatchPacket(mission, await operators.read(context.sessionID))
1125
- : { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
1126
- next_action: "Nontrivial: dispatch task now. Simple single-unit work with known scope/check: call plan_units directly. Do not create a proposal or ask for plan approval." });
1162
+ return serializeDispatchTransition(context.sessionID, async () => {
1163
+ await reconcileMissionDispatch(context.sessionID);
1164
+ const mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements));
1165
+ return JSON.stringify(mission.dispatchOpen
1166
+ ? missionDispatchPacket(mission, await operators.read(context.sessionID))
1167
+ : { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
1168
+ next_action: "Nontrivial: dispatch task now. Simple single-unit work with known scope/check: call plan_units directly. Do not create a proposal or ask for plan approval." });
1169
+ });
1127
1170
  } };
1128
1171
  async function declareMissionUnits(root, actor, mission, raw, reason) {
1172
+ const previous = await operators.read(root);
1173
+ mission = await retainCancelledMissionAcceptance(root, mission, previous);
1129
1174
  const plan = missionPlan(mission, raw);
1130
1175
  if (actor === root && (mission.coordinator !== null || plan.units.length !== 1))
1131
1176
  throw new Error("mission-coordinator-required: dispatch the returned Coordinator task");
1132
- const previous = await operators.read(root);
1133
1177
  const same = previous?.planHash === createHash("sha256").update(JSON.stringify(plan)).digest("hex");
1134
1178
  if (previous && !["completed", "cancelled"].includes(previous.phase) && !same) {
1135
1179
  if (!reason?.trim())
@@ -1139,8 +1183,19 @@ export function createProfiledPlugin(profile, assetVersion) {
1139
1183
  const budget = await control.currentBudget(root);
1140
1184
  if (budget && budget.remaining_units < plan.units.length && !same)
1141
1185
  throw new Error("mission-budget-exhausted: report the required cumulative extension to Operator");
1186
+ // Cancellation marks the durable units before interrupting their native sessions. A cancelled
1187
+ // status alone therefore cannot prove the old Worker stopped or its reservation settled.
1188
+ const cancelledPredecessor = previous?.phase === "cancelled" &&
1189
+ (mission.supersededRunID === previous.runID ||
1190
+ (mission.supersededRunID === undefined && previous.decision === "explicit-cancellation" &&
1191
+ previous.units.some(unit => unit.status === "cancelled" && unit.childSessionID !== null)));
1192
+ const terminalChildren = cancelledPredecessor
1193
+ ? await terminalCancelledMissionChildren(profile, root, previous, budget, {
1194
+ get: async (id) => payload(await session("get", { path: { id }, query: { directory: input.directory } })),
1195
+ children: async (id) => payload(await session("children", { path: { id }, query: { directory: input.directory } })),
1196
+ }) : [];
1142
1197
  const state = await operators.prepareMission(root, plan, actor === root ? undefined
1143
- : { sessionID: actor, callID: mission.callID }, mission.supersededRunID);
1198
+ : { sessionID: actor, callID: mission.callID }, mission.supersededRunID, terminalChildren);
1144
1199
  await restorePriorAcceptance(root, state);
1145
1200
  await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
1146
1201
  control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
@@ -1860,8 +1915,20 @@ export function createProfiledPlugin(profile, assetVersion) {
1860
1915
  },
1861
1916
  "experimental.chat.system.transform": async (request, output) => {
1862
1917
  const root = await rootFor(request.sessionID) ?? await proposalPromptRoot(request.sessionID);
1863
- if (!root)
1918
+ if (!root) {
1919
+ const who = await identity(request.sessionID);
1920
+ if (who.parent === undefined && who.role === undefined &&
1921
+ (await missions.read(request.sessionID))?.phase === "cancelled") {
1922
+ // OpenCode retains the same conversation when the user switches agents. A prior
1923
+ // INTERRUPTED report describes only its Sortie run; it cannot revoke Build's
1924
+ // native tools or turn a new independent request into a cancelled mission.
1925
+ (output.system ??= []).push("SORTIE_PROFILE_INACTIVE: This is a non-Sortie agent. " +
1926
+ "Any earlier INTERRUPTED mission or cancelled operator run in this session is historical state of the Sortie profile, " +
1927
+ "not a restriction on this agent's current user request. Use the current agent's native tools for independent work. " +
1928
+ "Do not revive, overwrite or claim completion of that historical Sortie run.");
1929
+ }
1864
1930
  return;
1931
+ }
1865
1932
  await core["experimental.chat.system.transform"]?.(request, output);
1866
1933
  (output.system ??= []).push(`SORTIE_RUNTIME_PROFILE ${profile.id}; marker ${assetVersion}. ` +
1867
1934
  `Shared MkII protocol role names are logical: ${protocolMap}. Use only ${profile.toolPrefix} tools for this profile. ` +
package/dist/plugin/v2.js CHANGED
@@ -63,13 +63,14 @@ function legacyMessage(value, session, agent) {
63
63
  if (!record(value) || typeof value.id !== "string" || typeof value.type !== "string")
64
64
  return undefined;
65
65
  const id = value.id;
66
+ const originalAgent = record(value.metadata) ? string(value.metadata.agent) ?? agent : agent;
66
67
  if (value.type === "user") {
67
- return { info: { id, sessionID: session, role: "user", agent, time: value.time },
68
+ return { info: { id, sessionID: session, role: "user", agent: originalAgent, time: value.time },
68
69
  parts: [{ id: `${id}-text`, sessionID: session, messageID: id, type: "text", text: String(value.text ?? "") }] };
69
70
  }
70
71
  if (value.type !== "assistant") {
71
72
  if (value.type === "synthetic")
72
- return { info: { id, sessionID: session, role: "user", agent, time: value.time },
73
+ return { info: { id, sessionID: session, role: "user", agent: originalAgent, time: value.time },
73
74
  parts: [{ id: `${id}-text`, sessionID: session, messageID: id, type: "text", text: String(value.text ?? ""), synthetic: true }] };
74
75
  return undefined;
75
76
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.12.7",
3
+ "version": "0.12.8",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",