sortie-dogs 0.12.18 → 0.12.20

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.18-review-references-retry-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.12.20-mission-recovery-rescue-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.18-review-references-retry-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.12.20-mission-recovery-rescue-v1";
@@ -3,6 +3,6 @@ export declare const CONTRACT_TEXT_LIMITS: Readonly<{
3
3
  title: 160;
4
4
  objective: 2000;
5
5
  statement: 1000;
6
- command: 1000;
6
+ command: 8192;
7
7
  path: 512;
8
8
  }>;
@@ -1,2 +1,2 @@
1
1
  /** Common text bounds shared by handoff, manifest and goal evidence validation. */
2
- export const CONTRACT_TEXT_LIMITS = Object.freeze({ title: 160, objective: 2000, statement: 1000, command: 1000, path: 512 });
2
+ export const CONTRACT_TEXT_LIMITS = Object.freeze({ title: 160, objective: 2000, statement: 1000, command: 8192, path: 512 });
@@ -20,6 +20,55 @@ export interface MissionReviewScope {
20
20
  read: string[];
21
21
  write: string[];
22
22
  }
23
+ export interface MissionConsultation {
24
+ id: string;
25
+ role: "advisor" | "scout";
26
+ disposition: "skipped" | "dispatched";
27
+ reason: string;
28
+ at: string;
29
+ trigger?: string;
30
+ question?: string;
31
+ callID?: string;
32
+ childSessionID?: string;
33
+ observedModel?: string;
34
+ observedVariant?: string;
35
+ outcome?: "completed" | "failed" | "unknown";
36
+ result?: string;
37
+ }
38
+ export interface MissionAttempt {
39
+ attemptID: string;
40
+ runID: string;
41
+ unitID: string;
42
+ taskID: string;
43
+ predecessorAttemptID?: string | null;
44
+ /** Fingerprint of the settled scoped candidate against which a later Rescue is proposed. */
45
+ candidateID?: string;
46
+ kind: "implementation" | "normal_remediation" | "astra_rescue";
47
+ status: "pending" | "dispatched" | "succeeded" | "failed" | "cancelled" | "unconfirmed";
48
+ callID?: string;
49
+ childSessionID?: string;
50
+ nativeOutcome?: "completed" | "failed" | "unknown";
51
+ observedModel?: string;
52
+ observedVariant?: string;
53
+ failure?: {
54
+ category: "infrastructure" | "authorization" | "contract" | "cancellation" | "implementation" | "unknown";
55
+ code: string;
56
+ };
57
+ resultClass?: string;
58
+ selectedModel?: string;
59
+ }
60
+ export interface MissionTerminalRescue {
61
+ attemptID: string;
62
+ runID: string;
63
+ unitID: string;
64
+ selectedModel?: string;
65
+ selectedVariant?: string | null;
66
+ status: "prepared" | "dispatched" | "succeeded" | "failed" | "cancelled" | "unconfirmed" | "non_rescue";
67
+ reason?: string;
68
+ observedModel?: string;
69
+ observedVariant?: string;
70
+ outcome?: "succeeded" | "failed" | "cancelled" | "unknown";
71
+ }
23
72
  export interface MissionExecution {
24
73
  commands: string[];
25
74
  directory: string;
@@ -61,6 +110,12 @@ export interface OperatorMission {
61
110
  reviewBaseline?: string;
62
111
  /** Cumulative declared review inputs/outputs, including units that failed after writing source. */
63
112
  reviewScope?: MissionReviewScope;
113
+ /** Optional Advisor/Scout decisions and native consultation outcomes; never an admission gate. */
114
+ consultations?: MissionConsultation[];
115
+ /** Host-observed current-Mission Worker attempts; only an implementation failure followed by a failed remediation can qualify for Rescue. */
116
+ attempts?: MissionAttempt[];
117
+ /** One bounded Astra Rescue per eligible unit; validation, independent review and root acceptance remain separate. */
118
+ rescue?: MissionTerminalRescue;
64
119
  /** A cancelled run replaced by a later real user turn; never reuse its acceptance or evidence. */
65
120
  supersededRunID?: string;
66
121
  plans: number;
@@ -95,6 +150,7 @@ export interface OperatorMission {
95
150
  * Worker unit, so after this many evidence-only reviews the candidate may be submitted with the gaps listed.
96
151
  */
97
152
  export declare const MISSION_EVIDENCE_GAP_REVIEW_LIMIT = 2;
153
+ export declare const MISSION_CONSULTATION_LIMIT = 32;
98
154
  /** Review coverage survives a narrower replan; it is not a Worker write grant. */
99
155
  export declare function missionReviewScope(previous: MissionReviewScope | undefined, ...runs: OperatorState[]): MissionReviewScope;
100
156
  /** Classify an independent Reviewer's first line. Anything else is a finding. */
@@ -130,8 +186,23 @@ export declare class OperatorMissionRuntime {
130
186
  start(root: string, requirements: unknown, replaceRequirements?: boolean, options?: {
131
187
  kind?: OperatorMission["kind"];
132
188
  context?: MissionContext[];
189
+ cancelledRunID?: string;
133
190
  }): Promise<OperatorMission>;
134
191
  update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
192
+ recordConsultationSkip(root: string, role: MissionConsultation["role"], reason: string): Promise<OperatorMission>;
193
+ recordConsultationDispatch(root: string, input: {
194
+ role: MissionConsultation["role"];
195
+ callID: string;
196
+ reason: string;
197
+ trigger?: string;
198
+ question?: string;
199
+ }): Promise<OperatorMission>;
200
+ settleConsultation(root: string, callID: string, outcome: MissionConsultation["outcome"], details?: {
201
+ childSessionID?: string;
202
+ observedModel?: string;
203
+ observedVariant?: string;
204
+ result?: string;
205
+ }): Promise<OperatorMission>;
135
206
  /** Keep host-accepted criteria ahead of new text, including an exactly linked settled predecessor. */
136
207
  carryForward(root: string, missionID: string, acceptance: readonly string[], supersededRunID?: string): Promise<OperatorMission>;
137
208
  task(state: OperatorMission): OperatorTask;
@@ -23,6 +23,7 @@ export function missionValidationCommand(command) {
23
23
  * Worker unit, so after this many evidence-only reviews the candidate may be submitted with the gaps listed.
24
24
  */
25
25
  export const MISSION_EVIDENCE_GAP_REVIEW_LIMIT = 2;
26
+ export const MISSION_CONSULTATION_LIMIT = 32;
26
27
  /** Review coverage survives a narrower replan; it is not a Worker write grant. */
27
28
  export function missionReviewScope(previous, ...runs) {
28
29
  return {
@@ -240,16 +241,19 @@ export class OperatorMissionRuntime {
240
241
  }
241
242
  if (previous)
242
243
  await this.save(this.file(root, `.${previous.id}`), previous);
244
+ // A cancelled standalone/legacy run has no mission-owned runID. Only an explicit
245
+ // replacement may link it; normal continuation must not silently inherit its goal.
246
+ const predecessor = previous?.runID ?? previous?.supersededRunID ??
247
+ (replaceRequirements ? options.cancelledRunID : undefined);
243
248
  const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
244
249
  kind: options.kind ?? "implementation",
245
250
  context: (options.context ?? []).filter(item => item.id !== request.id),
246
251
  requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
247
252
  ...(replaceRequirements ? { requirementsReplaced: true } : {}),
248
253
  coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
249
- ...(previous?.phase === "cancelled" &&
250
- (previous.runID === null || request.id !== previous.requests[0]?.id) &&
251
- (previous.runID ?? previous.supersededRunID)
252
- ? { supersededRunID: (previous.runID ?? previous.supersededRunID) } : {}) };
254
+ ...((previous === undefined || previous.phase === "cancelled") &&
255
+ (previous?.runID === null || previous === undefined || request.id !== previous.requests[0]?.id) && predecessor
256
+ ? { supersededRunID: predecessor } : {}) };
253
257
  await this.save(this.file(root), state);
254
258
  return state;
255
259
  });
@@ -264,6 +268,41 @@ export class OperatorMissionRuntime {
264
268
  return state;
265
269
  });
266
270
  }
271
+ recordConsultationSkip(root, role, reason) {
272
+ if ((role !== "advisor" && role !== "scout") || typeof reason !== "string" || !reason.trim() ||
273
+ reason.length > 1000 || /[\r\n]/u.test(reason))
274
+ throw new Error("mission-consultation-input-invalid");
275
+ return this.update(root, state => {
276
+ state.consultations = [...(state.consultations ?? []), { id: randomUUID(), role, disposition: "skipped",
277
+ reason: reason.trim(), at: new Date().toISOString() }].slice(-MISSION_CONSULTATION_LIMIT);
278
+ });
279
+ }
280
+ recordConsultationDispatch(root, input) {
281
+ if ((input.role !== "advisor" && input.role !== "scout") || !input.callID || !input.reason.trim() ||
282
+ input.reason.length > 1000 || /[\r\n]/u.test(input.reason) ||
283
+ (input.trigger !== undefined && (input.trigger.length > 128 || /[\r\n]/u.test(input.trigger))) ||
284
+ (input.question !== undefined && (input.question.length > 1000 || /[\r\n]/u.test(input.question)))) {
285
+ throw new Error("mission-consultation-input-invalid");
286
+ }
287
+ return this.update(root, state => {
288
+ state.consultations = [...(state.consultations ?? []), { id: randomUUID(), role: input.role,
289
+ disposition: "dispatched", reason: input.reason.trim(), at: new Date().toISOString(), callID: input.callID,
290
+ ...(input.trigger === undefined ? {} : { trigger: input.trigger }),
291
+ ...(input.question === undefined ? {} : { question: input.question }) }].slice(-MISSION_CONSULTATION_LIMIT);
292
+ });
293
+ }
294
+ settleConsultation(root, callID, outcome, details = {}) {
295
+ if (!callID || !["completed", "failed", "unknown"].includes(String(outcome)))
296
+ throw new Error("mission-consultation-input-invalid");
297
+ return this.update(root, state => {
298
+ const item = [...(state.consultations ?? [])].reverse().find(entry => entry.callID === callID);
299
+ if (!item)
300
+ return;
301
+ if (details.result !== undefined && details.result.length > 4000)
302
+ details.result = details.result.slice(0, 4000);
303
+ Object.assign(item, { outcome, ...details });
304
+ });
305
+ }
267
306
  /** Keep host-accepted criteria ahead of new text, including an exactly linked settled predecessor. */
268
307
  carryForward(root, missionID, acceptance, supersededRunID) {
269
308
  return this.update(root, state => {
@@ -356,17 +395,20 @@ export function missionPlan(mission, raw) {
356
395
  throw new Error(`mission-unit-${index + 1}: ${field} must be paths`);
357
396
  return [...new Set(entries.map(item => normalizeExecutionScope(item)))];
358
397
  };
359
- // Scheduling coverage is not semantic proof. Only a single requirement is unambiguous.
360
- const ids = value.requirement_ids ?? (mission.requirements.length === 1 ? [mission.requirements[0].id] : []);
398
+ // A sole unit owns the whole request. This schedules work; it does not prove acceptance.
399
+ const ids = value.requirement_ids ?? (raw.length === 1 || mission.requirements.length === 1 ? mission.requirements.map(item => item.id) : []);
361
400
  if (!Array.isArray(ids) || ids.length === 0 || !ids.every(id => mission.requirements.some(item => item.id === id))) {
362
- throw new Error(`mission-unit-${index + 1}: requirement_ids must name the related R IDs; multi-requirement work needs explicit coverage`);
401
+ throw new Error(`mission-unit-${index + 1}: requirement_ids must name the related R IDs; splitting multiple requirements across units needs explicit coverage`);
363
402
  }
364
403
  if (!Array.isArray(value.validation) || value.validation.length === 0 ||
365
404
  !value.validation.every(command => typeof command === "string" && command.trim())) {
366
405
  throw new Error(`mission-unit-${index + 1}: validation must contain exact commands; final command proves the unit`);
367
406
  }
407
+ const validation = value.validation.map(missionValidationCommand);
408
+ // Retain last occurrences so removing a redundant check preserves the final proof command.
368
409
  return { id: `unit-${index + 1}`, title: line("title"), objective: line("objective"), read: paths("read"), write: paths("write"),
369
- validation: value.validation.map(missionValidationCommand), acceptance_indices: [...new Set(ids.map(id => mission.requirements.findIndex(item => item.id === id)))] };
410
+ validation: validation.filter((command, i) => validation.lastIndexOf(command) === i),
411
+ acceptance_indices: [...new Set(ids.map(id => mission.requirements.findIndex(item => item.id === id)))] };
370
412
  });
371
413
  // The serial engine counts new proof milestones. Units sharing the same final suite are one
372
414
  // milestone, so coalesce them instead of making the model repair a bookkeeping rejection.
@@ -412,7 +454,8 @@ export function missionPacket(mission, run) {
412
454
  completed_units: predecessor.units.filter(unit => unit.status === "succeeded").length,
413
455
  note: "Historical results and spend are retained; they do not complete the current requirements." } } : {}),
414
456
  requirements: mission.requirements, original_request_refs: mission.requests.map(item => `user:${item.id}`),
415
- submission: mission.submission, progress: mission.progress,
457
+ submission: mission.submission, progress: mission.progress, consultations: mission.consultations ?? [],
458
+ attempts: mission.attempts ?? [], ...(mission.rescue ? { rescue: mission.rescue } : {}),
416
459
  operation: { kind: mission.kind ?? "implementation", status: missionExecutionStatus(mission),
417
460
  ...(mission.execution ? { ...mission.execution } : {}) },
418
461
  execution_summary: { completed_units: run?.units.filter(unit => unit.status === "succeeded").length ?? 0,
@@ -432,13 +475,19 @@ export function missionPacket(mission, run) {
432
475
  child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
433
476
  handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
434
477
  scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
478
+ normal_remediation_used: unit.normalRemediationUsed ?? false,
479
+ ...(unit.failure ? { failure: { outcome: unit.failure.outcome, exit_code: unit.failure.exitCode } } : {}),
435
480
  ...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
436
481
  next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
437
482
  : mission.phase === "submitted" && mission.submission?.status === "ready"
438
483
  ? "Operator: compare the submitted candidate with the original requirements and actual evidence, then complete_mission if satisfied. Report remaining evidence gaps; they are not a review PASS."
439
484
  : run?.phase === "awaiting-decision" ? (run.units.some(unit => unit.dispatchDenial)
440
485
  ? "Coordinator: inspect units[].dispatch_denial before changing the plan. Correct only its diagnosed cause; do not repeat an unchanged refused Task or replan for a host-state mismatch. Report an unresolved runtime mismatch with the loaded runtime identity; preserve requirements and cumulative spend."
441
- : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
486
+ : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && !unit.normalRemediationUsed)
487
+ ? "Coordinator: one exact declared validation failed. Reconcile the returned Worker and cumulative budget, then call retry_mission_unit once for that unit before replanning. This preserves the same scope and acceptance."
488
+ : run.units.some(unit => unit.status === "failed" && unit.resultClass === "acceptance" && unit.failure?.outcome === "fail" && unit.normalRemediationUsed && !unit.terminalRescue)
489
+ ? "Coordinator: the one ordinary remediation failed the declared validation again. If its native terminal, writer release, exact candidate and cumulative budget permit, call rescue_mission_unit once; inspect and report a non_rescue reason, then continue ordinary correction within budget. Rescue does not bypass validation, review or root acceptance."
490
+ : "Coordinator: correct the cause and call plan_units with the remaining work and all requirements; budget is cumulative.")
442
491
  : run?.phase === "awaiting-acceptance" && !operationComplete
443
492
  ? `Requested operation is ${operationStatus}. Continue the actual operation or report its blocker; auxiliary checks and review disposition cannot complete it.`
444
493
  : run?.phase === "awaiting-acceptance" ? (currentReview && mission.review?.verdict === "evidence-gaps" && !reviewAccepted
@@ -462,7 +511,8 @@ export function missionReviewTraces(mission, raw) {
462
511
  throw new Error("mission-review-traces: supply concise implementation/test traces");
463
512
  }
464
513
  const grouped = raw.map(text => ({ text,
465
- ids: /^\s*((?:R\d+\s*[/,]?\s*)+):/u.exec(text)?.[1]?.match(/R\d+/gu) ?? [] }));
514
+ ids: [...text.matchAll(/(?:^|[.;]\s+|[\r\n])\s*((?:R\d+\s*[/,]?\s*)+):/gu)]
515
+ .flatMap(match => match[1].match(/R\d+/gu) ?? []) }));
466
516
  if (grouped.every(item => item.ids.length === 0) && raw.length === mission.requirements.length)
467
517
  return raw;
468
518
  const unknown = grouped.flatMap(item => item.ids).filter(id => !mission.requirements.some(requirement => requirement.id === id));
@@ -106,15 +106,32 @@ export type OperatorProposal = {
106
106
  type OperatorPhase = "prepared" | "running" | "awaiting-decision" | "awaiting-acceptance" | "completed" | "cancelled";
107
107
  interface UnitState {
108
108
  readonly unit: OperatorUnit;
109
- readonly task: OperatorTask;
109
+ task: OperatorTask;
110
110
  readonly handoffPath: string;
111
111
  readonly manifestPath: string;
112
- hashes: readonly string[];
112
+ hashes: string[];
113
113
  status: "pending" | "running" | "succeeded" | "failed" | "cancelled";
114
114
  callID: string | null;
115
115
  childSessionID: string | null;
116
116
  evidence: readonly GoalEvidence[];
117
117
  resultClass: string | null;
118
+ failure?: SerialDispatchSettlement["failure"];
119
+ normalRemediationUsed?: boolean;
120
+ terminalRescue?: {
121
+ readonly attempt_id: string;
122
+ readonly selected_model: string;
123
+ readonly selected_variant: string | null;
124
+ readonly accepted_base: {
125
+ readonly candidate_id: string;
126
+ readonly contract_id: string;
127
+ readonly scope: readonly string[];
128
+ readonly acceptance: readonly string[];
129
+ readonly validation: readonly string[];
130
+ };
131
+ readonly prior_failure: SerialDispatchSettlement["failure"];
132
+ status: "prepared" | "dispatched" | "settled";
133
+ disposition?: "succeeded" | "failed" | "cancelled";
134
+ };
118
135
  dispatchDenial?: string;
119
136
  repairValidationAttempts: number;
120
137
  repairValidation: {
@@ -319,6 +336,21 @@ export declare class OperatorRuntime {
319
336
  private operatorRejectedOnce;
320
337
  settled(result: SerialDispatchSettlement): Promise<void>;
321
338
  private settledOnce;
339
+ /** One ordinary same-scope Mission retry before any optional model Rescue is considered. */
340
+ prepareMissionNormalRemediation(root: string, runID: string, unitID: string): Promise<OperatorState>;
341
+ /** Requeue a host-classified normal-remediation failure through the ordinary Mission Worker gates. */
342
+ prepareMissionTerminalRescue(root: string, runID: string, unitID: string, input: {
343
+ attempt_id: string;
344
+ selected_model: string;
345
+ selected_variant: string | null;
346
+ accepted_base: {
347
+ candidate_id: string;
348
+ contract_id: string;
349
+ scope: readonly string[];
350
+ acceptance: readonly string[];
351
+ validation: readonly string[];
352
+ };
353
+ }): Promise<OperatorState>;
322
354
  observeChild(root: string, callID: string, childID: string): Promise<void>;
323
355
  private observeChildOnce;
324
356
  operatorReturned(root: string): Promise<OperatorState>;
@@ -1237,6 +1237,8 @@ export class OperatorRuntime {
1237
1237
  state.dispatched++;
1238
1238
  unit.status = "running";
1239
1239
  unit.callID = callID;
1240
+ if (unit.terminalRescue)
1241
+ unit.terminalRescue.status = "dispatched";
1240
1242
  state.phase = "running";
1241
1243
  await this.save(state);
1242
1244
  return admitted;
@@ -1528,7 +1530,13 @@ export class OperatorRuntime {
1528
1530
  unit.resultClass = result.resultClass;
1529
1531
  if (result.failure !== undefined)
1530
1532
  unit.failure = result.failure;
1533
+ else
1534
+ delete unit.failure;
1531
1535
  unit.status = result.disposition;
1536
+ if (unit.terminalRescue) {
1537
+ unit.terminalRescue.status = "settled";
1538
+ unit.terminalRescue.disposition = result.disposition;
1539
+ }
1532
1540
  if (result.disposition !== "succeeded") {
1533
1541
  state.phase = "awaiting-decision";
1534
1542
  state.decision = result.resultClass === "acceptance" ? ACCEPTANCE_REMEDIATION_DECISION : result.resultClass;
@@ -1540,6 +1548,76 @@ export class OperatorRuntime {
1540
1548
  }
1541
1549
  await this.save(state);
1542
1550
  }
1551
+ /** One ordinary same-scope Mission retry before any optional model Rescue is considered. */
1552
+ prepareMissionNormalRemediation(root, runID, unitID) {
1553
+ return this.serial(root, async () => {
1554
+ const state = await this.required(root);
1555
+ const unit = state.units.find(candidate => candidate.unit.id === unitID);
1556
+ if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(candidate => candidate.status === "running") ||
1557
+ !unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || !unit.failure ||
1558
+ unit.failure.outcome !== "fail" || unit.normalRemediationUsed || unit.terminalRescue ||
1559
+ state.units.some(candidate => candidate.terminalRescue))
1560
+ throw new Error("operator-mission-normal-remediation-unavailable");
1561
+ await this.verifyControls(unit);
1562
+ unit.normalRemediationUsed = true;
1563
+ unit.status = "pending";
1564
+ unit.callID = null;
1565
+ unit.childSessionID = null;
1566
+ unit.evidence = [];
1567
+ unit.resultClass = null;
1568
+ delete unit.failure;
1569
+ unit.repairValidation = null;
1570
+ state.phase = "prepared";
1571
+ state.decision = null;
1572
+ await this.save(state);
1573
+ return structuredClone(state);
1574
+ });
1575
+ }
1576
+ /** Requeue a host-classified normal-remediation failure through the ordinary Mission Worker gates. */
1577
+ prepareMissionTerminalRescue(root, runID, unitID, input) {
1578
+ return this.serial(root, async () => {
1579
+ const state = await this.required(root);
1580
+ const unit = state.units.find(candidate => candidate.unit.id === unitID);
1581
+ const base = input.accepted_base;
1582
+ if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(candidate => candidate.status === "running") ||
1583
+ !unit || unit.status !== "failed" || unit.resultClass !== "acceptance" || !unit.failure ||
1584
+ unit.failure.outcome !== "fail" || unit.normalRemediationUsed !== true || unit.terminalRescue ||
1585
+ state.units.some(candidate => candidate.terminalRescue) ||
1586
+ !identifier(input.attempt_id) || !text(input.selected_model) ||
1587
+ (input.selected_variant !== null && !text(input.selected_variant))) {
1588
+ throw new Error("operator-mission-terminal-rescue-unavailable");
1589
+ }
1590
+ if (!/^sha256:[0-9a-f]{64}$/u.test(base.candidate_id) ||
1591
+ !text(base.contract_id) || base.contract_id !== state.acceptanceFingerprint ||
1592
+ !strings(base.scope, true) || JSON.stringify(base.scope) !== JSON.stringify(unit.unit.write) ||
1593
+ !strings(base.acceptance, true) || base.acceptance.length !== state.acceptance.length ||
1594
+ base.acceptance.some((criterion, index) => criterion !== state.acceptance[index]) ||
1595
+ !strings(base.validation, true) || JSON.stringify(base.validation) !== JSON.stringify(unit.unit.validation)) {
1596
+ throw new Error("operator-mission-terminal-rescue-contract-mismatch");
1597
+ }
1598
+ const header = /^task_id: [^\r\n]+$/mu;
1599
+ if (!header.test(unit.task.prompt) || /^terminal_rescue_attempt:/mu.test(unit.task.prompt)) {
1600
+ throw new Error("operator-mission-terminal-rescue-task-invalid");
1601
+ }
1602
+ await this.verifyControls(unit);
1603
+ // The Rescue route is sealed in host-owned operator state and the returned Task reference.
1604
+ // Do not alter the registered handoff or expose the stable runtime's rescue capability.
1605
+ unit.task = { ...unit.task, prompt: unit.task.prompt.replace(header, line => `${line}\nterminal_rescue_attempt: ${input.attempt_id}`) };
1606
+ unit.terminalRescue = { attempt_id: input.attempt_id, selected_model: input.selected_model,
1607
+ selected_variant: input.selected_variant, accepted_base: structuredClone(base), prior_failure: structuredClone(unit.failure), status: "prepared" };
1608
+ unit.status = "pending";
1609
+ unit.callID = null;
1610
+ unit.childSessionID = null;
1611
+ unit.evidence = [];
1612
+ unit.resultClass = null;
1613
+ delete unit.failure;
1614
+ unit.repairValidation = null;
1615
+ state.phase = "prepared";
1616
+ state.decision = null;
1617
+ await this.save(state);
1618
+ return structuredClone(state);
1619
+ });
1620
+ }
1543
1621
  observeChild(root, callID, childID) {
1544
1622
  return this.serial(root, () => this.observeChildOnce(root, callID, childID));
1545
1623
  }
@@ -1910,6 +1988,10 @@ export class OperatorRuntime {
1910
1988
  handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
1911
1989
  scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
1912
1990
  acceptance_indices: unit.unit.acceptance_indices,
1991
+ normal_remediation_used: unit.normalRemediationUsed ?? false,
1992
+ ...(unit.terminalRescue ? { terminal_rescue: { attempt_id: unit.terminalRescue.attempt_id,
1993
+ selected_model: unit.terminalRescue.selected_model, selected_variant: unit.terminalRescue.selected_variant,
1994
+ status: unit.terminalRescue.status, ...(unit.terminalRescue.disposition ? { disposition: unit.terminalRescue.disposition } : {}) } } : {}),
1913
1995
  evidence: unit.evidence.map(item => ({ id: item.evidence_id, criteria: item.measurement.criterion_ids,
1914
1996
  candidate: item.identity.candidate, command: item.execution.command, outcome: item.execution.outcome, exit_code: item.execution.exit_code })),
1915
1997
  repair_validation: unit.repairValidation === null ? null : { repair_fingerprint: unit.repairValidation.repair_fingerprint,
@@ -6259,14 +6259,24 @@ export const SortieDogsPlugin = async (input, options) => {
6259
6259
  let terminalRescueTarget;
6260
6260
  if (selectedAgent === SERIAL_WORKER_AGENT) {
6261
6261
  const text = explicitTaskText(output);
6262
- const rescueRequested = text !== undefined && handoffValue(handoffEntries(text), ["terminal_rescue_attempt"]) !== undefined;
6262
+ const rescueAttemptID = text === undefined ? undefined : handoffValue(handoffEntries(text), ["terminal_rescue_attempt"]);
6263
+ const rescueRequested = rescueAttemptID !== undefined;
6264
+ const missionSelection = chatInput.missionRescueSelection;
6265
+ const missionRescueTarget = runtimeProfile.id === "v010" && rescueRequested && isRecord(missionSelection) &&
6266
+ missionSelection.attempt_id === rescueAttemptID && typeof missionSelection.model === "string" &&
6267
+ missionSelection.model.includes("/") &&
6268
+ (missionSelection.variant === null || typeof missionSelection.variant === "string")
6269
+ ? { model: missionSelection.model,
6270
+ ...(missionSelection.variant === null ? {} : { variant: missionSelection.variant }) }
6271
+ : undefined;
6263
6272
  if (text !== undefined && !rescueRequested)
6264
6273
  terminalRescueHandoffs.delete(chatInput.sessionID);
6265
6274
  const suppliedPath = !rescueRequested ? undefined : handoffValue(handoffEntries(text), ["handoff_path"]);
6266
6275
  const path = suppliedPath === undefined ? terminalRescueHandoffs.get(chatInput.sessionID) : unquoteValue(suppliedPath);
6267
6276
  if (path !== undefined) {
6268
- const identity = await inspect(path, undefined, { report: true, rescueSessionID: chatInput.sessionID });
6269
- terminalRescueTarget = identity?.terminalRescueTarget;
6277
+ const identity = await inspect(path, undefined, { report: true,
6278
+ ...(missionRescueTarget === undefined ? { rescueSessionID: chatInput.sessionID } : {}) });
6279
+ terminalRescueTarget = missionRescueTarget ?? identity?.terminalRescueTarget;
6270
6280
  if (rescueRequested && terminalRescueTarget === undefined)
6271
6281
  throw new Error("rescue-binding-unavailable");
6272
6282
  if (terminalRescueTarget !== undefined) {
@@ -6586,10 +6596,11 @@ export const SortieDogsPlugin = async (input, options) => {
6586
6596
  diagnosis.accepting = false;
6587
6597
  diagnosisCalls.delete(toolInput.callID ?? "");
6588
6598
  }
6589
- const returnedMissionWorker = completedChildSessionID !== undefined &&
6590
- await input.runtimeBridge?.allowsInvestigativeShell?.(completedChildSessionID);
6591
6599
  parallelCalls.delete(toolInput.callID ?? "");
6592
6600
  activeSessions.get(toolInput.sessionID ?? "")?.inFlightCalls.delete(toolInput.callID ?? "");
6601
+ // Snapshot Mission ownership before settling a failed validation changes its durable phase to awaiting-decision.
6602
+ const returnedMissionWorker = completedChildSessionID !== undefined &&
6603
+ await input.runtimeBridge?.allowsInvestigativeShell?.(completedChildSessionID);
6593
6604
  if (coordinatorTaskFinished && diagnosis === undefined) {
6594
6605
  if (toolInput.callID !== undefined) {
6595
6606
  await settleGoalDispatch(toolInput.callID, output).catch((error) => {
@@ -6603,16 +6614,16 @@ export const SortieDogsPlugin = async (input, options) => {
6603
6614
  if (repair?.callID === toolInput.callID)
6604
6615
  operatorContractRepairResumes.delete(toolInput.sessionID);
6605
6616
  }
6617
+ if (completedChildSessionID !== undefined && activeSessions.get(completedChildSessionID)?.parallel !== "valid" &&
6618
+ returnedMissionWorker) {
6619
+ // Mission failures also leave the run awaiting-decision before this return hook completes. Release the
6620
+ // serial writer before settlement/correction can observe terminal ownership, then evict as usual.
6621
+ await releaseWriteGate(completedChildSessionID);
6622
+ }
6606
6623
  if (completedChildSessionID !== undefined && !recoverableWorkerChildren.has(completedChildSessionID)) {
6607
6624
  evictSession(completedChildSessionID);
6608
6625
  void childLifecycles.get(completedChildSessionID)?.check();
6609
6626
  }
6610
- else if (completedChildSessionID !== undefined && activeSessions.get(completedChildSessionID)?.parallel !== "valid" &&
6611
- returnedMissionWorker) {
6612
- // Keep same-worker recovery identity, but a returned serial Task must not keep a live
6613
- // write reservation. Rebinding the retained manifest reacquires it before any later write.
6614
- await releaseWriteGate(completedChildSessionID);
6615
- }
6616
6627
  }
6617
6628
  },
6618
6629
  "tool.execute.before": async (toolInput, output) => {
@@ -7850,12 +7861,32 @@ export const SortieDogsPlugin = async (input, options) => {
7850
7861
  if (state.goal_id === null || state.budget === null)
7851
7862
  return null;
7852
7863
  const reserved = state.outstanding_reservations.length;
7853
- return { max_units: state.budget.max_units, consumed_units: state.consumed_units,
7864
+ const snapshot = { max_units: state.budget.max_units, consumed_units: state.consumed_units,
7854
7865
  reserved_units: reserved, remaining_units: Math.max(0, state.budget.max_units - state.consumed_units - reserved),
7866
+ settled_time_ms: state.consumed_time_ms, time_limit_ms: state.budget.time_ms,
7855
7867
  settled_cost_usd: state.consumed_cost_usd, cost_limit_usd: state.budget.cost_usd,
7856
7868
  cost_status: state.consumed_cost_usd === null ? "unknown-usage" : reserved > 0 ? "in-flight-not-final" : "settled",
7857
7869
  cost_source: "native-usage-price-table",
7858
7870
  cost_note: "Worker token-price estimates from complete native usage; missing requests remain unknown and are reconciled on later status. Excludes orchestration/review and external campaign spend. Zero settled cost does not mean free execution." };
7871
+ return snapshot;
7872
+ },
7873
+ ...{
7874
+ missionWorkerTerminal: async (childSessionID, writeScopes) => {
7875
+ const active = activeSessions.get(childSessionID), authorization = sessionAuthorizations.get(childSessionID);
7876
+ if ((active?.inFlightCalls.size ?? 0) !== 0 || (active !== undefined && active.released !== true) ||
7877
+ (authorization !== undefined && (!authorization.suspended || authorization.lease !== undefined)))
7878
+ return false;
7879
+ const scopeRoot = await durableScopeRoot(project?.root ?? input.directory);
7880
+ if (scopeRoot === undefined)
7881
+ return false;
7882
+ try {
7883
+ const scope = normalizeWorktreeScope({ read: [], write: [...writeScopes] });
7884
+ return !await new ScopeLeaseRegistry(scopeRoot).hasConflictingLease(scope);
7885
+ }
7886
+ catch {
7887
+ return false;
7888
+ }
7889
+ },
7859
7890
  },
7860
7891
  registerGoalDeclaration: async (root, prompt, missionRevision) => {
7861
7892
  if (!isCoordinatorSession(root) && !await recoverCoordinatorRoot(root))
@@ -28,6 +28,10 @@ export function missionProgressReader(directory, root, callID) {
28
28
  return { description: `🐾 ${status}: ${title}`, sortie_progress: {
29
29
  mission_id: mission.id, phase: mission.phase, unit: running ? `${run.runID}/${running.unit.id}` : last?.unit ?? null,
30
30
  title, status, child_session_id: running?.childSessionID ?? null,
31
+ // Stable timestamps, not a fabricated heartbeat. The parent can tell when the
32
+ // run began and when a unit last settled without generating another model turn.
33
+ run_created_at: run?.runID === mission.runID ? run.createdAt : null,
34
+ last_transition_at: last?.at ?? null,
31
35
  completed_units: units.filter(unit => unit.status === "succeeded").length,
32
36
  failed_units: units.filter(unit => unit.status === "failed").length,
33
37
  historical_failed_attempts: mission.progress.filter(unit => unit.status === "failed").length,