sortie-dogs 0.13.17 → 0.13.18

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.
package/README.md CHANGED
@@ -19,8 +19,8 @@ evidence-backed result, not more agents for their own sake.
19
19
  [![Node.js](https://img.shields.io/node/v/sortie-dogs)](https://www.npmjs.com/package/sortie-dogs)
20
20
  [![MIT License](https://img.shields.io/npm/l/sortie-dogs)](LICENSE)
21
21
 
22
- **Current release: [v0.13.17](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.17)**
23
- · [Release notes](docs/release-v0.13.17.md)
22
+ **Current release: [v0.13.18](https://github.com/zufall-upon/Sortie-dogs/releases/tag/v0.13.18)**
23
+ · [Release notes](docs/release-v0.13.18.md)
24
24
 
25
25
  [Quick start](#quick-start) · [Workflow](#mission-workflow) · [Models](#default-models) ·
26
26
  [Measured results](#measured-results) · [Configuration](#configuration) · [Documentation](#documentation)
@@ -57,7 +57,7 @@ Use an existing Codex CLI signed in with **ChatGPT**. On Windows, PowerShell **7
57
57
  available; POSIX uses bash. Sortie does not install Codex, start login or use a metered API fallback.
58
58
 
59
59
  ```sh
60
- npm install --save-dev sortie-dogs@0.13.17
60
+ npm install --save-dev sortie-dogs@0.13.18
61
61
  npx --no-install sortie-dogs codex init .
62
62
  ```
63
63
 
@@ -86,7 +86,7 @@ bridge. [Codex guide: SDK, permissions, progress, resume and manifest route](doc
86
86
  Use **OpenCode V2**. In the target project:
87
87
 
88
88
  ```sh
89
- npm install --save-dev sortie-dogs@0.13.17
89
+ npm install --save-dev sortie-dogs@0.13.18
90
90
  npx --no-install sortie-dogs init .
91
91
  ```
92
92
 
@@ -188,7 +188,7 @@ known expense. Official local evaluation and leaderboard acceptance are separate
188
188
  **8/23 (34.8%)**, versus v0.12.25's 7/23; all seven prior resolutions retained, no empty patches or
189
189
  official evaluation errors. [Fixed conditions, stopped-run accounting and costs](docs/benchmarks/swebench-lite-history.md#v0131-dev23-2026-09-30).
190
190
 
191
- All scores belong to their fixed candidates, **not v0.13.17**. SWE-bench remains an optional
191
+ All scores belong to their fixed candidates, **not v0.13.18**. SWE-bench remains an optional
192
192
  measurement, not a release gate. Different budgets, models and conditions are not controlled comparisons.
193
193
 
194
194
  ## Configuration
@@ -211,7 +211,7 @@ Install the desired package version and rerun **your host's** initializer:
211
211
 
212
212
  An OpenCode config-local bridge may resolve a separate package; npm-global update alone does not
213
213
  update it. [Global/update instructions](docs/configuration.md#global-availability).
214
- The current Mission asset marker is `0.13.17-handoff-continuity-v1`; the unchanged Codex skill marker is
214
+ The current Mission asset marker is `0.13.18-direct-settlement-v1`; the unchanged Codex skill marker is
215
215
  `0.13.11-codex-skill-v2`. Installed markers alone do not prove a running host reloaded the new version.
216
216
 
217
217
  There is no supported uninstall command. Remove the dependency and only known Sortie-owned paths;
@@ -227,7 +227,7 @@ follow the [manual removal guide](docs/uninstall.md), never delete the entire `.
227
227
  [SWE-bench history](docs/benchmarks/swebench-lite-history.md) · [Historical local case study](docs/benchmark-reference.md).
228
228
  - **Development:** [Testing](docs/testing.md) · [CLI testing](docs/cli-testing.md) ·
229
229
  [Windows tests](docs/windows-tests.md) · [Release routine](docs/release-batch.md).
230
- - **Releases:** [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
230
+ - **Releases:** [v0.13.18](docs/release-v0.13.18.md) · [v0.13.17](docs/release-v0.13.17.md) · [v0.13.16](docs/release-v0.13.16.md) · [v0.13.15](docs/release-v0.13.15.md) · [v0.13.14](docs/release-v0.13.14.md) · [v0.13.13](docs/release-v0.13.13.md) · [v0.13.12](docs/release-v0.13.12.md) · [v0.13.11](docs/release-v0.13.11.md) · [v0.13.10](docs/release-v0.13.10.md) ·
231
231
  [v0.13.9](docs/release-v0.13.9.md) · [All GitHub releases](https://github.com/zufall-upon/Sortie-dogs/releases).
232
232
 
233
233
  ## Community
@@ -3,6 +3,6 @@
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.13.17-handoff-continuity-v1";
6
+ export declare const V010_RUNTIME_ASSET_VERSION = "0.13.18-direct-settlement-v1";
7
7
  export declare const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
8
8
  export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION | typeof CODEX_SKILL_ASSET_VERSION;
@@ -3,5 +3,5 @@
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.13.17-handoff-continuity-v1";
6
+ export const V010_RUNTIME_ASSET_VERSION = "0.13.18-direct-settlement-v1";
7
7
  export const CODEX_SKILL_ASSET_VERSION = "0.13.11-codex-skill-v2";
@@ -111,6 +111,15 @@ export interface GoalTerminalReceipt {
111
111
  interface GoalEventBase {
112
112
  readonly at: string;
113
113
  }
114
+ /** Host verification of an existing direct finish receipt, not a new execution. */
115
+ export interface GoalDirectFinishRecovery {
116
+ readonly call_id: string;
117
+ readonly actor_session_id: string;
118
+ readonly finish_call_id: string;
119
+ readonly started_at: string;
120
+ readonly finished_at: string;
121
+ readonly validation_call_ids: readonly string[];
122
+ }
114
123
  export interface GoalValidationRetryAuthorization {
115
124
  readonly authority: "operator-repair-validation-retry";
116
125
  readonly operator_run_id: string;
@@ -218,6 +227,7 @@ export type GoalFlightEvent = (GoalEventBase & {
218
227
  readonly unit_id: string;
219
228
  readonly previous_receipt_id: string;
220
229
  readonly evidence: readonly GoalEvidence[];
230
+ readonly direct_finish?: GoalDirectFinishRecovery;
221
231
  }) | (GoalEventBase & {
222
232
  readonly kind: "validation.admission";
223
233
  readonly goal_id: string;
@@ -133,7 +133,7 @@ export function reduceGoalFlight(records) {
133
133
  let replanRevision = null;
134
134
  let legacyResetRevision = false;
135
135
  let previous = null;
136
- const processDefects = new Map();
136
+ const recoverableSettlements = new Map();
137
137
  const unitCosts = new Map();
138
138
  const validationAdmissions = new Map();
139
139
  const validationSettlements = new Map();
@@ -160,7 +160,7 @@ export function reduceGoalFlight(records) {
160
160
  session_ids: [event.origin_session_id] };
161
161
  replanRevision = null;
162
162
  legacyResetRevision = false;
163
- processDefects.clear();
163
+ recoverableSettlements.clear();
164
164
  validationAdmissions.clear();
165
165
  validationSettlements.clear();
166
166
  reopenedValidationEvidence.clear();
@@ -255,8 +255,8 @@ export function reduceGoalFlight(records) {
255
255
  unit_ids: addUnique(state.unit_ids, event.unit_id),
256
256
  evidence_refs: event.evidence.reduce((refs, evidence) => addUnique(refs, evidence.evidence_id), state.evidence_refs),
257
257
  satisfied_criteria: event.evidence.reduce((ids, evidence) => evidence.measurement.criterion_ids.reduce(addUnique, ids), state.satisfied_criteria) };
258
- if (event.result_class === "process-defect" && event.evidence.length === 0 && event.disposition !== "cancelled") {
259
- processDefects.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch });
258
+ if (event.evidence.length === 0 && event.disposition !== "succeeded") {
259
+ recoverableSettlements.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch, settlement: event });
260
260
  }
261
261
  }
262
262
  else if (event.kind === "unit.usage-reconciled") {
@@ -271,11 +271,24 @@ export function reduceGoalFlight(records) {
271
271
  : costs.reduce((sum, item) => sum + item.cost, 0) };
272
272
  }
273
273
  else if (event.kind === "unit.evidence-reconciled") {
274
- const old = processDefects.get(event.previous_receipt_id);
274
+ const old = recoverableSettlements.get(event.previous_receipt_id);
275
+ const direct = event.direct_finish, settled = old?.settlement;
276
+ const directFinish = direct !== undefined && text(direct.call_id) && direct.call_id.startsWith("direct-") &&
277
+ text(direct.actor_session_id) && text(direct.finish_call_id) && instant(direct.started_at) && instant(direct.finished_at) &&
278
+ Date.parse(direct.finished_at) >= Date.parse(direct.started_at) && Array.isArray(direct.validation_call_ids) && direct.validation_call_ids.length > 0 &&
279
+ direct.validation_call_ids.every(text) && settled?.native_session_id === direct.actor_session_id &&
280
+ settled.native_started_at !== undefined && Math.abs(Date.parse(settled.native_started_at) - Date.parse(direct.started_at)) <= 1000 &&
281
+ Math.abs(Date.parse(settled.at) - Date.parse(direct.finished_at)) <= 1000 &&
282
+ settled.reservation_id === goalFingerprint({ goal_id: event.goal_id, unit_id: event.unit_id, call_id: direct.call_id }) &&
283
+ settled.receipt_id === goalFingerprint({ call_id: direct.call_id,
284
+ output: "Host-observed direct unit finished; controller session remains active." }) &&
285
+ (settled.disposition === "cancelled" && settled.result_class === "interrupted" ||
286
+ settled.disposition === "failed" && ["process-defect", "acceptance"].includes(settled.result_class ?? ""));
287
+ const processDefect = direct === undefined && settled?.disposition !== "cancelled" && settled?.result_class === "process-defect";
275
288
  requireState(state.phase === "active" && state.receipt === null && old?.unitID === event.unit_id &&
276
- old.revision === state.revision && old.epoch === state.scope_epoch && state.outstanding_reservations.length === 0, "transition", "Evidence reconciliation requires a current settled process defect.");
289
+ old.revision === state.revision && old.epoch === state.scope_epoch && state.outstanding_reservations.length === 0 && (processDefect || directFinish), "transition", "Evidence reconciliation requires a current settled process defect or verified direct finish defect.");
277
290
  requireState(event.evidence.length > 0 && event.evidence.every(entry => validGoalEvidence(entry, state) && entry.execution.units.includes(event.unit_id)), "evidence", "Reconciled evidence must retain the current goal and unit identity.");
278
- processDefects.delete(event.previous_receipt_id);
291
+ recoverableSettlements.delete(event.previous_receipt_id);
279
292
  state = { ...state, evidence_refs: event.evidence.reduce((refs, evidence) => addUnique(refs, evidence.evidence_id), state.evidence_refs),
280
293
  satisfied_criteria: event.evidence.reduce((ids, evidence) => evidence.measurement.criterion_ids.reduce(addUnique, ids), state.satisfied_criteria) };
281
294
  }
@@ -76,6 +76,13 @@ export interface MissionAttempt {
76
76
  code: string;
77
77
  };
78
78
  resultClass?: string;
79
+ /** Retained erroneous finish projection; the original goal receipt remains append-only. */
80
+ registrationRecovery?: {
81
+ at: string;
82
+ previousStatus: MissionAttempt["status"];
83
+ previousResultClass?: string;
84
+ previousFailure?: MissionAttempt["failure"];
85
+ };
79
86
  selectedModel?: string;
80
87
  }
81
88
  export interface MissionTerminalRescue {
@@ -131,6 +131,11 @@ interface UnitState {
131
131
  actor: string;
132
132
  startedAt: string;
133
133
  finishedAt?: string;
134
+ registrationRecovery?: {
135
+ at: string;
136
+ previousStatus: "failed" | "cancelled";
137
+ previousResultClass: string | null;
138
+ };
134
139
  checks: import("../plugin/runtime-bridge.js").ReviewerCorrectionCheck[];
135
140
  validationCwd?: Readonly<Record<string, string>>;
136
141
  validationRegisteredAt?: Readonly<Record<string, string>>;
@@ -430,7 +435,7 @@ export declare class OperatorRuntime {
430
435
  private repairValidationRetryBinding;
431
436
  canRetryRepairValidation(state: OperatorState): boolean;
432
437
  resumeRepairValidation(root: string, runID: string, authorize: (source: OperatorRepairValidationRetrySource) => Promise<void>): Promise<OperatorState>;
433
- resume(root: string, runID: string, recovered: ReadonlyMap<string, readonly GoalEvidence[]>, unstarted?: ReadonlySet<string>): Promise<OperatorState>;
438
+ resume(root: string, runID: string, recovered: ReadonlyMap<string, readonly GoalEvidence[]>, unstarted?: ReadonlySet<string>, recoveredDirect?: ReadonlySet<string>): Promise<OperatorState>;
434
439
  requireAcceptanceRemediation(root: string, runID: string): Promise<OperatorState>;
435
440
  draftStatus(root: string): Promise<unknown | undefined>;
436
441
  completionGoalFingerprint(state: OperatorState): Promise<string>;
@@ -2091,16 +2091,17 @@ export class OperatorRuntime {
2091
2091
  return state;
2092
2092
  });
2093
2093
  }
2094
- resume(root, runID, recovered, unstarted = new Set()) {
2094
+ resume(root, runID, recovered, unstarted = new Set(), recoveredDirect = new Set()) {
2095
2095
  return this.serial(root, async () => {
2096
2096
  const state = await this.required(root);
2097
2097
  if (state.contractRepair !== null)
2098
2098
  throw new Error("operator-resume-contract-repair-required");
2099
- if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(unit => unit.status === "running" || unit.status === "cancelled")) {
2099
+ if (state.runID !== runID || state.phase !== "awaiting-decision" || state.units.some(unit => unit.status === "running" ||
2100
+ unit.status === "cancelled" && !recoveredDirect.has(unit.unit.id))) {
2100
2101
  throw new Error("operator-resume-not-ready");
2101
2102
  }
2102
2103
  for (const unit of state.units) {
2103
- if (unit.status !== "failed")
2104
+ if (unit.status !== "failed" && unit.status !== "cancelled")
2104
2105
  continue;
2105
2106
  if (unstarted.has(unit.unit.id)) {
2106
2107
  if (unit.resultClass !== "process-defect" || unit.childSessionID !== null || state.decision !== "dispatch-admission-rejected")
@@ -2111,10 +2112,18 @@ export class OperatorRuntime {
2111
2112
  continue;
2112
2113
  }
2113
2114
  const evidence = recovered.get(unit.unit.id);
2115
+ const direct = recoveredDirect.has(unit.unit.id) && unit.directExecution?.actor === unit.childSessionID &&
2116
+ !!unit.directExecution.finishedAt && !!unit.callID && !!evidence?.length;
2117
+ await this.verifyControls(unit);
2114
2118
  const ids = new Set(evidence?.flatMap(item => item.measurement.criterion_ids) ?? []);
2115
- if (unit.resultClass !== "process-defect" || !evidence?.length || !unit.unit.acceptance_indices.every(index => state.acceptanceProof[index].some(id => ids.has(id)))) {
2119
+ if (unit.resultClass !== "process-defect" && !direct || !evidence?.length || !unit.unit.acceptance_indices.every(index => state.acceptanceProof[index].some(id => ids.has(id)))) {
2116
2120
  throw new Error("operator-resume-proof-incomplete");
2117
2121
  }
2122
+ if (unit.status === "cancelled" && !direct)
2123
+ throw new Error("operator-resume-proof-incomplete");
2124
+ if (direct)
2125
+ unit.directExecution.registrationRecovery = { at: new Date().toISOString(),
2126
+ previousStatus: unit.status, previousResultClass: unit.resultClass };
2118
2127
  unit.status = "succeeded";
2119
2128
  unit.resultClass = "acceptance";
2120
2129
  unit.evidence = evidence;
@@ -925,6 +925,14 @@ export class RunFlightLedger {
925
925
  stored.reservation_id === event.reservation_id && stored.native_session_id === event.native_session_id &&
926
926
  stored.cost_usd === event.cost_usd))
927
927
  return reduceGoalFlight(records);
928
+ if (event.kind === "unit.evidence-reconciled" && event.direct_finish !== undefined) {
929
+ const existing = records.find(record => record.event.kind === "unit.evidence-reconciled" &&
930
+ record.event.previous_receipt_id === event.previous_receipt_id)?.event;
931
+ if (existing?.kind === "unit.evidence-reconciled" && existing.goal_id === event.goal_id && existing.unit_id === event.unit_id &&
932
+ goalFingerprint(existing.evidence) === goalFingerprint(event.evidence) &&
933
+ goalFingerprint(existing.direct_finish) === goalFingerprint(event.direct_finish))
934
+ return reduceGoalFlight(records);
935
+ }
928
936
  if (event.kind === "goal.accepted") {
929
937
  const prior = records.find(({ event: stored }) => stored.kind === "goal.accepted" &&
930
938
  stored.origin_user_message_id === event.origin_user_message_id);
@@ -1,4 +1,5 @@
1
1
  import { createHash } from "node:crypto";
2
+ import { CONTRACT_TEXT_LIMITS } from "./contract-limits.js";
2
3
  export const VALIDATION_PROFILES = ["fast", "balanced", "assurance"];
3
4
  /** A real failed process can be rerun after setup repair without changing source or command identity. */
4
5
  export function retryableValidationFailure(outcome, exitCode) {
@@ -38,6 +39,7 @@ export function validationEvidenceState(events) {
38
39
  return { reusable: [...reusable], blocked: [...blocked] };
39
40
  }
40
41
  const id = (value) => typeof value === "string" && value.length > 0 && value.length <= 4096;
42
+ const commandText = (value) => typeof value === "string" && value.length > 0 && value.length <= CONTRACT_TEXT_LIMITS.command;
41
43
  const scopes = new Set(["static", "targeted", "related", "canonical", "full-suite", "full"]);
42
44
  const normalizedScope = (scope) => scope === "full" ? "canonical" : scope;
43
45
  export function validationOwner(scope) {
@@ -94,7 +96,7 @@ export function decideValidationBudget(request, state) {
94
96
  const environment = value.environment;
95
97
  const marginal = value.marginal_value;
96
98
  const valid = id(value.run_id) && id(value.operation_id) && id(value.source_snapshot) && id(value.candidate) &&
97
- value.command.length > 0 && value.command.every(id) && scopes.has(value.scope) &&
99
+ value.command.length > 0 && value.command.every(commandText) && scopes.has(value.scope) &&
98
100
  environment !== null && typeof environment === "object" && id(environment.platform) && id(environment.arch) &&
99
101
  id(environment.runtime) && (environment.toolchain === undefined || id(environment.toolchain)) &&
100
102
  (value.owner === "worker" || value.owner === "coordinator") &&
@@ -50,6 +50,7 @@ import { AdaptiveRemediationRuntime, AdaptiveRunFlightLineage } from "../core/ad
50
50
  import { DEFAULT_ADAPTIVE_REMEDIATION_MODEL, GitAdaptiveRemediationHost, OpenCodeAdaptiveRemediationProvider } from "./adaptive-remediation-host.js";
51
51
  import { collectRunMetrics, createSortieResult, createGoalReport, insertRunMetrics, insertSortieResult, replaceDoneTerminalStatus, replaceTerminalStatus, sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
52
52
  import { STABLE_RUNTIME_PROFILE } from "../core/runtime-profile.js";
53
+ import { reviewerCorrectionValidationFresh } from "./mission-review.js";
53
54
  import { evidenceFromObservedExecution } from "../core/observed-goal-evidence.js";
54
55
  import { receiptBoundTerminalText } from "./receipt-presentation.js";
55
56
  import { operationInputSnapshot, protectedSnapshot, refreshProtectedSnapshot, validatedSourceSnapshot, validationInputSnapshot } from "./protected-snapshot.js";
@@ -2369,11 +2370,15 @@ export const SortieDogsPlugin = async (input, options) => {
2369
2370
  const failed = [...hostGoalExecutions.values()].find(execution => execution.root === reservation.root &&
2370
2371
  execution.sessionID === child && execution.endedAt !== undefined && execution.outcome === "fail" &&
2371
2372
  Date.parse(execution.startedAt) >= reservation.started - 1000);
2372
- await input.runtimeBridge?.onSerialSettlement?.({ rootSessionID: reservation.root, callID, unitID: settled.unit_id,
2373
+ const result = { rootSessionID: reservation.root, callID, unitID: settled.unit_id,
2373
2374
  ...(child ? { childSessionID: child } : {}), disposition: settled.disposition, evidence: settled.evidence,
2374
- resultClass: settled.result_class ?? "process-defect", nativeOutcome: output.status === "error" || output.status === "cancelled" ? "failed" : "completed",
2375
+ resultClass: settled.result_class ?? "process-defect",
2376
+ ...(directEvidence !== undefined ? {} : { nativeOutcome: output.status === "error" || output.status === "cancelled" ? "failed" : "completed" }),
2375
2377
  ...(settled.result_class === "acceptance" && failed ? { failure: { command: failed.command.slice(0, 8),
2376
- outcome: "fail", exitCode: failed.exitCode ?? null } } : {}) });
2378
+ outcome: "fail", exitCode: failed.exitCode ?? null } } : {}) };
2379
+ await input.runtimeBridge?.onSerialSettlement?.(result);
2380
+ goalReservations.delete(callID);
2381
+ return result;
2377
2382
  }
2378
2383
  goalReservations.delete(callID);
2379
2384
  return;
@@ -2405,7 +2410,10 @@ export const SortieDogsPlugin = async (input, options) => {
2405
2410
  // Revalidation can succeed for an already-satisfied criterion after a candidate change.
2406
2411
  // New criterion coverage controls progress accounting, not the validation disposition.
2407
2412
  const metadata = isRecord(output.metadata) ? output.metadata : undefined;
2408
- const child = childSessionID ? await input.client?.session?.get?.({ path: { id: childSessionID }, query: { directory: input.directory } }).catch(() => undefined) : undefined;
2413
+ // A direct unit owns this invocation's checks, not the reusable controller
2414
+ // conversation's last native Task outcome. Empty direct evidence still denotes
2415
+ // direct failure; explicit cancellation/error below remains authoritative.
2416
+ const child = directEvidence === undefined && childSessionID ? await input.client?.session?.get?.({ path: { id: childSessionID }, query: { directory: input.directory } }).catch(() => undefined) : undefined;
2409
2417
  const childInfo = isRecord(child) && "data" in child ? child.data : child;
2410
2418
  const childOutcome = isRecord(childInfo) ? childInfo.outcome : undefined;
2411
2419
  const terminalFailed = nativeOutcome === "failed" || metadata?.status === "error" || output.status === "error" ||
@@ -2432,7 +2440,7 @@ export const SortieDogsPlugin = async (input, options) => {
2432
2440
  evidence: settlementEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started),
2433
2441
  cost_usd: await nativeUnitCost(childSessionID, new Date(reservation.started).toISOString()),
2434
2442
  ...(childSessionID ? { native_session_id: childSessionID, native_started_at: new Date(reservation.started).toISOString() } : {}) });
2435
- await input.runtimeBridge?.onSerialSettlement?.({
2443
+ const result = {
2436
2444
  rootSessionID: reservation.root, callID, unitID: reservation.unitID,
2437
2445
  ...(childSessionID === undefined ? {} : { childSessionID }),
2438
2446
  disposition: validated ? "succeeded" : interrupted ? "cancelled" : "failed",
@@ -2442,7 +2450,8 @@ export const SortieDogsPlugin = async (input, options) => {
2442
2450
  command: failedAcceptanceExecution.command.slice(0, 8), outcome: "fail",
2443
2451
  exitCode: failedAcceptanceExecution.exitCode ?? null,
2444
2452
  } } : {}),
2445
- });
2453
+ };
2454
+ await input.runtimeBridge?.onSerialSettlement?.(result);
2446
2455
  goalReservations.delete(callID);
2447
2456
  if (childSessionID !== undefined) {
2448
2457
  goalValidationDefects.delete(childSessionID);
@@ -2451,6 +2460,7 @@ export const SortieDogsPlugin = async (input, options) => {
2451
2460
  hostGoalExecutions.delete(executionCallID);
2452
2461
  }
2453
2462
  }
2463
+ return result;
2454
2464
  }
2455
2465
  async function settleRejectedGoalDispatch(rootSessionID, callID) {
2456
2466
  const reservation = goalReservations.get(callID);
@@ -2472,6 +2482,89 @@ export const SortieDogsPlugin = async (input, options) => {
2472
2482
  if (goal.acceptance_fingerprint !== request.goalFingerprint || goal.phase !== "active" || goal.receipt !== null || goal.outstanding_reservations.length) {
2473
2483
  throw new Error("operator-recovery-goal-not-ready");
2474
2484
  }
2485
+ if (request.direct) {
2486
+ const direct = request.direct;
2487
+ const defect = [...snapshot.records].reverse().map(record => record.event).find(event => event.kind === "unit.settled" &&
2488
+ event.unit_id === request.unitID && event.evidence.length === 0 && event.native_session_id === request.childSessionID &&
2489
+ event.reservation_id === goalFingerprint({ goal_id: goal.goal_id, unit_id: request.unitID, call_id: direct.callID }) &&
2490
+ event.receipt_id === goalFingerprint({ call_id: direct.callID,
2491
+ output: "Host-observed direct unit finished; controller session remains active." }));
2492
+ if (defect?.kind !== "unit.settled" || Math.abs(Date.parse(defect.at) - Date.parse(direct.finishedAt)) > 1000) {
2493
+ throw new Error("operator-recovery-direct-finish-receipt-missing");
2494
+ }
2495
+ const identity = await hostSessionIdentity(request.childSessionID);
2496
+ if (!identity || request.childSessionID !== root && identity.parentID !== root &&
2497
+ coordinatorRootForSession(request.childSessionID) !== root)
2498
+ throw new Error("operator-recovery-direct-owner-mismatch");
2499
+ const sessionAPI = input.client?.session;
2500
+ const historyAPI = sessionAPI;
2501
+ const messages = historyAPI?.reviewMessages ?? sessionAPI?.messages;
2502
+ if (!messages)
2503
+ throw new Error("operator-recovery-host-evidence-unavailable");
2504
+ const historyRequest = { path: { id: request.childSessionID }, query: { directory: input.directory,
2505
+ notBefore: new Date(Date.parse(direct.startedAt) - 1000).toISOString() } };
2506
+ const response = await messages.call(input.client.session, historyRequest);
2507
+ const payload = isRecord(response) && "data" in response ? response.data : response;
2508
+ if (!Array.isArray(payload))
2509
+ throw new Error("operator-recovery-host-evidence-invalid");
2510
+ const history = payload.filter(isRecord);
2511
+ const parts = history.filter(message => isRecord(message.info) && message.info.role === "assistant" &&
2512
+ message.info.sessionID === request.childSessionID).flatMap(message => Array.isArray(message.parts) ? message.parts.filter(isRecord) : []);
2513
+ const finish = parts.find(part => part.type === "tool" && ["sortie_finish_direct_unit", `${runtimeProfile.toolPrefix}finish_direct_unit`].includes(String(part.tool)) &&
2514
+ isRecord(part.state) && part.state.status === "completed" && isRecord(part.state.time) &&
2515
+ typeof part.state.time.start === "number" && typeof part.state.time.end === "number" &&
2516
+ part.state.time.start <= Date.parse(defect.at) && part.state.time.end >= Date.parse(defect.at));
2517
+ if (!finish || typeof finish.callID !== "string")
2518
+ throw new Error("operator-recovery-direct-finish-observation-missing");
2519
+ if (parts.some(part => part.type === "tool" && ["cancel_operator", "cancel_mission"].some(name => part.tool === `sortie_${name}` || part.tool === `${runtimeProfile.toolPrefix}${name}`) && isRecord(part.state) && part.state.status === "completed" &&
2520
+ isRecord(part.state.time) && typeof part.state.time.end === "number" && part.state.time.end >= Date.parse(direct.startedAt))) {
2521
+ throw new Error("operator-recovery-direct-explicit-cancellation");
2522
+ }
2523
+ const checked = await reviewerCorrectionValidationFresh(direct.validation, request.childSessionID, history, Date.parse(direct.startedAt), direct.checks, input.directory, direct.directories, direct.registeredAt);
2524
+ if (!checked.ready)
2525
+ throw new Error(`operator-recovery-proof-unavailable-or-stale:${checked.reason}`);
2526
+ const checks = direct.checks.filter(check => checked.matched?.some(item => item.callID === check.callID));
2527
+ if (checks.some(check => check.dispatchCallID !== direct.callID || check.childSessionID !== request.childSessionID ||
2528
+ resolve(input.directory, check.binding.manifest_path) !== resolve(request.manifestPath) ||
2529
+ Date.parse(check.endedAt) > Date.parse(defect.at) + 1000))
2530
+ throw new Error("operator-recovery-direct-check-identity-mismatch");
2531
+ const evidence = checks.flatMap(check => observedHostGoalEvidence({ ...check, owner: "coordinator", outcome: "pass",
2532
+ immutableRef: goalFingerprint({ direct_check: check }),
2533
+ correction: { taskID: request.unitID, dispatchCallID: direct.callID, commands: check.command } }, goal, request.unitID));
2534
+ for (const check of checks.filter(check => evidence.some(entry => check.command.includes(entry.execution.command[0])))) {
2535
+ // The persisted host check already binds this exact native call to its
2536
+ // Task/cwd/recipe. Its post-check validation recipe can be narrower than
2537
+ // the admission snapshot; do not relabel or recompute that historical key.
2538
+ const admission = snapshot.records.map(record => record.event).find(event => event.kind === "validation.admission" &&
2539
+ event.goal_id === goal.goal_id && event.operation_id === check.callID && event.decision === "ALLOW" &&
2540
+ Date.parse(event.at) >= Date.parse(direct.startedAt) - 1000 && Date.parse(event.at) <= Date.parse(check.endedAt) + 1000);
2541
+ if (admission?.kind !== "validation.admission" || !snapshot.records.some(({ event }) => event.kind === "validation.settled" &&
2542
+ event.reservation_id === admission.reservation_id && event.operation_id === check.callID &&
2543
+ event.goal_id === goal.goal_id && event.evidence_key === admission.evidence_key && event.outcome === "passed" && event.exit_code === 0)) {
2544
+ throw new Error("operator-recovery-direct-validation-settlement-missing");
2545
+ }
2546
+ }
2547
+ if (!evidence.length || evidence.some(entry => !validGoalEvidence(entry, goal)))
2548
+ throw new Error("operator-recovery-proof-unavailable-or-stale");
2549
+ const proof = { call_id: direct.callID, actor_session_id: request.childSessionID, finish_call_id: finish.callID,
2550
+ started_at: direct.startedAt, finished_at: direct.finishedAt, validation_call_ids: checks.map(check => check.callID) };
2551
+ const prior = snapshot.records.map(record => record.event).find(event => event.kind === "unit.evidence-reconciled" &&
2552
+ event.previous_receipt_id === defect.receipt_id);
2553
+ if (prior?.kind === "unit.evidence-reconciled") {
2554
+ if (goalFingerprint(prior.direct_finish) !== goalFingerprint(proof) || goalFingerprint(prior.evidence) !== goalFingerprint(evidence)) {
2555
+ throw new Error("operator-recovery-direct-reconciliation-mismatch");
2556
+ }
2557
+ return prior.evidence;
2558
+ }
2559
+ // Recheck immediately before the existing append path; no reserve/admit/validation
2560
+ // execution or spend settlement occurs when repairing this registration.
2561
+ const final = await reviewerCorrectionValidationFresh(direct.validation, request.childSessionID, history, Date.parse(direct.startedAt), direct.checks, input.directory, direct.directories, direct.registeredAt);
2562
+ if (!final.ready)
2563
+ throw new Error(`operator-recovery-proof-unavailable-or-stale:${final.reason}`);
2564
+ await ledger.appendGoal({ kind: "unit.evidence-reconciled", at: new Date().toISOString(), goal_id: goal.goal_id, unit_id: request.unitID,
2565
+ previous_receipt_id: defect.receipt_id, evidence, direct_finish: proof });
2566
+ return evidence;
2567
+ }
2475
2568
  const defect = [...snapshot.records].reverse().map(record => record.event).find(event => event.kind === "unit.settled" &&
2476
2569
  event.unit_id === request.unitID && event.result_class === "process-defect" && !event.evidence.length);
2477
2570
  if (defect?.kind !== "unit.settled")
@@ -8290,10 +8383,13 @@ export const SortieDogsPlugin = async (input, options) => {
8290
8383
  const evidence = checks.flatMap(check => observedHostGoalEvidence({ ...check, owner: "coordinator",
8291
8384
  outcome: check.exitCode === 0 ? "pass" : "fail", immutableRef: goalFingerprint({ direct_check: check }),
8292
8385
  correction: { taskID: direct.taskID, dispatchCallID: direct.callID, commands: check.command } }, snapshot.state, direct.taskID));
8293
- await settleGoalDispatch(direct.callID, { output: "Host-observed direct unit finished; controller session remains active.",
8386
+ const settled = await settleGoalDispatch(direct.callID, { output: "Host-observed direct unit finished; controller session remains active.",
8294
8387
  metadata: { sessionId: actor } }, undefined, evidence);
8388
+ if (!settled)
8389
+ throw new Error("mission-direct-settlement-unavailable");
8295
8390
  await releaseWriteGate(actor);
8296
8391
  releaseSessionEnforcement(actor);
8392
+ return settled;
8297
8393
  },
8298
8394
  releaseDirectUnit: async (actor) => {
8299
8395
  await releaseWriteGate(actor);
@@ -240,8 +240,8 @@ export function createProfiledPlugin(profile, assetVersion) {
240
240
  return observedMissionValidationSummary(validation, child, typeof nativeSession?.messages === "function"
241
241
  ? () => session("messages", { path: { id: child }, query: { directory: input.directory } }) : undefined, notBefore, notBefore === undefined ? undefined : input.directory, direct?.unit.directExecution?.validationCwd ?? direct?.unit.unit.validation_cwd);
242
242
  }
243
- async function reviewMessages(id) {
244
- const result = payload(await session(typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages", { path: { id }, query: { directory: input.directory } }));
243
+ async function reviewMessages(id, notBefore) {
244
+ const result = payload(await session(typeof nativeSession?.reviewMessages === "function" ? "reviewMessages" : "messages", { path: { id }, query: { directory: input.directory, ...(notBefore ? { notBefore } : {}) } }));
245
245
  return Array.isArray(result) ? result.filter(record) : [];
246
246
  }
247
247
  async function observedSessionModel(id) {
@@ -1814,107 +1814,171 @@ export function createProfiledPlugin(profile, assetVersion) {
1814
1814
  ...(panel ? { return_report: panel,
1815
1815
  return_report_instruction: "The host-authored receipt is retained in this tool result. Give the user a concise outcome and key checks; do not transcribe this card, recalculate it or request another model turn for presentation." } : {}) });
1816
1816
  } };
1817
- tools[resume] = { description: "Reconcile a finished process-defect unit from native host validation records and unchanged source. For the exact first validation-only contract-repair continuation process defect, unchanged durable controls/candidate and available validation budget permit one same-worker validation-only retry; the result includes the exact Task to invoke. Never reimplements a unit or resets implementation/goal spend.",
1817
+ tools[resume] = { description: "Reconcile a finished process-defect or diagnosed direct finish-registration defect from retained native checks and currently unchanged source. Direct recovery preserves the same unit and all spend without rerunning checks; explicit cancellation, failed/incomplete/stale validation and ordinary Worker terminal decisions remain authoritative. For the exact first validation-only contract-repair continuation process defect, unchanged durable controls/candidate and available validation budget permit one same-worker validation-only retry. Never reimplements a unit or resets implementation/goal spend.",
1818
1818
  args: { run_id: stringSchema, acceptance_fingerprint: stringSchema }, execute: async (args, context) => {
1819
1819
  await requireRoot(context.sessionID);
1820
- let state = await operators.required(context.sessionID);
1821
- if (state.runID !== args.run_id || state.acceptanceFingerprint !== args.acceptance_fingerprint || state.phase !== "awaiting-decision")
1822
- throw new Error("operator-resume-identity-mismatch");
1823
- if (state.decision === "operator-acceptance-remediation-required") {
1824
- throw new Error(state.repairGeneration === 1
1825
- ? "operator-resume-unit-not-recoverable"
1826
- : "operator-resume-acceptance-remediation-required");
1827
- }
1828
- async function finishedSession(id) {
1829
- const statuses = payload(await session("status", { query: { directory: input.directory } }));
1830
- const observed = record(statuses) ? statuses[id] : undefined;
1831
- if (record(observed) && observed.type !== "idle")
1832
- throw new Error("operator-resume-host-still-active");
1833
- const history = await messages(id);
1834
- const assistants = history.filter(message => (record(message.info) ? message.info.role : message.role) === "assistant");
1835
- const last = assistants.at(-1), info = record(last?.info) ? last.info : last;
1836
- const tail = history.at(-1), tailInfo = record(tail?.info) ? tail.info : tail;
1837
- if (!info || tailInfo?.role !== "assistant" || (!isRecordTimeComplete(info) && info.finish !== "stop") || history.some(message => Array.isArray(message.parts) && message.parts.some(part => record(part) && part.type === "tool" && record(part.state) && ["pending", "running"].includes(String(part.state.status))))) {
1838
- throw new Error("operator-resume-host-still-active");
1820
+ return serializeDispatchTransition(context.sessionID, async () => {
1821
+ let state = await operators.required(context.sessionID);
1822
+ const replayDirect = new Set(state.units.filter(unit => unit.status === "succeeded" && !!unit.directExecution?.registrationRecovery)
1823
+ .map(unit => unit.unit.id));
1824
+ const replay = replayDirect.size > 0 && ["awaiting-acceptance", "awaiting-decision", "prepared"].includes(state.phase) &&
1825
+ !state.units.some(unit => ["failed", "cancelled", "running"].includes(unit.status));
1826
+ if (state.runID !== args.run_id || state.acceptanceFingerprint !== args.acceptance_fingerprint ||
1827
+ state.phase !== "awaiting-decision" && !replay)
1828
+ throw new Error("operator-resume-identity-mismatch");
1829
+ await operators.verifyContinuityControls(state);
1830
+ if (state.decision === "operator-acceptance-remediation-required" &&
1831
+ !state.units.some(unit => unit.status === "failed" && !!unit.directExecution?.finishedAt)) {
1832
+ throw new Error(state.repairGeneration === 1
1833
+ ? "operator-resume-unit-not-recoverable"
1834
+ : "operator-resume-acceptance-remediation-required");
1839
1835
  }
1840
- }
1841
- function isRecordTimeComplete(info) { return record(info.time) && typeof info.time.completed === "number"; }
1842
- const delegate = state.operatorSessionID;
1843
- if (state.units.length > 1) {
1844
- const owner = delegate ? await identity(delegate) : undefined;
1845
- if (!delegate || owner?.parent !== context.sessionID || owner.role !== "dog-operator")
1846
- throw new Error("operator-resume-delegate-owner-mismatch");
1847
- await finishedSession(delegate);
1848
- operatorParents.set(delegate, context.sessionID);
1849
- }
1850
- const goalFingerprint = await operators.completionGoalFingerprint(state);
1851
- await relinkRegisteredGoal(context.sessionID, state, goalFingerprint);
1852
- await control.assertActiveGoal(context.sessionID, goalFingerprint);
1853
- await restorePriorAcceptance(context.sessionID, state);
1854
- const recovered = new Map();
1855
- const unstarted = new Set();
1856
- for (const unit of state.units) {
1857
- if (unit.status !== "failed")
1858
- continue;
1859
- const taskID = /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1];
1860
- if (!taskID)
1861
- throw new Error("operator-resume-task-id-missing");
1862
- if (unit.resultClass === "process-defect" && unit.childSessionID === null && unit.callID && state.decision === "dispatch-admission-rejected") {
1863
- const history = await messages(state.units.length > 1 ? delegate : context.sessionID);
1864
- const matching = history.flatMap(message => Array.isArray(message.parts) ? message.parts : []).filter(part => record(part) &&
1865
- part.type === "tool" && ["task", "subagent"].includes(String(part.tool)) && part.callID === unit.callID && record(part.state) && part.state.status === "error" &&
1866
- record(part.state.input) && operators.matchesRecordedWorkerTask(state, unit.unit.id, part.state.input) &&
1867
- taskChildSessionID({ metadata: part.state.metadata, output: typeof part.state.output === "string" ? part.state.output : "" }) === undefined);
1868
- if (matching.length !== 1 || !await control.hasNoGoalReservation(context.sessionID, taskID, unit.callID))
1869
- throw new Error("operator-resume-unstarted-proof-missing");
1870
- unstarted.add(unit.unit.id);
1871
- continue;
1836
+ async function finishedSession(id, fullHistory = false, notBefore) {
1837
+ const statuses = payload(await session("status", { query: { directory: input.directory } }));
1838
+ const observed = record(statuses) ? statuses[id] : undefined;
1839
+ if (record(observed) && observed.type !== "idle")
1840
+ throw new Error("operator-resume-host-still-active");
1841
+ const history = fullHistory ? await reviewMessages(id, notBefore) : await messages(id);
1842
+ const assistants = history.filter(message => (record(message.info) ? message.info.role : message.role) === "assistant");
1843
+ const last = assistants.at(-1), info = record(last?.info) ? last.info : last;
1844
+ const tail = history.at(-1), tailInfo = record(tail?.info) ? tail.info : tail;
1845
+ if (!info || tailInfo?.role !== "assistant" || (!isRecordTimeComplete(info) && info.finish !== "stop") || history.some(message => Array.isArray(message.parts) && message.parts.some(part => record(part) && part.type === "tool" && record(part.state) && ["pending", "running"].includes(String(part.state.status))))) {
1846
+ throw new Error("operator-resume-host-still-active");
1847
+ }
1872
1848
  }
1873
- const child = unit.childSessionID ? await identity(unit.childSessionID) : undefined;
1874
- if (unit.resultClass !== "process-defect" || !unit.childSessionID || child?.role !== "dog-worker" ||
1875
- child.parent !== (state.units.length > 1 ? delegate : context.sessionID))
1876
- throw new Error("operator-resume-unit-not-recoverable");
1877
- await finishedSession(unit.childSessionID);
1878
- try {
1879
- recovered.set(unit.unit.id, await control.recoverUnitEvidence(context.sessionID, { unitID: taskID,
1880
- childSessionID: unit.childSessionID, manifestPath: unit.manifestPath, manifestHash: unit.hashes[1], goalFingerprint }));
1849
+ function isRecordTimeComplete(info) { return record(info.time) && typeof info.time.completed === "number"; }
1850
+ const delegate = state.operatorSessionID;
1851
+ if (!replay && state.units.length > 1 && state.units.some(unit => unit.status === "failed" && !unit.directExecution)) {
1852
+ const owner = delegate ? await identity(delegate) : undefined;
1853
+ if (!delegate || owner?.parent !== context.sessionID || owner.role !== "dog-operator")
1854
+ throw new Error("operator-resume-delegate-owner-mismatch");
1855
+ await finishedSession(delegate);
1856
+ operatorParents.set(delegate, context.sessionID);
1881
1857
  }
1882
- catch (error) {
1883
- if (!(error instanceof Error) || error.message.split(":", 1)[0] !== "operator-recovery-proof-unavailable-or-stale")
1884
- throw error;
1885
- if (!operators.canRetryRepairValidation(state)) {
1886
- try {
1887
- const remediation = await operators.requireAcceptanceRemediation(context.sessionID, state.runID);
1888
- return JSON.stringify(await operatorPacket(remediation));
1858
+ const goalFingerprint = await operators.completionGoalFingerprint(state);
1859
+ await relinkRegisteredGoal(context.sessionID, state, goalFingerprint);
1860
+ await control.assertActiveGoal(context.sessionID, goalFingerprint);
1861
+ await restorePriorAcceptance(context.sessionID, state);
1862
+ const recovered = new Map();
1863
+ const recoveredDirect = new Set();
1864
+ const unstarted = new Set();
1865
+ for (const unit of state.units) {
1866
+ if (unit.status !== "failed" && unit.status !== "cancelled" && !(replay && replayDirect.has(unit.unit.id)))
1867
+ continue;
1868
+ const taskID = /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1];
1869
+ if (!taskID)
1870
+ throw new Error("operator-resume-task-id-missing");
1871
+ if (unit.directExecution) {
1872
+ const direct = unit.directExecution;
1873
+ const mission = await missions.required(context.sessionID);
1874
+ if (["cancelled", "completed"].includes(mission.phase) || mission.runID !== state.runID || !unit.callID ||
1875
+ !direct.finishedAt || direct.actor !== unit.childSessionID ||
1876
+ direct.actor !== (mission.coordinator ?? context.sessionID) && unit.reviewerCorrection?.author !== direct.actor) {
1877
+ throw new Error("operator-resume-direct-not-recoverable");
1889
1878
  }
1890
- catch (remediationError) {
1891
- if (!(remediationError instanceof Error) || remediationError.message !== "operator-process-remediation-not-ready") {
1892
- throw remediationError;
1879
+ // A reusable root executes this very recovery tool. Its native conversation
1880
+ // need not end like a Worker Task; other actors still must be inactive.
1881
+ if (direct.actor !== context.sessionID)
1882
+ await finishedSession(direct.actor, true, direct.startedAt);
1883
+ recovered.set(unit.unit.id, await control.recoverUnitEvidence(context.sessionID, { unitID: taskID,
1884
+ childSessionID: direct.actor, manifestPath: unit.manifestPath, manifestHash: unit.hashes[1], goalFingerprint,
1885
+ direct: { callID: unit.callID, startedAt: direct.startedAt, finishedAt: direct.finishedAt,
1886
+ checks: unit.reviewerCorrection?.checks ?? direct.checks, validation: unit.unit.validation,
1887
+ directories: direct.validationCwd ?? unit.unit.validation_cwd,
1888
+ registeredAt: unit.reviewerCorrection?.validationRegisteredAt ?? direct.validationRegisteredAt } }));
1889
+ recoveredDirect.add(unit.unit.id);
1890
+ continue;
1891
+ }
1892
+ if (unit.resultClass === "process-defect" && unit.childSessionID === null && unit.callID && state.decision === "dispatch-admission-rejected") {
1893
+ const history = await messages(state.units.length > 1 ? delegate : context.sessionID);
1894
+ const matching = history.flatMap(message => Array.isArray(message.parts) ? message.parts : []).filter(part => record(part) &&
1895
+ part.type === "tool" && ["task", "subagent"].includes(String(part.tool)) && part.callID === unit.callID && record(part.state) && part.state.status === "error" &&
1896
+ record(part.state.input) && operators.matchesRecordedWorkerTask(state, unit.unit.id, part.state.input) &&
1897
+ taskChildSessionID({ metadata: part.state.metadata, output: typeof part.state.output === "string" ? part.state.output : "" }) === undefined);
1898
+ if (matching.length !== 1 || !await control.hasNoGoalReservation(context.sessionID, taskID, unit.callID))
1899
+ throw new Error("operator-resume-unstarted-proof-missing");
1900
+ unstarted.add(unit.unit.id);
1901
+ continue;
1902
+ }
1903
+ const child = unit.childSessionID ? await identity(unit.childSessionID) : undefined;
1904
+ if (unit.resultClass !== "process-defect" || !unit.childSessionID || child?.role !== "dog-worker" ||
1905
+ child.parent !== (state.units.length > 1 ? delegate : context.sessionID))
1906
+ throw new Error("operator-resume-unit-not-recoverable");
1907
+ await finishedSession(unit.childSessionID);
1908
+ try {
1909
+ recovered.set(unit.unit.id, await control.recoverUnitEvidence(context.sessionID, { unitID: taskID,
1910
+ childSessionID: unit.childSessionID, manifestPath: unit.manifestPath, manifestHash: unit.hashes[1], goalFingerprint }));
1911
+ }
1912
+ catch (error) {
1913
+ if (!(error instanceof Error) || error.message.split(":", 1)[0] !== "operator-recovery-proof-unavailable-or-stale")
1914
+ throw error;
1915
+ if (!operators.canRetryRepairValidation(state)) {
1916
+ try {
1917
+ const remediation = await operators.requireAcceptanceRemediation(context.sessionID, state.runID);
1918
+ return JSON.stringify(await operatorPacket(remediation));
1919
+ }
1920
+ catch (remediationError) {
1921
+ if (!(remediationError instanceof Error) || remediationError.message !== "operator-process-remediation-not-ready") {
1922
+ throw remediationError;
1923
+ }
1924
+ // A process defect without an authorized Git lifecycle has no committed remediation
1925
+ // baseline. Preserve the failed run and return an explicit replacement route instead
1926
+ // of turning this local routing limitation into a terminal tool exception.
1927
+ const replacement = await operators.processReplacementRequired(context.sessionID, state.runID);
1928
+ return JSON.stringify(processRemediationReplacementPacket(remediationError.message, operators.packet(replacement), cancel, prepare));
1893
1929
  }
1894
- // A process defect without an authorized Git lifecycle has no committed remediation
1895
- // baseline. Preserve the failed run and return an explicit replacement route instead
1896
- // of turning this local routing limitation into a terminal tool exception.
1897
- const replacement = await operators.processReplacementRequired(context.sessionID, state.runID);
1898
- return JSON.stringify(processRemediationReplacementPacket(remediationError.message, operators.packet(replacement), cancel, prepare));
1899
1930
  }
1931
+ const retried = await operators.resumeRepairValidation(context.sessionID, state.runID, source => control.authorizeOperatorContractRepairValidationRetry(context.sessionID, { ...source, declarationFingerprint: goalFingerprint }));
1932
+ await restorePriorAcceptance(context.sessionID, retried);
1933
+ return JSON.stringify({ run_id: retried.runID, acceptance_fingerprint: retried.acceptanceFingerprint,
1934
+ resumed: true, mode: "repair-validation-only", repair_validation_attempt: 2,
1935
+ task: retried.units.length === 1 ? operators.nextWorkerTask(retried) : operators.dispatchTask(retried) });
1900
1936
  }
1901
- const retried = await operators.resumeRepairValidation(context.sessionID, state.runID, source => control.authorizeOperatorContractRepairValidationRetry(context.sessionID, { ...source, declarationFingerprint: goalFingerprint }));
1902
- await restorePriorAcceptance(context.sessionID, retried);
1903
- return JSON.stringify({ run_id: retried.runID, acceptance_fingerprint: retried.acceptanceFingerprint,
1904
- resumed: true, mode: "repair-validation-only", repair_validation_attempt: 2,
1905
- task: retried.units.length === 1 ? operators.nextWorkerTask(retried) : operators.dispatchTask(retried) });
1906
1937
  }
1907
- }
1908
- await control.assertActiveGoal(context.sessionID, goalFingerprint);
1909
- const resumed = await operators.resume(context.sessionID, state.runID, recovered, unstarted);
1910
- await restorePriorAcceptance(context.sessionID, resumed);
1911
- if (resumed.phase === "awaiting-acceptance")
1912
- return JSON.stringify(operators.packet(resumed));
1913
- const remaining = resumed.units.filter(unit => unit.status === "pending").length;
1914
- control.enableUnits(context.sessionID, remaining);
1915
- return JSON.stringify({ run_id: resumed.runID, acceptance_fingerprint: resumed.acceptanceFingerprint,
1916
- resumed: true, remaining_units: remaining,
1917
- task: resumed.units.length === 1 ? operators.nextWorkerTask(resumed) : operators.dispatchTask(resumed) });
1938
+ await control.assertActiveGoal(context.sessionID, goalFingerprint);
1939
+ const resumed = replay ? state : await operators.resume(context.sessionID, state.runID, recovered, unstarted, recoveredDirect);
1940
+ if (recoveredDirect.size)
1941
+ await missions.update(context.sessionID, mission => {
1942
+ if (mission.runID !== resumed.runID || ["completed", "cancelled"].includes(mission.phase))
1943
+ throw new Error("operator-resume-direct-mission-changed");
1944
+ for (const unit of resumed.units.filter(unit => recoveredDirect.has(unit.unit.id))) {
1945
+ const attempt = mission.attempts?.find(attempt => attempt.callID === unit.callID);
1946
+ if (attempt && !attempt.registrationRecovery) {
1947
+ attempt.registrationRecovery = { at: unit.directExecution.registrationRecovery.at, previousStatus: attempt.status,
1948
+ previousResultClass: attempt.resultClass, previousFailure: attempt.failure };
1949
+ attempt.status = "succeeded";
1950
+ attempt.resultClass = "acceptance";
1951
+ delete attempt.failure;
1952
+ }
1953
+ if ([...mission.progress].reverse().find(item => item.unit === `${resumed.runID}/${unit.unit.id}`)?.status !== "succeeded") {
1954
+ mission.progress.push({ unit: `${resumed.runID}/${unit.unit.id}`, title: unit.unit.title, status: "succeeded", at: new Date().toISOString() });
1955
+ }
1956
+ const correction = mission.corrections?.find(item => item.runID === resumed.runID && item.author === unit.directExecution?.actor);
1957
+ if (correction)
1958
+ correction.status = "ready";
1959
+ }
1960
+ });
1961
+ await restorePriorAcceptance(context.sessionID, resumed);
1962
+ if (recoveredDirect.size)
1963
+ return JSON.stringify({
1964
+ ...compactMissionEvidence(missionPacket(await missions.required(context.sessionID), resumed), resumed, context.sessionID),
1965
+ recovered_direct_units: [...recoveredDirect],
1966
+ next_action: "The same direct unit's finish registration is reconciled from currently fresh native checks, without a new unit or check rerun. Retain the original erroneous receipt and cumulative spend. " +
1967
+ (resumed.phase === "awaiting-decision" ? `The recorded boundary decision remains pending: ${resumed.decision}. No replacement Task is admitted. ` :
1968
+ resumed.phase === "prepared" ? "Remaining planned units are still pending under the existing authorization and cumulative budget. " : "") +
1969
+ "Latest-change Review and individual product acceptance remain pending; this recovery is not acceptance, Go or completion."
1970
+ });
1971
+ if (resumed.phase === "awaiting-acceptance") {
1972
+ return JSON.stringify(operators.packet(resumed));
1973
+ }
1974
+ if (resumed.phase === "awaiting-decision")
1975
+ return JSON.stringify(operators.packet(resumed));
1976
+ const remaining = resumed.units.filter(unit => unit.status === "pending").length;
1977
+ control.enableUnits(context.sessionID, remaining);
1978
+ return JSON.stringify({ run_id: resumed.runID, acceptance_fingerprint: resumed.acceptanceFingerprint,
1979
+ resumed: true, remaining_units: remaining,
1980
+ task: resumed.units.length === 1 ? operators.nextWorkerTask(resumed) : operators.dispatchTask(resumed) });
1981
+ });
1918
1982
  } };
1919
1983
  tools[beginProposal] = { description: `Root-only: freeze original ordered requirements and grant one bounded read-only proposal investigation to dogs-coordinator. The result contains one exact short Task reference; pass task.subagent_type, task.description, and task.prompt byte-for-byte without appending or paraphrasing. The native parent retains that reference; the host expands it only inside the directly claimed proposal child. ${OPERATOR_INTENT_CONTRACT}`,
1920
1984
  args: { intent_json: intentSchema }, execute: async (args, context) => {
@@ -2140,8 +2204,16 @@ export function createProfiledPlugin(profile, assetVersion) {
2140
2204
  next_action: "Correct the actual failure or run the missing/affected checks in this same session, then finish_direct_unit. Preserve successful current evidence; do not dispatch a validation-only Worker." });
2141
2205
  // A resumed correction Task settles at its native terminal, not at this
2142
2206
  // advisory completion call. Inline/direct execution uses its existing finish path.
2143
- if (direct.unit.directExecution)
2144
- await control.finishDirectUnit(root, context.sessionID, (direct.unit.reviewerCorrection?.checks ?? direct.unit.directExecution.checks).filter(check => checks.matched?.some(item => item.callID === check.callID)));
2207
+ if (direct.unit.directExecution) {
2208
+ const settled = await control.finishDirectUnit(root, context.sessionID, (direct.unit.reviewerCorrection?.checks ?? direct.unit.directExecution.checks).filter(check => checks.matched?.some(item => item.callID === check.callID)));
2209
+ const saved = (await operators.required(root)).units.find(unit => unit.callID === direct.unit.callID);
2210
+ if (settled.disposition !== "succeeded" || !settled.evidence.length || saved?.status !== "succeeded" || !saved.evidence.length) {
2211
+ const run = await operators.required(root);
2212
+ return JSON.stringify({ ...compactMissionEvidence(missionPacket(await missions.required(root), run), run, root),
2213
+ direct_settlement: { disposition: settled.disposition, result_class: settled.resultClass, evidence_count: settled.evidence.length },
2214
+ next_action: "Direct unit is not validated: retain its actual settlement, native checks, current source and cumulative spend. Inspect the recorded cancellation/failure or use resume_operator for a diagnosed finish-registration defect; do not repeat passed checks or create a replacement unit just to repair registration. Review and Mission acceptance remain pending." });
2215
+ }
2216
+ }
2145
2217
  if (direct.unit.reviewerCorrection)
2146
2218
  return JSON.stringify({ status: "correction-validated",
2147
2219
  execution: "same-native-task", validation: direct.unit.unit.validation,
@@ -203,6 +203,15 @@ export interface RuntimeBridge {
203
203
  manifestPath: string;
204
204
  manifestHash: string;
205
205
  goalFingerprint: string;
206
+ direct?: {
207
+ callID: string;
208
+ startedAt: string;
209
+ finishedAt: string;
210
+ checks: readonly ReviewerCorrectionCheck[];
211
+ validation: readonly string[];
212
+ directories?: Readonly<Record<string, string>>;
213
+ registeredAt?: Readonly<Record<string, string>>;
214
+ };
206
215
  }): Promise<readonly GoalEvidence[]>;
207
216
  completionReadiness(rootSessionID: string): Promise<import("./goal-completion.js").CompletionReadiness>;
208
217
  missionWorkerTerminal(rootSessionID: string, terminal: MissionWorkerTerminalRecord, writeScopes: readonly string[]): Promise<{
@@ -212,7 +221,7 @@ export interface RuntimeBridge {
212
221
  restoreReviewerCorrectionChild(rootSessionID: string, childSessionID: string, callID: string, taskID: string): Promise<void>;
213
222
  activateMissionWorker(rootSessionID: string, childSessionID: string, handoffPath: string, admission: MissionImplementationAdmission): Promise<void>;
214
223
  activateDirectUnit(rootSessionID: string, actorSessionID: string, prompt: string, handoffPath: string): Promise<void>;
215
- finishDirectUnit(rootSessionID: string, actorSessionID: string, checks: readonly ReviewerCorrectionCheck[]): Promise<void>;
224
+ finishDirectUnit(rootSessionID: string, actorSessionID: string, checks: readonly ReviewerCorrectionCheck[]): Promise<SerialDispatchSettlement>;
216
225
  /** Settle an unfinished direct unit against its actual actor, never a fabricated child Task. */
217
226
  failDirectUnit(rootSessionID: string, actorSessionID: string): Promise<void>;
218
227
  releaseDirectUnit(actorSessionID: string): Promise<void>;
package/dist/plugin/v2.js CHANGED
@@ -110,11 +110,12 @@ function legacyMessage(value, session, agent) {
110
110
  return converted === undefined ? [] : [converted];
111
111
  }) };
112
112
  }
113
- async function legacyMessages(context, id, paginated) {
113
+ async function legacyMessages(context, id, paginated, notBefore) {
114
114
  const list = paginated ?? (context.message ? (input) => context.message.list(input) : undefined);
115
115
  const [info, history] = await Promise.all([
116
116
  context.session.get({ sessionID: id }).catch(() => undefined),
117
- list ? nativePages(cursor => list({ sessionID: id, limit: 100, ...(cursor ? { cursor } : { order: "asc" }) }))
117
+ list ? nativePages(cursor => list({ sessionID: id, limit: 100,
118
+ ...(cursor ? { cursor } : { order: notBefore === undefined ? "asc" : "desc" }) }), notBefore)
118
119
  : context.session.context({ sessionID: id }),
119
120
  ]);
120
121
  const agent = record(info) ? string(info.agent) : undefined;
@@ -123,17 +124,24 @@ async function legacyMessages(context, id, paginated) {
123
124
  return converted === undefined ? [] : [converted];
124
125
  });
125
126
  }
126
- async function nativePages(fetch) {
127
+ async function nativePages(fetch, notBefore) {
127
128
  const items = [], seen = new Set();
128
129
  let cursor;
129
- for (let page = 0; page < 100; page += 1) {
130
+ for (let page = 0; notBefore !== undefined || page < 100; page += 1) {
130
131
  const result = await fetch(cursor);
131
132
  if (!record(result) || !Array.isArray(result.data) || !record(result.cursor))
132
133
  throw new Error("v2-history-page-invalid");
133
134
  items.push(...result.data);
135
+ // Retain the whole boundary page: a message created before admission can
136
+ // contain tools that started after it. The check verifier applies its own
137
+ // exact native-start cutoff. Do not scan decades of unrelated controller
138
+ // history or reject a known unit window at the general full-history cap.
139
+ if (notBefore !== undefined && result.data.some(message => record(message) && record(message.time) &&
140
+ typeof message.time.created === "number" && message.time.created < notBefore))
141
+ return items.reverse();
134
142
  const next = string(result.cursor.next);
135
143
  if (!next)
136
- return items;
144
+ return notBefore === undefined ? items : items.reverse();
137
145
  if (seen.has(next))
138
146
  throw new Error("v2-history-cursor-repeated");
139
147
  seen.add(next);
@@ -160,7 +168,9 @@ function legacyClient(context) {
160
168
  },
161
169
  reviewMessages: async (request) => {
162
170
  const id = sessionID(request);
163
- return { data: id === undefined ? [] : await legacyMessages(context, id, context.message ? input => context.message.list(input) : reviewList) };
171
+ const query = record(request) && record(request.query) ? request.query : undefined;
172
+ const requestedStart = typeof query?.notBefore === "string" ? Date.parse(query.notBefore) : undefined;
173
+ return { data: id === undefined ? [] : await legacyMessages(context, id, context.message ? input => context.message.list(input) : reviewList, Number.isFinite(requestedStart) ? requestedStart : undefined) };
164
174
  },
165
175
  reviewExecution: async (request) => {
166
176
  const id = sessionID(request);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "sortie-dogs",
3
- "version": "0.13.17",
3
+ "version": "0.13.18",
4
4
  "description": "Bounded agent harness and validated orchestration loop plugin for OpenCode",
5
5
  "keywords": [
6
6
  "opencode",