sortie-dogs 0.12.19 → 0.12.21

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.
@@ -18,7 +18,10 @@ import { publishMissionProgress } from "./mission-progress.js";
18
18
  import { completedMissionReviewPrompts, initialMissionReviewPrompt, missionReviewBaseline, missionReviewSource } from "./mission-review.js";
19
19
  import { missionLocations, missionLocationPacket } from "./mission-location.js";
20
20
  import { SOURCE_REVIEW_RISK_TAGS } from "../core/consultation.js";
21
- import { createHash } from "node:crypto";
21
+ import { createHash, randomUUID } from "node:crypto";
22
+ import { proposeTerminalRescue } from "../core/terminal-rescue-policy.js";
23
+ import { DEFAULT_TERMINAL_RESCUE_MODEL } from "./terminal-rescue-host.js";
24
+ import { readHostModels } from "./model-routing-hook.js";
22
25
  const SERIAL_CAPABILITIES = new Set([
23
26
  "sortie_bind_write_gate", "sortie_release_write_gate", "sortie_check_contract",
24
27
  "sortie_compact_and_continue", "sortie_enable_backlog_drain", "sortie_reflection",
@@ -84,6 +87,17 @@ export function previewModelCatalog(routes = PREVIEW_ROUTES, base = BUILT_IN_MOD
84
87
  }
85
88
  const record = (value) => value !== null && typeof value === "object" && !Array.isArray(value);
86
89
  const payload = (value) => record(value) && "data" in value ? value.data : value;
90
+ function missionConsultationDetails(role, prompt) {
91
+ const marker = role === "advisor" ? /^strategy_trigger:\s*(\S+)\s*$/mu : /^missing_evidence_code:\s*(\S+)\s*$/mu;
92
+ const match = marker.exec(prompt);
93
+ const lines = prompt.split(/\r?\n/u);
94
+ const start = match === null ? 0 : lines.findIndex(line => marker.test(line)) + 1;
95
+ const question = lines.slice(Math.max(0, start)).map(line => line.trim()).find(line => line.length > 0 &&
96
+ !/^(?:project_root|known_paths|missing_evidence_code|strategy_trigger|role|task_id|acceptance|validation)\s*:/u.test(line));
97
+ const summary = (question ?? prompt.trim().split(/\r?\n/u).find(line => line.trim()) ?? "consultation requested").slice(0, 1000);
98
+ return { reason: summary, ...(question === undefined ? {} : { question: question.slice(0, 1000) }),
99
+ ...(match === null ? {} : { trigger: match[1].slice(0, 128) }) };
100
+ }
87
101
  /** Prove native V2 termination of the old dispatch, including earlier units and consultations. */
88
102
  export async function terminalCancelledMissionChildren(profile, root, previous, budget, host) {
89
103
  if (!budget || budget.reserved_units !== 0)
@@ -153,6 +167,7 @@ export function createProfiledPlugin(profile, assetVersion) {
153
167
  const selected = new Map();
154
168
  const operatorParents = new Map();
155
169
  const taskOwners = new Map();
170
+ const missionRescueSelections = new Map();
156
171
  const operatorTurnLifecycle = new Map();
157
172
  const historicalTurnMessages = new Map();
158
173
  const dispatchTransitions = new Map();
@@ -189,6 +204,19 @@ export function createProfiledPlugin(profile, assetVersion) {
189
204
  const result = payload(await session(typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages", { path: { id }, query: { directory: input.directory } }));
190
205
  return Array.isArray(result) ? result.filter(record) : [];
191
206
  }
207
+ async function observedSessionModel(id) {
208
+ const info = payload(await session("get", { path: { id }, query: { directory: input.directory } }).catch(() => undefined));
209
+ if (!record(info))
210
+ return { outcome: "unknown" };
211
+ const model = record(info.model) ? info.model : info;
212
+ const provider = typeof model.providerID === "string" ? model.providerID : info.providerID;
213
+ const modelID = typeof model.modelID === "string" ? model.modelID : typeof model.id === "string" ? model.id : info.modelID;
214
+ const variant = typeof model.variant === "string" ? model.variant : typeof info.variant === "string" ? info.variant : undefined;
215
+ const outcome = ["succeeded", "completed"].includes(String(info.outcome)) ? "completed"
216
+ : ["failed", "interrupted", "cancelled"].includes(String(info.outcome)) ? "failed" : "unknown";
217
+ return { ...(typeof provider === "string" && typeof modelID === "string" ? { model: `${provider}/${modelID}` } : {}),
218
+ ...(variant === undefined ? {} : { variant }), outcome };
219
+ }
192
220
  async function identity(id) {
193
221
  const info = payload(await session("get", { path: { id }, query: { directory: input.directory } }));
194
222
  const parent = record(info) && typeof info.parentID === "string" ? info.parentID : undefined;
@@ -466,8 +494,52 @@ export function createProfiledPlugin(profile, assetVersion) {
466
494
  const unit = run.units.find(unit => unit.callID === result.callID);
467
495
  if (!unit)
468
496
  return;
497
+ const recordedAttempt = [...(mission.attempts ?? [])].reverse().find(attempt => attempt.callID === result.callID);
498
+ const observed = result.childSessionID ? await observedSessionModel(result.childSessionID) : { outcome: "unknown" };
499
+ const currentCandidate = result.resultClass === "acceptance" && result.failure?.outcome === "fail"
500
+ ? await missionReviewSource(input.directory, run, [], mission.reviewBaseline, mission.reviewScope).then(value => value.fingerprint)
501
+ : undefined;
502
+ const attemptFailure = result.disposition === "cancelled"
503
+ ? { category: "cancellation", code: "native-task-cancelled" }
504
+ : result.resultClass === "acceptance" && result.failure?.outcome === "fail" && observed.outcome === "completed"
505
+ ? { category: "implementation",
506
+ code: `validation-failed:${result.failure.exitCode ?? "unknown"}` }
507
+ : result.resultClass === "process-defect"
508
+ ? { category: "contract", code: result.resultClass }
509
+ : { category: "unknown", code: result.resultClass };
469
510
  const progress = { unit: `${run.runID}/${unit.unit.id}`, title: unit.unit.title, status: unit.status, at: new Date().toISOString() };
470
- await missions.update(result.rootSessionID, state => { state.progress.push(progress); });
511
+ await missions.update(result.rootSessionID, state => {
512
+ state.progress.push(progress);
513
+ const attempt = recordedAttempt && state.attempts?.find(item => item.attemptID === recordedAttempt.attemptID);
514
+ if (attempt) {
515
+ attempt.status = result.disposition;
516
+ attempt.resultClass = result.resultClass;
517
+ attempt.nativeOutcome = observed.outcome;
518
+ if (result.childSessionID)
519
+ attempt.childSessionID = result.childSessionID;
520
+ if (observed.model)
521
+ attempt.observedModel = observed.model;
522
+ if (observed.variant)
523
+ attempt.observedVariant = observed.variant;
524
+ if (result.disposition !== "succeeded")
525
+ attempt.failure = attemptFailure;
526
+ else
527
+ delete attempt.failure;
528
+ if (currentCandidate)
529
+ attempt.candidateID = currentCandidate;
530
+ }
531
+ if (state.rescue && state.rescue.attemptID === unit.terminalRescue?.attempt_id) {
532
+ state.rescue.status = result.disposition === "succeeded" ? "succeeded"
533
+ : result.disposition === "cancelled" ? "cancelled" : "failed";
534
+ state.rescue.outcome = result.disposition;
535
+ if (observed.model)
536
+ state.rescue.observedModel = observed.model;
537
+ if (observed.variant)
538
+ state.rescue.observedVariant = observed.variant;
539
+ }
540
+ });
541
+ if (result.childSessionID)
542
+ missionRescueSelections.delete(result.childSessionID);
471
543
  await publishMissionProgress(result.rootSessionID, { description: `🐾 ${unit.status === "succeeded" ? "✅" : "🔧"} ${unit.unit.title}`,
472
544
  sortie_progress: progress });
473
545
  },
@@ -507,6 +579,12 @@ export function createProfiledPlugin(profile, assetVersion) {
507
579
  next_action: "Use a known Coordinator's native location to resume. Absence here does not prove absence in other locations." },
508
580
  } : {}) };
509
581
  }
582
+ function sourceReconciliationRequired(mission, previous) {
583
+ return previous?.phase === "cancelled" && !["completed", "cancelled"].includes(mission.phase) &&
584
+ mission.runID === null && mission.supersededRunID === undefined &&
585
+ (previous.sourceRefs[0] !== `user:${mission.requests[0]?.id}` ||
586
+ !previous.sourceRefs.every(ref => mission.requests.some(request => ref === `user:${request.id}`)));
587
+ }
510
588
  async function missionAuthority(id) {
511
589
  const root = await rootFor(id);
512
590
  if (!root)
@@ -516,11 +594,54 @@ export function createProfiledPlugin(profile, assetVersion) {
516
594
  throw new Error("mission-controller-required");
517
595
  return { root, mission };
518
596
  }
597
+ async function missionWorkerTerminalProof(root, mission, run, attempt) {
598
+ if (!attempt.callID || !attempt.childSessionID || !mission.coordinator ||
599
+ !control) {
600
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
601
+ }
602
+ if (attempt.runID !== run.runID || attempt.unitID !== run.units.find(unit => unit.unit.id === attempt.unitID)?.unit.id) {
603
+ return { status: "non_rescue", reason: "terminal_identity_conflict" };
604
+ }
605
+ try {
606
+ await control.assertActiveGoal(root, await operators.completionGoalFingerprint(run));
607
+ }
608
+ catch {
609
+ return { status: "non_rescue", reason: "goal_not_active" };
610
+ }
611
+ const budget = await control.currentBudget(root);
612
+ if (!budget || budget.reserved_units !== 0)
613
+ return { status: "non_rescue", reason: "goal_reservation_unsettled" };
614
+ const child = attempt.childSessionID;
615
+ const who = await identity(child);
616
+ if (who.role !== "dog-worker" || who.parent !== mission.coordinator) {
617
+ return { status: "non_rescue", reason: "terminal_identity_conflict" };
618
+ }
619
+ const info = payload(await session("get", { path: { id: child }, query: { directory: input.directory } }).catch(() => undefined));
620
+ const outcome = record(info) ? String(info.outcome) : "unknown";
621
+ if (!record(info) || info.id !== child || info.parentID !== mission.coordinator ||
622
+ !["succeeded", "completed"].includes(outcome)) {
623
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
624
+ }
625
+ const descendants = payload(await session("children", { path: { id: child }, query: { directory: input.directory } }).catch(() => undefined));
626
+ if (!Array.isArray(descendants) || descendants.length !== 0) {
627
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
628
+ }
629
+ const terminalObserver = control?.missionWorkerTerminal;
630
+ if (!terminalObserver) {
631
+ return { status: "non_rescue", reason: "terminal_not_reconciled" };
632
+ }
633
+ if (!await terminalObserver(child, run.units.find(unit => unit.unit.id === attempt.unitID).unit.write)) {
634
+ return { status: "non_rescue", reason: "writer_not_released" };
635
+ }
636
+ return { status: "ready", child };
637
+ }
519
638
  async function reconcileMissionDispatch(root) {
520
639
  const mission = await missions.read(root);
521
- if (!mission?.dispatchOpen || !mission.callID || ["cancelled", "completed"].includes(mission.phase) ||
522
- taskOwners.has(mission.callID))
640
+ if (!mission?.dispatchOpen || !mission.callID || ["cancelled", "completed"].includes(mission.phase))
523
641
  return mission;
642
+ // A native Task can be interrupted without an execute.after/event callback. Its in-memory
643
+ // owner then survives even though the host has already persisted a terminal tool part.
644
+ // The native record, not that cache, decides whether this exact dispatch can be resumed.
524
645
  const history = await messages(root);
525
646
  const finished = history.flatMap(message => Array.isArray(message.parts) ? message.parts : []).filter(part => record(part) && part.type === "tool" && part.tool === "task" && part.callID === mission.callID &&
526
647
  record(part.state) && ["completed", "error"].includes(String(part.state.status)) &&
@@ -535,11 +656,20 @@ export function createProfiledPlugin(profile, assetVersion) {
535
656
  if (who.parent !== root || who.role !== "dog-operator")
536
657
  return mission;
537
658
  }
538
- return missions.reconcileFinishedDispatch(root, mission.id, mission.callID);
659
+ const reconciled = await missions.reconcileFinishedDispatch(root, mission.id, mission.callID);
660
+ taskOwners.delete(mission.callID);
661
+ return reconciled;
539
662
  }
540
663
  function missionDispatchPacket(mission, run) {
541
664
  const packet = { ...missionPacket(mission, run), project_root: input.directory,
542
665
  coordinator_dispatch: mission.dispatchOpen ? "active" : ["completed", "cancelled"].includes(mission.phase) ? "terminal" : "resumable" };
666
+ if (sourceReconciliationRequired(mission, run))
667
+ return { ...packet, status: "mission-source-reconciliation-required",
668
+ next_action: mission.dispatchOpen ? "The Coordinator Task is still active. Do not redispatch; wait for its native completion and reconcile via operator_status."
669
+ : `The cancelled run's source_refs cannot prove this mission is the same goal. Do not dispatch or repeat plan_units. ` +
670
+ `If these saved requirements reflect the user's changed or narrowed scope, Operator: call ${profile.toolPrefix}start_mission ` +
671
+ `with intent=replace and the exact requirements array shown here. The host repairs this mission in place, retains spend, ` +
672
+ `and verifies old children before preparing a Worker. Otherwise obtain the user's scope decision.` };
543
673
  if (!mission.dispatchOpen && (["open", "running"].includes(mission.phase) ||
544
674
  (mission.phase === "submitted" && mission.submission?.status !== "ready"))) {
545
675
  return { ...packet, task: missions.task(mission),
@@ -560,8 +690,7 @@ export function createProfiledPlugin(profile, assetVersion) {
560
690
  }
561
691
  // A different original user request needs the explicit, host-proven supersession path.
562
692
  // Only the same source can retain a cancelled run's acceptance as a prefix.
563
- if (previous.sourceRefs[0] !== `user:${mission.requests[0]?.id}` ||
564
- !previous.sourceRefs.every(ref => mission.requests.some(request => ref === `user:${request.id}`))) {
693
+ if (sourceReconciliationRequired(mission, previous)) {
565
694
  throw new Error("mission-cancelled-source-unproven: the cancelled run's source_refs do not prove this is the same request. " +
566
695
  "If the user changed or narrowed the goal, start_mission with intent=replace and the complete current requirements; " +
567
696
  "otherwise preserve the earlier accepted criteria and establish the original source before continuing.");
@@ -838,6 +967,9 @@ export function createProfiledPlugin(profile, assetVersion) {
838
967
  const activeMission = await reconcileMissionDispatch(root);
839
968
  if (activeMission && !["cancelled", "completed"].includes(activeMission.phase) && activeMission.runID === null && context.sessionID !== root) {
840
969
  const budget = await control.currentBudget(root);
970
+ const prior = await operators.read(root);
971
+ if (sourceReconciliationRequired(activeMission, prior))
972
+ return JSON.stringify({ ...missionDispatchPacket(activeMission, prior), budget });
841
973
  return JSON.stringify({ ...missionDispatchPacket(activeMission, await operators.read(root)), budget,
842
974
  next_action: "This mission has no unit plan yet. Call plan_units with the next useful work under the current requirements; prior run results are historical." });
843
975
  }
@@ -1339,9 +1471,9 @@ export function createProfiledPlugin(profile, assetVersion) {
1339
1471
  return JSON.stringify({ ...proposalIdentity(committed), execution: JSON.parse(preparedTask(prepared.state)) });
1340
1472
  });
1341
1473
  } };
1342
- 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`;
1474
+ 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`, skipMissionConsultation = `${profile.toolPrefix}skip_mission_consultation`;
1343
1475
  const stringList = { type: "array", items: { type: "string" } };
1344
- tools[startMission] = { description: "Operator: save the current user requirements. Use intent=replace when the user changes an existing request (version, parallelism, target): host cancels the previous run and archives its requirements/results while retaining spend. Supply the complete current requirements, retaining constraints the user has not changed. For separate work in a different location use intent=new. Dispatch the Coordinator immediately, or plan_units for a known single-unit procedure; item count and runtime alone do not require a Coordinator.",
1476
+ tools[startMission] = { description: "Operator: save the current user requirements. Use intent=replace when the user changes an existing request (version, parallelism, target): host cancels the previous run and archives its requirements/results while retaining spend. If status reports mission-source-reconciliation-required and the saved requirements reflect that changed scope, call intent=replace with those exact requirements: the host repairs this mission in place and keeps its Coordinator. Supply the complete current requirements, retaining constraints the user has not changed. For separate work in a different location use intent=new. Dispatch the Coordinator immediately, or plan_units for a known single-unit procedure; item count and runtime alone do not require a Coordinator.",
1345
1477
  args: { requirements: { ...stringList, minItems: 1, maxItems: 64 },
1346
1478
  kind: { type: "string", enum: ["implementation", "operation"], description: "Use operation for running an existing benchmark, command or procedure. The host records its actual execution separately from setup and checks.", "x-sortie-optional": true },
1347
1479
  intent: { type: "string", enum: ["", "continue", "new", "replace"], "x-sortie-optional": true } }, execute: async (args, context) => {
@@ -1350,6 +1482,27 @@ export function createProfiledPlugin(profile, assetVersion) {
1350
1482
  throw new Error("mission-intent-invalid");
1351
1483
  return serializeDispatchTransition(context.sessionID, async () => {
1352
1484
  const existing = await reconcileMissionDispatch(context.sessionID);
1485
+ const prior = await operators.read(context.sessionID);
1486
+ const requirements = args.requirements;
1487
+ if (args.intent === "replace" && existing && !existing.dispatchOpen && existing.runID === null &&
1488
+ existing.phase !== "cancelled" && existing.phase !== "completed" &&
1489
+ (existing.phase !== "submitted" || existing.submission?.status === "blocked") &&
1490
+ sourceReconciliationRequired(existing, prior) && Array.isArray(requirements) &&
1491
+ requirements.length === existing.requirements.length &&
1492
+ existing.requirements.every((item, index) => item.text === requirements[index])) {
1493
+ const repaired = await missions.update(context.sessionID, state => {
1494
+ if (state.id !== existing.id || state.runID !== null || state.dispatchOpen)
1495
+ throw new Error("mission-source-reconciliation-stale");
1496
+ state.supersededRunID = prior.runID;
1497
+ state.requirementsReplaced = true;
1498
+ if (state.phase === "submitted") {
1499
+ state.phase = "running";
1500
+ state.submission = null;
1501
+ }
1502
+ });
1503
+ return JSON.stringify({ ...locationObservation(context.sessionID), ...missionDispatchPacket(repaired, prior),
1504
+ budget: await control.currentBudget(context.sessionID) });
1505
+ }
1353
1506
  if (args.intent === "replace" && existing && !["completed", "cancelled"].includes(existing.phase)) {
1354
1507
  await stop(context.sessionID, "explicit-cancellation", false);
1355
1508
  }
@@ -1358,12 +1511,16 @@ export function createProfiledPlugin(profile, assetVersion) {
1358
1511
  if (relocated)
1359
1512
  return JSON.stringify({ ...relocated, budget: await control.currentBudget(context.sessionID) });
1360
1513
  }
1361
- const prior = await operators.read(context.sessionID);
1362
- let mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements, args.intent === "replace" || args.intent === "new", {
1514
+ const cancelled = await operators.read(context.sessionID);
1515
+ let mission = await missions.start(context.sessionID, requirements, args.intent === "replace" || args.intent === "new", {
1363
1516
  kind: args.kind === "operation" ? "operation" : "implementation",
1364
1517
  context: missionConversationContext(await messages(context.sessionID)),
1365
- ...(args.intent === "replace" && prior?.phase === "cancelled" ? { cancelledRunID: prior.runID } : {}),
1366
- }));
1518
+ ...(args.intent === "replace" && cancelled?.phase === "cancelled" ? { cancelledRunID: cancelled.runID } : {}),
1519
+ });
1520
+ if (sourceReconciliationRequired(mission, cancelled))
1521
+ return JSON.stringify({ ...locationObservation(context.sessionID),
1522
+ ...missionDispatchPacket(mission, cancelled), budget: await control.currentBudget(context.sessionID) });
1523
+ mission = await retainCancelledMissionAcceptance(context.sessionID, mission, cancelled);
1367
1524
  if (!mission.reviewBaseline) {
1368
1525
  const baseline = await missionReviewBaseline(input.directory);
1369
1526
  if (baseline)
@@ -1375,8 +1532,146 @@ export function createProfiledPlugin(profile, assetVersion) {
1375
1532
  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." }) });
1376
1533
  });
1377
1534
  } };
1535
+ tools[skipMissionConsultation] = { description: "Coordinator: durably record why a concrete optional Advisor/Scout consultation is unnecessary. This is observation only: it adds no approval, consultation requirement, or dispatch gate.",
1536
+ args: { role: { type: "string", enum: ["advisor", "scout"] }, reason: stringSchema }, execute: async (args, context) => {
1537
+ const { root, mission } = await missionAuthority(context.sessionID);
1538
+ if (context.sessionID !== mission.coordinator || (await identity(context.sessionID)).role !== "dog-operator") {
1539
+ throw new Error("mission-consultation-coordinator-required");
1540
+ }
1541
+ const consultation = await missions.recordConsultationSkip(root, args.role, args.reason);
1542
+ return JSON.stringify({ status: "recorded", consultation: consultation.consultations?.at(-1) });
1543
+ } };
1544
+ const retryMissionUnit = `${profile.toolPrefix}retry_mission_unit`, rescueMissionUnit = `${profile.toolPrefix}rescue_mission_unit`;
1545
+ tools[retryMissionUnit] = { description: "Coordinator: after one host-classified implementation validation failure, dispatch exactly one same-scope ordinary Mission Worker remediation. Native child termination, released reservation/writer, and remaining cumulative unit budget are required; this does not change acceptance or scope.",
1546
+ args: { unit_id: stringSchema }, execute: async (args, context) => {
1547
+ const { root, mission } = await missionAuthority(context.sessionID);
1548
+ if (context.sessionID !== mission.coordinator || (await identity(context.sessionID)).role !== "dog-operator") {
1549
+ throw new Error("mission-remediation-coordinator-required");
1550
+ }
1551
+ const run = await operators.required(root), unitID = String(args.unit_id);
1552
+ const unit = run.units.find(item => item.unit.id === unitID);
1553
+ const attempt = [...(mission.attempts ?? [])].reverse().find(item => item.runID === run.runID && item.unitID === unitID);
1554
+ if (!unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || unit.failure?.outcome !== "fail" ||
1555
+ unit.normalRemediationUsed || !attempt || attempt.kind !== "implementation" || attempt.status !== "failed" ||
1556
+ attempt.failure?.category !== "implementation")
1557
+ throw new Error("operator-mission-normal-remediation-unavailable");
1558
+ const terminal = await missionWorkerTerminalProof(root, mission, run, attempt);
1559
+ if (terminal.status !== "ready") {
1560
+ const packet = missionPacket(mission, run);
1561
+ return JSON.stringify({ ...packet, run_status: packet.status, status: "non_rescue", reason: terminal.reason });
1562
+ }
1563
+ const budget = await control.currentBudget(root);
1564
+ if (!budget || budget.remaining_units < 1) {
1565
+ const packet = missionPacket(mission, run);
1566
+ return JSON.stringify({ ...packet, run_status: packet.status, status: "non_rescue", reason: "budget_exhausted", budget });
1567
+ }
1568
+ const prepared = await operators.prepareMissionNormalRemediation(root, run.runID, unitID);
1569
+ control.enableUnits(root, 1);
1570
+ const next = await operators.next(root, context.sessionID);
1571
+ return JSON.stringify({ status: "normal_remediation_prepared", run_id: prepared.runID,
1572
+ unit_id: unitID, task: record(next) ? next.task : undefined, budget: await control.currentBudget(root) });
1573
+ } };
1574
+ tools[rescueMissionUnit] = { description: "Coordinator: only after the same unit's one ordinary remediation failed the same declared validation, evaluate one optional Astra Rescue. The host requires the current Mission failure class, reconciled native terminal, exact current candidate/acceptance/scope/validation, discovered model availability, one-use and cumulative budget; the returned Worker must still pass ordinary validation, independent review, and root acceptance. This is not the legacy sortie_execute_terminal_rescue tool.",
1575
+ args: { unit_id: stringSchema }, execute: async (args, context) => {
1576
+ const { root, mission } = await missionAuthority(context.sessionID);
1577
+ if (context.sessionID !== mission.coordinator || (await identity(context.sessionID)).role !== "dog-operator") {
1578
+ throw new Error("mission-rescue-coordinator-required");
1579
+ }
1580
+ const run = await operators.required(root), unitID = String(args.unit_id);
1581
+ const unit = run.units.find(item => item.unit.id === unitID);
1582
+ const attempt = [...(mission.attempts ?? [])].reverse().find(item => item.runID === run.runID && item.unitID === unitID);
1583
+ const nonRescue = async (reason) => {
1584
+ await missions.update(root, state => {
1585
+ state.rescue = { attemptID: attempt?.attemptID ?? randomUUID(),
1586
+ runID: run.runID, unitID, status: "non_rescue", reason };
1587
+ });
1588
+ const packet = missionPacket(await missions.required(root), await operators.read(root));
1589
+ return JSON.stringify({ ...packet, run_status: packet.status, status: "non_rescue", reason });
1590
+ };
1591
+ if (!unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || unit.failure?.outcome !== "fail" ||
1592
+ unit.normalRemediationUsed !== true || unit.terminalRescue || !attempt || attempt.kind !== "normal_remediation" ||
1593
+ attempt.status !== "failed" || attempt.resultClass !== "acceptance" || attempt.failure?.category !== "implementation") {
1594
+ return nonRescue("normal_remediation_not_exhausted");
1595
+ }
1596
+ const terminal = await missionWorkerTerminalProof(root, mission, run, attempt);
1597
+ if (terminal.status !== "ready")
1598
+ return nonRescue(terminal.reason);
1599
+ const candidate = await missionReviewSource(input.directory, run, [], mission.reviewBaseline, mission.reviewScope);
1600
+ if (!attempt.candidateID || attempt.candidateID !== candidate.fingerprint)
1601
+ return nonRescue("accepted_candidate_changed");
1602
+ const budget = await control.currentBudget(root);
1603
+ if (!budget || budget.reserved_units !== 0)
1604
+ return nonRescue("budget_exhausted");
1605
+ const budgetDetails = budget;
1606
+ const hasTimeLimit = typeof budgetDetails.time_limit_ms === "number";
1607
+ const timeLimit = hasTimeLimit ? budgetDetails.time_limit_ms : Number.MAX_SAFE_INTEGER;
1608
+ const consumedTime = hasTimeLimit ? budgetDetails.settled_time_ms : 0;
1609
+ const hasCostLimit = typeof budgetDetails.cost_limit_usd === "number";
1610
+ const costLimit = hasCostLimit ? budgetDetails.cost_limit_usd : Number.MAX_VALUE;
1611
+ const consumedCost = hasCostLimit ? budgetDetails.settled_cost_usd : (budgetDetails.settled_cost_usd ?? 0);
1612
+ if (!Number.isSafeInteger(timeLimit) || timeLimit < 0 || !Number.isSafeInteger(consumedTime) || consumedTime < 0 ||
1613
+ !Number.isFinite(costLimit) || costLimit < 0 || typeof consumedCost !== "number" || !Number.isFinite(consumedCost) || consumedCost < 0) {
1614
+ return nonRescue("budget_exhausted");
1615
+ }
1616
+ if ((hasTimeLimit && consumedTime >= timeLimit) || (hasCostLimit && consumedCost >= costLimit)) {
1617
+ return nonRescue("budget_exhausted");
1618
+ }
1619
+ const nativeModels = await readHostModels(input.client);
1620
+ const target = nativeModels?.has(DEFAULT_TERMINAL_RESCUE_MODEL)
1621
+ ? { model: DEFAULT_TERMINAL_RESCUE_MODEL, variant: null } : null;
1622
+ const terminalIdentity = { run_id: run.runID, unit_id: unitID, attempt_id: attempt.attemptID,
1623
+ predecessor_attempt_id: attempt.predecessorAttemptID ?? null, candidate_id: candidate.fingerprint,
1624
+ route_id: `${profile.id}:mission:${unitID}`, child_id: terminal.child, call_id: attempt.callID };
1625
+ const priorFailure = attempt.failure;
1626
+ const policy = proposeTerminalRescue({ prior_attempt: { identity: terminalIdentity, role: "implementation", recovery_kind: "normal_remediation",
1627
+ disposition: "failed", failure: priorFailure ? { category: priorFailure.category, code: priorFailure.code } : null },
1628
+ terminal_reconciliation: { current: terminalIdentity, observation: { identity: terminalIdentity, disposition: "failed" }, evidence: {
1629
+ terminal: "satisfied", tools_quiescent: "satisfied", artifact_window_closed: "satisfied", writer_released: "satisfied",
1630
+ gate_released: "satisfied", lease_released: "satisfied", worktree_released: "satisfied"
1631
+ } },
1632
+ accepted_base: { candidate_id: candidate.fingerprint, contract_id: run.acceptanceFingerprint, scope: [...unit.unit.write],
1633
+ acceptance: [...run.acceptance], validation: [...unit.unit.validation] }, available_target: target,
1634
+ explicit_override: (attempt.selectedModel !== undefined && attempt.selectedModel !== PREVIEW_WORKER_ROUTE.model) ||
1635
+ (attempt.observedModel !== undefined && attempt.observedModel !== PREVIEW_WORKER_ROUTE.model) ||
1636
+ (attempt.observedVariant !== undefined && attempt.observedVariant !== PREVIEW_WORKER_ROUTE.variant),
1637
+ unit_rescue_count: (mission.attempts ?? []).filter(item => item.runID === run.runID && item.unitID === unitID && item.kind === "astra_rescue").length,
1638
+ active_run_rescue_count: (mission.attempts ?? []).filter(item => item.runID === run.runID && item.kind === "astra_rescue").length,
1639
+ budget_limits: { counters: { recovery_actions: budget.max_units, probe_iterations: 0, model_attempts: budget.max_units },
1640
+ resources: { time_ms: timeLimit, cost_usd: costLimit } },
1641
+ budget_consumed: { counters: { recovery_actions: budget.consumed_units + budget.reserved_units, probe_iterations: 0,
1642
+ model_attempts: budget.consumed_units + budget.reserved_units },
1643
+ resources: { time_ms: consumedTime, cost_usd: consumedCost } },
1644
+ budget_request: { counters: { recovery_actions: 1, probe_iterations: 0, model_attempts: 1 },
1645
+ // Current Mission charges native time/cost through its cumulative goal ledger at normal dispatch settlement;
1646
+ // this policy reservation consumes one existing unit, not an invented parallel resource allowance.
1647
+ resources: { time_ms: 0, cost_usd: 0 } } });
1648
+ if (policy.status !== "proposed")
1649
+ return nonRescue(policy.reason);
1650
+ const rescueAttemptID = randomUUID();
1651
+ const prepared = await operators.prepareMissionTerminalRescue(root, run.runID, unitID, {
1652
+ attempt_id: rescueAttemptID, selected_model: policy.proposal.target.model,
1653
+ selected_variant: policy.proposal.target.variant,
1654
+ accepted_base: { candidate_id: policy.proposal.accepted_base_candidate_id, contract_id: policy.proposal.contract_id,
1655
+ scope: policy.proposal.scope, acceptance: policy.proposal.acceptance, validation: policy.proposal.validation },
1656
+ });
1657
+ control.enableUnits(root, 1);
1658
+ await missions.update(root, state => {
1659
+ state.rescue = { attemptID: rescueAttemptID, runID: run.runID, unitID,
1660
+ selectedModel: policy.proposal.target.model, selectedVariant: policy.proposal.target.variant, status: "prepared" };
1661
+ });
1662
+ const next = await operators.next(root, context.sessionID);
1663
+ return JSON.stringify({ status: "prepared", rescue: { attempt_id: rescueAttemptID, selected_model: policy.proposal.target.model,
1664
+ selected_variant: policy.proposal.target.variant, accepted_base_candidate_id: policy.proposal.accepted_base_candidate_id,
1665
+ obligations: policy.proposal.obligations }, run_id: prepared.runID, unit_id: unitID,
1666
+ task: record(next) ? next.task : undefined, budget: await control.currentBudget(root) });
1667
+ } };
1378
1668
  async function declareMissionUnits(root, actor, mission, raw, reason, execution) {
1379
1669
  const previous = await operators.read(root);
1670
+ if (sourceReconciliationRequired(mission, previous))
1671
+ return JSON.stringify({ ...missionDispatchPacket(mission, previous),
1672
+ next_action: `Coordinator: do not repeat plan_units. Call ${submitMission} with status=blocked and report the saved ` +
1673
+ `requirements to Operator. Operator can relink this mission in place via ${startMission} intent=replace ` +
1674
+ `when they match the user's changed scope; no Worker can start before that decision.` });
1380
1675
  mission = await retainCancelledMissionAcceptance(root, mission, previous);
1381
1676
  let operation = mission.execution;
1382
1677
  // Models can serialize an optional operation field as an empty object for a normal edit.
@@ -1402,7 +1697,18 @@ export function createProfiledPlugin(profile, assetVersion) {
1402
1697
  if (!reason?.trim())
1403
1698
  throw new Error("mission-replan-reason-required: name the observed correction or write-scope extension");
1404
1699
  }
1405
- const budget = await control.currentBudget(root);
1700
+ // A cancelled V2 delegate may leave its Worker Task running after the parent Task aborts.
1701
+ // Reconcile that exact native orphan before checking the cumulative budget or replacing
1702
+ // the run; an unproven orphan remains reserved and the normal terminal check still blocks.
1703
+ const cancelledPredecessor = previous?.phase === "cancelled" &&
1704
+ (mission.supersededRunID === previous.runID ||
1705
+ (mission.supersededRunID === undefined && ["explicit-cancellation", "agent-changed"].includes(previous.decision ?? "") &&
1706
+ previous.units.some(unit => (previous.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null)));
1707
+ let budget = await control.currentBudget(root);
1708
+ if (cancelledPredecessor && budget?.reserved_units) {
1709
+ await control.reconcileAbortedOperatorOrphan(root);
1710
+ budget = await control.currentBudget(root);
1711
+ }
1406
1712
  if (budget && budget.remaining_units < plan.units.length && !same)
1407
1713
  throw new Error(`mission-budget-exhausted: plan needs ${plan.units.length} units; ${budget.remaining_units} remain. ` +
1408
1714
  "The current run is retained. Correct the plan within the remaining budget, or report a necessary cumulative extension to Operator.");
@@ -1413,10 +1719,6 @@ export function createProfiledPlugin(profile, assetVersion) {
1413
1719
  }
1414
1720
  // Cancellation marks the durable units before interrupting their native sessions. A cancelled
1415
1721
  // status alone therefore cannot prove the old Worker stopped or its reservation settled.
1416
- const cancelledPredecessor = previous?.phase === "cancelled" &&
1417
- (mission.supersededRunID === previous.runID ||
1418
- (mission.supersededRunID === undefined && ["explicit-cancellation", "agent-changed"].includes(previous.decision ?? "") &&
1419
- previous.units.some(unit => (previous.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null)));
1420
1722
  const terminalChildren = cancelledPredecessor
1421
1723
  ? await terminalCancelledMissionChildren(profile, root, previous, budget, {
1422
1724
  get: async (id) => {
@@ -1583,7 +1885,8 @@ export function createProfiledPlugin(profile, assetVersion) {
1583
1885
  await missions.update(context.sessionID, state => { state.phase = "completed"; });
1584
1886
  return result;
1585
1887
  } };
1586
- const ownTools = new Set([startMission, planUnits, expandUnit, reviewMission, submitMission, completeMission,
1888
+ const ownTools = new Set([startMission, planUnits, expandUnit, reviewMission, submitMission, completeMission, skipMissionConsultation,
1889
+ retryMissionUnit, rescueMissionUnit,
1587
1890
  prepare, repair, next, status, cancel, complete, resume, resolveContractRepair,
1588
1891
  beginProposal, submitProposal, approveProposal, reviseProposal, extendProposalBudget, reopenProposalScope,
1589
1892
  reconcileOrphan, reviseApprovedIntent]);
@@ -1798,17 +2101,36 @@ export function createProfiledPlugin(profile, assetVersion) {
1798
2101
  });
1799
2102
  }
1800
2103
  textParts[0].text = admitted.prompt;
2104
+ const current = await operators.required(root);
2105
+ const activeUnit = current.units.find(unit => unit.status === "running" && unit.childSessionID === chat.sessionID);
2106
+ if (activeUnit?.terminalRescue)
2107
+ missionRescueSelections.set(chat.sessionID, {
2108
+ attemptID: activeUnit.terminalRescue.attempt_id,
2109
+ model: activeUnit.terminalRescue.selected_model, variant: activeUnit.terminalRescue.selected_variant,
2110
+ });
1801
2111
  }
1802
2112
  }
1803
2113
  const mapped = translate(output, false);
1804
- await core["chat.message"]?.(translate(chat, false), mapped);
2114
+ const mappedChat = translate(chat, false);
2115
+ const rescueSelection = role === "dog-worker" ? missionRescueSelections.get(chat.sessionID) : undefined;
2116
+ if (rescueSelection)
2117
+ mappedChat.missionRescueSelection = { attempt_id: rescueSelection.attemptID,
2118
+ model: rescueSelection.model, variant: rescueSelection.variant };
2119
+ await core["chat.message"]?.(mappedChat, mapped);
1805
2120
  Object.assign(output, translate(mapped, true));
1806
2121
  if (role === "dog-worker")
1807
2122
  for (const part of output.parts) {
1808
2123
  if (record(part) && part.type === "text" && typeof part.text === "string")
1809
2124
  part.text = workerControlPrompt(part.text, true);
1810
2125
  }
1811
- if (role === "dog-worker" && explicitWorkerSelection) {
2126
+ if (rescueSelection) {
2127
+ const split = rescueSelection.model.indexOf("/");
2128
+ if (split <= 0)
2129
+ throw new Error("invalid-mission-terminal-rescue-model");
2130
+ output.message.model = { providerID: rescueSelection.model.slice(0, split), modelID: rescueSelection.model.slice(split + 1),
2131
+ ...(rescueSelection.variant === null ? {} : { variant: rescueSelection.variant }) };
2132
+ }
2133
+ else if (role === "dog-worker" && explicitWorkerSelection) {
1812
2134
  if (explicitWorkerSelection.model) {
1813
2135
  const split = explicitWorkerSelection.model.indexOf("/");
1814
2136
  if (split <= 0)
@@ -1907,6 +2229,11 @@ export function createProfiledPlugin(profile, assetVersion) {
1907
2229
  const mapped = translate(output, false);
1908
2230
  await core["tool.execute.before"]?.({ ...request, sessionID: root, agent: "dog-coordinator" }, mapped);
1909
2231
  Object.assign(output, translate(mapped, true));
2232
+ if ((consultRole === "dog-advisor" || consultRole === "dog-scout") && typeof args.prompt === "string") {
2233
+ const role = consultRole === "dog-advisor" ? "advisor" : "scout";
2234
+ await missions.recordConsultationDispatch(root, { role, callID: request.callID,
2235
+ ...missionConsultationDetails(role, args.prompt) });
2236
+ }
1910
2237
  taskOwners.set(request.callID, { root, actor: request.sessionID, operator: false, consultation: consultRole });
1911
2238
  return;
1912
2239
  }
@@ -1995,6 +2322,13 @@ export function createProfiledPlugin(profile, assetVersion) {
1995
2322
  throw new Error("operator-worker-task-required");
1996
2323
  let expandedWorker;
1997
2324
  if (delegated) {
2325
+ const pendingUnit = state.units.find(unit => unit.status !== "succeeded");
2326
+ if (pendingUnit?.terminalRescue) {
2327
+ const requiredModel = pendingUnit.terminalRescue.selected_model;
2328
+ if (args.model !== undefined && args.model !== requiredModel)
2329
+ throw new Error("operator-mission-terminal-rescue-model-mismatch");
2330
+ args.model = requiredModel;
2331
+ }
1998
2332
  if (!mission || mission.runID !== state.runID)
1999
2333
  await proposals.assertExecutionApproved(root, state.planHash);
2000
2334
  if (!mission || mission.runID !== state.runID)
@@ -2023,6 +2357,20 @@ export function createProfiledPlugin(profile, assetVersion) {
2023
2357
  }
2024
2358
  }
2025
2359
  }
2360
+ const operationArgs = record(mapped.args) ? mapped.args : {};
2361
+ // A decorated launch would spend on the real operation but leave the native mission observation empty.
2362
+ // Ask the same Worker to correct its shell input before that expensive side effect.
2363
+ if (who.role === "dog-worker" && ["bash", "shell"].includes(request.tool.toLowerCase()) && mission?.execution &&
2364
+ typeof operationArgs.command === "string" &&
2365
+ resolve(input.directory, typeof operationArgs.workdir === "string" ? operationArgs.workdir : ".") === mission.execution.directory) {
2366
+ const actual = normalizeCommand(operationArgs.command);
2367
+ const decorated = mission.execution.commands.some(declared => actual.startsWith(declared) &&
2368
+ /^\s*(?:\||\d?>|&&?|;)/u.test(actual.slice(declared.length)));
2369
+ if (decorated)
2370
+ throw new Error("mission-operation-command-not-observed: run the declared operation command exactly; " +
2371
+ "do not append a pipe, tee, redirection or chained command. Capture its tool output or write a separate result file afterward. " +
2372
+ "No new plan or approval is needed; if the run already started, inspect its existing state instead of starting another one.");
2373
+ }
2026
2374
  await core["tool.execute.before"]?.({ ...translate(request, false),
2027
2375
  ...(delegated ? { sessionID: root, agent: "dog-coordinator" } : {}) }, mapped);
2028
2376
  if (who.role === "dog-worker") {
@@ -2080,6 +2428,27 @@ export function createProfiledPlugin(profile, assetVersion) {
2080
2428
  }
2081
2429
  throw error;
2082
2430
  }
2431
+ if (delegated && mission?.runID === state.runID) {
2432
+ const admitted = await operators.required(root);
2433
+ const unit = admitted.units.find(candidate => candidate.callID === request.callID);
2434
+ const taskID = unit && /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1];
2435
+ if (unit && taskID) {
2436
+ const previousAttempt = [...(mission.attempts ?? [])].reverse().find(attempt => attempt.runID === admitted.runID && attempt.unitID === unit.unit.id);
2437
+ const kind = unit.terminalRescue ? "astra_rescue"
2438
+ : unit.normalRemediationUsed ? "normal_remediation" : "implementation";
2439
+ await missions.update(root, current => {
2440
+ current.attempts ??= [];
2441
+ if (current.attempts.some(attempt => attempt.callID === request.callID))
2442
+ return;
2443
+ current.attempts.push({ attemptID: unit.terminalRescue?.attempt_id ?? randomUUID(), runID: admitted.runID,
2444
+ unitID: unit.unit.id, taskID, ...(previousAttempt ? { predecessorAttemptID: previousAttempt.attemptID } : { predecessorAttemptID: null }),
2445
+ kind, status: "dispatched", callID: request.callID,
2446
+ ...(unit.terminalRescue ? { selectedModel: unit.terminalRescue.selected_model } :
2447
+ typeof args.model === "string" ? { selectedModel: args.model } : {}) });
2448
+ current.attempts = current.attempts.slice(-64);
2449
+ });
2450
+ }
2451
+ }
2083
2452
  },
2084
2453
  "tool.execute.after": async (request, output) => {
2085
2454
  const id = request.sessionID;
@@ -2124,6 +2493,16 @@ export function createProfiledPlugin(profile, assetVersion) {
2124
2493
  : `\n\nHOST: evidence-gap review ${count}/${MISSION_EVIDENCE_GAP_REVIEW_LIMIT}. Supply focused original-file excerpts through review_mission evidence and traces. Do not create an evidence-copying Worker or re-implement.`;
2125
2494
  }
2126
2495
  }
2496
+ else if (ownership.consultation === "dog-advisor" || ownership.consultation === "dog-scout") {
2497
+ const child = taskChildSessionID(output);
2498
+ const observed = child ? await observedSessionModel(child) : { outcome: "unknown" };
2499
+ await missions.settleConsultation(ownership.root, request.callID, observed.outcome, {
2500
+ ...(child === undefined ? {} : { childSessionID: child }),
2501
+ ...(observed.model === undefined ? {} : { observedModel: observed.model }),
2502
+ ...(observed.variant === undefined ? {} : { observedVariant: observed.variant }),
2503
+ ...(typeof raw === "string" ? { result: raw } : {}),
2504
+ });
2505
+ }
2127
2506
  taskOwners.delete(request.callID);
2128
2507
  return;
2129
2508
  }
@@ -5,6 +5,9 @@ export declare function isRuntimeControlPath(path: string): boolean;
5
5
  /** An existing operation may create its declared outputs. Compare its other inputs during execution;
6
6
  * ordinary evidence still pins the full post-operation source and outputs for later acceptance. */
7
7
  export declare function operationInputSnapshot(projectRoot: string, binding: Binding): Promise<string | undefined>;
8
+ /** A validation may populate write-only caches. Keep its declared read inputs stable
9
+ * while binding the resulting candidate (including those outputs) after the command. */
10
+ export declare function validationInputSnapshot(projectRoot: string, binding: Binding): Promise<string | undefined>;
8
11
  export declare function protectedSnapshot(authorization: {
9
12
  manifestPath: string;
10
13
  manifestHash: string;