sortie-dogs 0.12.12 → 0.12.14
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/dist/asset-version.d.ts +1 -1
- package/dist/asset-version.js +1 -1
- package/dist/core/goal-bound.d.ts +9 -0
- package/dist/core/goal-bound.js +14 -0
- package/dist/core/operator-mission.d.ts +3 -1
- package/dist/core/operator-mission.js +10 -1
- package/dist/core/operator-runtime.d.ts +6 -1
- package/dist/core/operator-runtime.js +34 -5
- package/dist/core/run-flight-ledger.js +4 -0
- package/dist/plugin/gate.js +5 -10
- package/dist/plugin/index.js +64 -6
- package/dist/plugin/mission-progress.d.ts +2 -1
- package/dist/plugin/mission-progress.js +28 -0
- package/dist/plugin/profiled.js +112 -14
- package/dist/plugin/runtime-bridge.d.ts +6 -0
- package/dist/plugin/unit-usage.d.ts +2 -0
- package/dist/plugin/unit-usage.js +44 -0
- package/dist/plugin/v2.js +41 -9
- package/dist/runtime-mission-assets.js +20 -3
- package/package.json +1 -1
package/dist/asset-version.d.ts
CHANGED
|
@@ -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.
|
|
6
|
+
export declare const V010_RUNTIME_ASSET_VERSION = "0.12.14-worker-context-progress-v1";
|
|
7
7
|
export type RuntimeAssetVersion = typeof RUNTIME_ASSET_VERSION | typeof V010_RUNTIME_ASSET_VERSION;
|
package/dist/asset-version.js
CHANGED
|
@@ -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.
|
|
6
|
+
export const V010_RUNTIME_ASSET_VERSION = "0.12.14-worker-context-progress-v1";
|
|
@@ -167,6 +167,15 @@ export type GoalFlightEvent = (GoalEventBase & {
|
|
|
167
167
|
readonly evidence: readonly GoalEvidence[];
|
|
168
168
|
readonly elapsed_ms: number | null;
|
|
169
169
|
readonly cost_usd: number | null;
|
|
170
|
+
readonly native_session_id?: string;
|
|
171
|
+
readonly native_started_at?: string;
|
|
172
|
+
}) | (GoalEventBase & {
|
|
173
|
+
readonly kind: "unit.usage-reconciled";
|
|
174
|
+
readonly goal_id: string;
|
|
175
|
+
readonly reservation_id: string;
|
|
176
|
+
readonly native_session_id: string;
|
|
177
|
+
readonly cost_usd: number;
|
|
178
|
+
readonly source: "native-usage-price-table";
|
|
170
179
|
}) | (GoalEventBase & {
|
|
171
180
|
readonly kind: "unit.evidence-reconciled";
|
|
172
181
|
readonly goal_id: string;
|
package/dist/core/goal-bound.js
CHANGED
|
@@ -115,6 +115,7 @@ export function reduceGoalFlight(records) {
|
|
|
115
115
|
let legacyResetRevision = false;
|
|
116
116
|
let previous = null;
|
|
117
117
|
const processDefects = new Map();
|
|
118
|
+
const unitCosts = new Map();
|
|
118
119
|
const validationAdmissions = new Map();
|
|
119
120
|
const validationSettlements = new Map();
|
|
120
121
|
const reopenedValidationEvidence = new Set();
|
|
@@ -128,6 +129,7 @@ export function reduceGoalFlight(records) {
|
|
|
128
129
|
continue;
|
|
129
130
|
requireState(instant(event.at), "invalid", "Goal event timestamp is invalid.");
|
|
130
131
|
if (event.kind === "goal.accepted") {
|
|
132
|
+
unitCosts.clear();
|
|
131
133
|
requireState(state.goal_id === null || state.phase === "terminal" || state.phase === "stopped", "transition", "An active goal already owns this root.");
|
|
132
134
|
requireState(text(event.goal_id) && HASH.test(event.acceptance_fingerprint) && text(event.origin_user_message_id) &&
|
|
133
135
|
text(event.origin_session_id) && text(event.selected_agent) && event.budget.max_units > 0 &&
|
|
@@ -222,6 +224,7 @@ export function reduceGoalFlight(records) {
|
|
|
222
224
|
: state.no_progress_results;
|
|
223
225
|
const elapsed = state.consumed_time_ms === null || event.elapsed_ms === null ? null : state.consumed_time_ms + event.elapsed_ms;
|
|
224
226
|
const cost = state.consumed_cost_usd === null || event.cost_usd === null ? null : state.consumed_cost_usd + event.cost_usd;
|
|
227
|
+
unitCosts.set(event.reservation_id, { cost: event.cost_usd, session: event.native_session_id });
|
|
225
228
|
state = { ...state, consumed_units: state.consumed_units + 1, consumed_time_ms: elapsed, consumed_cost_usd: cost,
|
|
226
229
|
no_progress_results: nextNoProgress, replan_required: nextNoProgress >= 2,
|
|
227
230
|
outstanding_reservations: state.outstanding_reservations.filter((entry) => entry.reservation_id !== event.reservation_id),
|
|
@@ -232,6 +235,17 @@ export function reduceGoalFlight(records) {
|
|
|
232
235
|
processDefects.set(event.receipt_id, { unitID: event.unit_id, revision: state.revision, epoch: state.scope_epoch });
|
|
233
236
|
}
|
|
234
237
|
}
|
|
238
|
+
else if (event.kind === "unit.usage-reconciled") {
|
|
239
|
+
const prior = unitCosts.get(event.reservation_id);
|
|
240
|
+
requireState(event.goal_id === state.goal_id && prior !== undefined && prior.session === event.native_session_id &&
|
|
241
|
+
event.source === "native-usage-price-table" &&
|
|
242
|
+
Number.isFinite(event.cost_usd) && event.cost_usd >= 0 &&
|
|
243
|
+
(prior.cost === null || prior.cost === event.cost_usd), "transition", "Usage reconciliation must identify one settled native unit.");
|
|
244
|
+
unitCosts.set(event.reservation_id, { cost: event.cost_usd, session: event.native_session_id });
|
|
245
|
+
const costs = [...unitCosts.values()];
|
|
246
|
+
state = { ...state, consumed_cost_usd: costs.some(item => item.cost === null) ? null
|
|
247
|
+
: costs.reduce((sum, item) => sum + item.cost, 0) };
|
|
248
|
+
}
|
|
235
249
|
else if (event.kind === "unit.evidence-reconciled") {
|
|
236
250
|
const old = processDefects.get(event.previous_receipt_id);
|
|
237
251
|
requireState(state.phase === "active" && state.receipt === null && old?.unitID === event.unit_id &&
|
|
@@ -17,6 +17,8 @@ export interface OperatorMission {
|
|
|
17
17
|
id: string;
|
|
18
18
|
text: string;
|
|
19
19
|
}[];
|
|
20
|
+
/** The current user intentionally replaced the predecessor's requirements. */
|
|
21
|
+
requirementsReplaced?: boolean;
|
|
20
22
|
phase: "open" | "running" | "submitted" | "completed" | "cancelled";
|
|
21
23
|
coordinator: string | null;
|
|
22
24
|
callID: string | null;
|
|
@@ -74,7 +76,7 @@ export declare class OperatorMissionRuntime {
|
|
|
74
76
|
read(root: string): Promise<OperatorMission | undefined>;
|
|
75
77
|
required(root: string): Promise<OperatorMission>;
|
|
76
78
|
capture(root: string, request: MissionRequest): Promise<void>;
|
|
77
|
-
start(root: string, requirements: unknown): Promise<OperatorMission>;
|
|
79
|
+
start(root: string, requirements: unknown, replaceRequirements?: boolean): Promise<OperatorMission>;
|
|
78
80
|
update(root: string, change: (state: OperatorMission) => void): Promise<OperatorMission>;
|
|
79
81
|
/** Keep host-accepted criteria ahead of new text, including an exactly linked settled predecessor. */
|
|
80
82
|
carryForward(root: string, missionID: string, acceptance: readonly string[], supersededRunID?: string): Promise<OperatorMission>;
|
|
@@ -160,7 +160,7 @@ export class OperatorMissionRuntime {
|
|
|
160
160
|
}
|
|
161
161
|
});
|
|
162
162
|
}
|
|
163
|
-
start(root, requirements) {
|
|
163
|
+
start(root, requirements, replaceRequirements = false) {
|
|
164
164
|
return this.serial(root, async () => {
|
|
165
165
|
if (!Array.isArray(requirements) || requirements.length === 0 || requirements.length > 64 ||
|
|
166
166
|
!requirements.every(item => typeof item === "string" && item.trim() && !/[\r\n]/u.test(item))) {
|
|
@@ -182,6 +182,7 @@ export class OperatorMissionRuntime {
|
|
|
182
182
|
await this.save(this.file(root, `.${previous.id}`), previous);
|
|
183
183
|
const state = { version: "0.12", id: `mission-${randomUUID()}`, root, requests: [request],
|
|
184
184
|
requirements: requirements.map((text, index) => ({ id: `R${index + 1}`, text })), phase: "open",
|
|
185
|
+
...(replaceRequirements ? { requirementsReplaced: true } : {}),
|
|
185
186
|
coordinator: null, callID: null, dispatchOpen: false, runID: null, plans: 0, progress: [], submission: null,
|
|
186
187
|
...(previous?.phase === "cancelled" &&
|
|
187
188
|
(previous.runID === null || request.id !== previous.requests[0]?.id) &&
|
|
@@ -331,9 +332,15 @@ export function missionPlan(mission, raw) {
|
|
|
331
332
|
units }, "execution");
|
|
332
333
|
}
|
|
333
334
|
export function missionPacket(mission, run) {
|
|
335
|
+
const predecessor = run && mission.runID !== run.runID && (mission.supersededRunID === run.runID || run.phase === "cancelled") ? run : undefined;
|
|
336
|
+
if (predecessor)
|
|
337
|
+
run = undefined;
|
|
334
338
|
const currentReview = mission.review !== undefined && mission.review.runID === (run?.runID ?? mission.runID);
|
|
335
339
|
const reviewAccepted = currentReview && missionReviewAccepted(mission.review);
|
|
336
340
|
return { mission_id: mission.id, phase: mission.phase, coordinator_session_id: mission.coordinator,
|
|
341
|
+
...(predecessor ? { predecessor: { run_id: predecessor.runID, status: predecessor.phase,
|
|
342
|
+
completed_units: predecessor.units.filter(unit => unit.status === "succeeded").length,
|
|
343
|
+
note: "Historical results and spend are retained; they do not complete the current requirements." } } : {}),
|
|
337
344
|
requirements: mission.requirements, original_request_refs: mission.requests.map(item => `user:${item.id}`),
|
|
338
345
|
submission: mission.submission, progress: mission.progress,
|
|
339
346
|
execution_summary: { completed_units: run?.units.filter(unit => unit.status === "succeeded").length ?? 0,
|
|
@@ -351,6 +358,8 @@ export function missionPacket(mission, run) {
|
|
|
351
358
|
...(run ? { run_id: run.runID, status: run.phase, decision: run.decision,
|
|
352
359
|
units: run.units.map(unit => ({ id: unit.unit.id, title: unit.unit.title, status: unit.status,
|
|
353
360
|
child_session_id: unit.childSessionID, result_class: unit.resultClass, evidence: unit.evidence,
|
|
361
|
+
handoff_path: unit.handoffPath, operation_manifest_path: unit.manifestPath,
|
|
362
|
+
scope_read: unit.unit.read, scope_write: unit.unit.write, validation: unit.unit.validation,
|
|
354
363
|
...(unit.dispatchDenial ? { dispatch_denial: unit.dispatchDenial } : {}) })) } : {}),
|
|
355
364
|
next_action: mission.phase === "completed" ? "Mission completed. Report the accepted result and retained review gaps; no further dispatch or completion call is needed."
|
|
356
365
|
: mission.phase === "submitted" && mission.submission?.status === "ready"
|
|
@@ -204,6 +204,8 @@ export interface OperatorState {
|
|
|
204
204
|
operatorCallID: string | null;
|
|
205
205
|
operatorSessionID: string | null;
|
|
206
206
|
dispatched: number;
|
|
207
|
+
/** Native interrupt acknowledgements for this cancelled run, persisted before settlement. */
|
|
208
|
+
stoppedChildren?: string[];
|
|
207
209
|
units: UnitState[];
|
|
208
210
|
decision: string | null;
|
|
209
211
|
receipt: GoalTerminalReceipt | null;
|
|
@@ -248,7 +250,7 @@ export declare class OperatorRuntime {
|
|
|
248
250
|
prepareMission(root: string, raw: unknown, dispatcher?: {
|
|
249
251
|
sessionID: string;
|
|
250
252
|
callID: string;
|
|
251
|
-
}, supersededRunID?: string, terminalChildren?: readonly string[]): Promise<OperatorState>;
|
|
253
|
+
}, supersededRunID?: string, terminalChildren?: readonly string[], replaceRequirements?: boolean): Promise<OperatorState>;
|
|
252
254
|
/** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
|
|
253
255
|
replanMission(root: string, runID: string, raw: unknown, dispatcher?: {
|
|
254
256
|
sessionID: string;
|
|
@@ -323,6 +325,7 @@ export declare class OperatorRuntime {
|
|
|
323
325
|
processReplacementRequired(root: string, runID: string): Promise<OperatorState>;
|
|
324
326
|
private operatorReturnedOnce;
|
|
325
327
|
interrupted(root: string, reason: string): Promise<readonly string[]>;
|
|
328
|
+
recordStoppedChild(root: string, runID: string, child: string): Promise<void>;
|
|
326
329
|
private interruptedOnce;
|
|
327
330
|
terminal(root: string, receipt: GoalTerminalReceipt): Promise<void>;
|
|
328
331
|
private terminalOnce;
|
|
@@ -337,6 +340,8 @@ export declare class OperatorRuntime {
|
|
|
337
340
|
completionGoalFingerprint(state: OperatorState): Promise<string>;
|
|
338
341
|
verifyContinuityControls(state: OperatorState): Promise<void>;
|
|
339
342
|
packet(state: OperatorState): unknown;
|
|
343
|
+
/** Reconstruct the admitted child's instructions without trusting a lossy conversation summary. */
|
|
344
|
+
workerContext(root: string, child: string): Promise<string | undefined>;
|
|
340
345
|
continuationCheckpoint(root: string): Promise<string | undefined>;
|
|
341
346
|
private verifyControls;
|
|
342
347
|
/** Follow only the host's authorized parent-link repair; keep the durable control pin in sync. */
|
|
@@ -686,8 +686,8 @@ export class OperatorRuntime {
|
|
|
686
686
|
}
|
|
687
687
|
}
|
|
688
688
|
/** Mission authority is supplied only by the owning profile, never by a model-authored plan. */
|
|
689
|
-
prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = []) {
|
|
690
|
-
return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren }));
|
|
689
|
+
prepareMission(root, raw, dispatcher, supersededRunID, terminalChildren = [], replaceRequirements = false) {
|
|
690
|
+
return this.serial(root, () => this.prepareOnce(root, raw, undefined, { dispatcher, supersededRunID, terminalChildren, replaceRequirements }));
|
|
691
691
|
}
|
|
692
692
|
/** Stage a settled mission's replacement; rejected preparation never cancels the usable run. */
|
|
693
693
|
replanMission(root, runID, raw, dispatcher) {
|
|
@@ -758,12 +758,12 @@ export class OperatorRuntime {
|
|
|
758
758
|
return previous;
|
|
759
759
|
throw new Error("operator-active-contract-immutable");
|
|
760
760
|
}
|
|
761
|
-
//
|
|
762
|
-
//
|
|
761
|
+
// Same-request continuation retains acceptance. Explicit user replacement keeps the
|
|
762
|
+
// predecessor's history/spend but uses the current requirements instead of an old prefix.
|
|
763
763
|
// Treat the predecessor as retired only in this staged replacement. Its durable state remains
|
|
764
764
|
// intact through schema checks, control writes and archive creation, until save atomically switches it.
|
|
765
765
|
const parent = replanning ? { ...previous, phase: "cancelled", decision: "mission-replan" }
|
|
766
|
-
: (!superseding || retainAcceptance) && previous?.phase === "cancelled" ? previous : undefined;
|
|
766
|
+
: (!superseding || (retainAcceptance && !mission?.replaceRequirements)) && previous?.phase === "cancelled" ? previous : undefined;
|
|
767
767
|
if (parent && ["explicit-cancellation", "agent-changed"].includes(parent.decision ?? "")) {
|
|
768
768
|
const cancelled = parent.units.flatMap(unit => (parent.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null
|
|
769
769
|
? [unit.childSessionID] : []);
|
|
@@ -1579,6 +1579,15 @@ export class OperatorRuntime {
|
|
|
1579
1579
|
interrupted(root, reason) {
|
|
1580
1580
|
return this.serial(root, () => this.interruptedOnce(root, reason));
|
|
1581
1581
|
}
|
|
1582
|
+
recordStoppedChild(root, runID, child) {
|
|
1583
|
+
return this.serial(root, async () => {
|
|
1584
|
+
const state = await this.required(root);
|
|
1585
|
+
if (state.runID !== runID || state.phase !== "cancelled")
|
|
1586
|
+
return;
|
|
1587
|
+
state.stoppedChildren = [...new Set([...(state.stoppedChildren ?? []), child])];
|
|
1588
|
+
await this.save(state);
|
|
1589
|
+
});
|
|
1590
|
+
}
|
|
1582
1591
|
async interruptedOnce(root, reason) {
|
|
1583
1592
|
const state = await this.read(root);
|
|
1584
1593
|
if (!state || state.phase === "completed")
|
|
@@ -1910,6 +1919,26 @@ export class OperatorRuntime {
|
|
|
1910
1919
|
throw new Error("operator-packet-too-large-no-evidence-truncated");
|
|
1911
1920
|
return packet;
|
|
1912
1921
|
}
|
|
1922
|
+
/** Reconstruct the admitted child's instructions without trusting a lossy conversation summary. */
|
|
1923
|
+
async workerContext(root, child) {
|
|
1924
|
+
const state = await this.read(root);
|
|
1925
|
+
if (!state || state.phase !== "running")
|
|
1926
|
+
return undefined;
|
|
1927
|
+
const unit = state.units.find(item => item.childSessionID === child && item.status === "running");
|
|
1928
|
+
if (!unit)
|
|
1929
|
+
return undefined;
|
|
1930
|
+
// Keep this prefix stable across reads/edits: changing counters belong in status/tool results.
|
|
1931
|
+
return `SORTIE_WORKER_CONTEXT\n${JSON.stringify({ root_session_id: root, run_id: state.runID,
|
|
1932
|
+
unit_id: unit.unit.id, task_id: /^task_id: (.+)$/m.exec(unit.task.prompt)?.[1],
|
|
1933
|
+
project_root: this.projectRoot, handoff_path: unit.handoffPath, operation_manifest: unit.manifestPath,
|
|
1934
|
+
objective: unit.unit.objective, read: unit.unit.read, write: unit.unit.write,
|
|
1935
|
+
validation: unit.unit.validation, acceptance: state.acceptance,
|
|
1936
|
+
...(unit.repairValidation === null ? {} : { validation_only: true }),
|
|
1937
|
+
})}\nThis is the durable assignment for this same Worker, including after compaction or reload. ` +
|
|
1938
|
+
"A missing path in a summary does not mean the handoff is missing. Read the retained handoff/manifest and inspect the actual diff and outputs before claiming no edits or replaying work. " +
|
|
1939
|
+
`Use ${this.profile.toolPrefix}operator_status for observed validation and remaining work; do not repeat successful checks for an unchanged candidate. ` +
|
|
1940
|
+
"This context is an assignment, not proof of execution or acceptance.";
|
|
1941
|
+
}
|
|
1913
1942
|
async continuationCheckpoint(root) {
|
|
1914
1943
|
const state = await this.read(root);
|
|
1915
1944
|
const cancelledRemediation = state?.phase === "cancelled" &&
|
|
@@ -918,6 +918,10 @@ export class RunFlightLedger {
|
|
|
918
918
|
const records = await this.#readGoalRecords();
|
|
919
919
|
if (guard !== undefined)
|
|
920
920
|
guard(reduceGoalFlight(records));
|
|
921
|
+
if (event.kind === "unit.usage-reconciled" && records.some(({ event: stored }) => stored.kind === "unit.usage-reconciled" && stored.goal_id === event.goal_id &&
|
|
922
|
+
stored.reservation_id === event.reservation_id && stored.native_session_id === event.native_session_id &&
|
|
923
|
+
stored.cost_usd === event.cost_usd))
|
|
924
|
+
return reduceGoalFlight(records);
|
|
921
925
|
if (event.kind === "goal.accepted") {
|
|
922
926
|
const prior = records.find(({ event: stored }) => stored.kind === "goal.accepted" &&
|
|
923
927
|
stored.origin_user_message_id === event.origin_user_message_id);
|
package/dist/plugin/gate.js
CHANGED
|
@@ -1240,21 +1240,16 @@ export async function createWriteGate(project, value, toolDirectory = project.ro
|
|
|
1240
1240
|
: undefined;
|
|
1241
1241
|
if (command !== undefined && declaredValidation.has(command))
|
|
1242
1242
|
return;
|
|
1243
|
+
// Shell syntax is not an execution permission boundary. Mission workers already use
|
|
1244
|
+
// native host permissions; only declared checks produce formal validation evidence.
|
|
1245
|
+
if (options?.investigativeShell && ["bash", "shell"].includes(_input.tool))
|
|
1246
|
+
return;
|
|
1243
1247
|
const extracted = extractWritePaths(_input.tool, output.args);
|
|
1244
1248
|
if (!extracted.applies)
|
|
1245
1249
|
return;
|
|
1246
|
-
// Mission workers may investigate with repository-specific executables without registering
|
|
1247
|
-
// every diagnostic as a formal check. Known write destinations/Git mutations still use the
|
|
1248
|
-
// same scope gate; this option never makes diagnostic output acceptance evidence.
|
|
1249
|
-
if (options?.investigativeShell && ["bash", "shell"].includes(_input.tool) &&
|
|
1250
|
-
extracted.issue?.cause === "executable-not-allowlisted" && extracted.paths.length === 0 &&
|
|
1251
|
-
!extracted.gitMutation && !extracted.remoteMutation)
|
|
1252
|
-
return;
|
|
1253
1250
|
if (extracted.ambiguous || (extracted.paths.length === 0 && !extracted.gitCommit)) {
|
|
1254
1251
|
if (extracted.issue !== undefined) {
|
|
1255
|
-
|
|
1256
|
-
["unsupported-curl-form", "unsupported-git-archive-form", "unsupported-webrequest-form"].includes(extracted.issue.cause);
|
|
1257
|
-
throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue, formatCorrection));
|
|
1252
|
+
throw new WriteDeniedError("unclassified-command", issuePath(extracted.issue));
|
|
1258
1253
|
}
|
|
1259
1254
|
throw new WriteDeniedError("path-required", "<missing-path>");
|
|
1260
1255
|
}
|
package/dist/plugin/index.js
CHANGED
|
@@ -52,6 +52,7 @@ import { STABLE_RUNTIME_PROFILE } from "../core/runtime-profile.js";
|
|
|
52
52
|
import { evidenceFromObservedExecution } from "../core/observed-goal-evidence.js";
|
|
53
53
|
import { receiptBoundTerminalText } from "./receipt-presentation.js";
|
|
54
54
|
import { protectedSnapshot, refreshProtectedSnapshot } from "./protected-snapshot.js";
|
|
55
|
+
import { settledUnitUsage } from "./unit-usage.js";
|
|
55
56
|
import { goalCompletionReadiness } from "./goal-completion.js";
|
|
56
57
|
const INPUT_LIMITS = { config: 64 * 1024, manifest: 512 * 1024, handoff: 2 * 1024 * 1024, parallel: 512 * 1024 };
|
|
57
58
|
const INSPECTION_CACHE = { maximum: 256, ttlMilliseconds: 30 * 60 * 1000 };
|
|
@@ -1520,6 +1521,32 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1520
1521
|
startedAt: observedStartedAt, endedAt, exitCode: exitCode ?? null,
|
|
1521
1522
|
...(outcome === undefined ? {} : { outcome }), ...(immutableRef === undefined ? {} : { immutableRef }), fresh });
|
|
1522
1523
|
}
|
|
1524
|
+
async function nativeUnitCost(child, startedAt) {
|
|
1525
|
+
if (!child || !input.client?.session?.messages)
|
|
1526
|
+
return null;
|
|
1527
|
+
try {
|
|
1528
|
+
const response = await input.client.session.messages({ path: { id: child }, query: { directory: input.directory } });
|
|
1529
|
+
return isRecord(response) && Array.isArray(response.data) ? settledUnitUsage(response.data, startedAt) : null;
|
|
1530
|
+
}
|
|
1531
|
+
catch {
|
|
1532
|
+
return null;
|
|
1533
|
+
}
|
|
1534
|
+
}
|
|
1535
|
+
async function reconcileUnitUsage(root) {
|
|
1536
|
+
const ledger = await goalLedger(root), snapshot = await ledger.readGoal();
|
|
1537
|
+
const reconciled = new Set(snapshot.records.flatMap(({ event }) => event.kind === "unit.usage-reconciled" ? [event.reservation_id] : []));
|
|
1538
|
+
for (const { event } of snapshot.records) {
|
|
1539
|
+
if (event.kind !== "unit.settled" || event.goal_id !== snapshot.state.goal_id || event.cost_usd !== null ||
|
|
1540
|
+
!event.native_session_id || reconciled.has(event.reservation_id))
|
|
1541
|
+
continue;
|
|
1542
|
+
const cost = await nativeUnitCost(event.native_session_id, event.native_started_at);
|
|
1543
|
+
if (cost === null)
|
|
1544
|
+
continue;
|
|
1545
|
+
await ledger.appendGoal({ kind: "unit.usage-reconciled", at: new Date().toISOString(), goal_id: event.goal_id,
|
|
1546
|
+
reservation_id: event.reservation_id, native_session_id: event.native_session_id,
|
|
1547
|
+
cost_usd: cost, source: "native-usage-price-table" });
|
|
1548
|
+
}
|
|
1549
|
+
}
|
|
1523
1550
|
async function recoverCompletedGoalReservations(sessionID) {
|
|
1524
1551
|
const root = goalRoot(sessionID);
|
|
1525
1552
|
const active = goalReservationRecoveries.get(root);
|
|
@@ -1531,7 +1558,31 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1531
1558
|
// A terminal host Task is authoritative even when a missed after hook left process-local
|
|
1532
1559
|
// dispatch accounting behind. Treating that memory as live made the reservation permanent.
|
|
1533
1560
|
const pending = state.outstanding_reservations;
|
|
1534
|
-
|
|
1561
|
+
for (const reservation of pending) {
|
|
1562
|
+
const dispatch = await input.runtimeBridge?.recoverMissionDispatch?.(root, reservation.unit_id);
|
|
1563
|
+
if (!dispatch || !state.goal_id || goalFingerprint({ goal_id: state.goal_id, unit_id: reservation.unit_id,
|
|
1564
|
+
call_id: dispatch.callID }) !== reservation.reservation_id)
|
|
1565
|
+
continue;
|
|
1566
|
+
const startedAt = (await ledger.readGoal()).records.find(({ event }) => event.kind === "dispatch.reserved" &&
|
|
1567
|
+
event.reservation_id === reservation.reservation_id)?.event.at;
|
|
1568
|
+
await ledger.appendGoal({ kind: "unit.settled", at: new Date().toISOString(),
|
|
1569
|
+
reservation_id: reservation.reservation_id,
|
|
1570
|
+
receipt_id: goalFingerprint({ recovered_host_task: dispatch.callID, reservation: reservation.reservation_id }),
|
|
1571
|
+
goal_id: state.goal_id, unit_id: reservation.unit_id,
|
|
1572
|
+
disposition: dispatch.cancelled ? "cancelled" : "failed", result_class: dispatch.cancelled ? "interrupted" : "process-defect",
|
|
1573
|
+
progress_fingerprint: null, evidence: [], elapsed_ms: null,
|
|
1574
|
+
cost_usd: await nativeUnitCost(dispatch.childSessionID, startedAt),
|
|
1575
|
+
...(dispatch.childSessionID ? { native_session_id: dispatch.childSessionID, native_started_at: startedAt } : {}) });
|
|
1576
|
+
// A missed after hook closes execution, but cannot turn unobserved validation into acceptance.
|
|
1577
|
+
await input.runtimeBridge?.onSerialSettlement?.({ rootSessionID: root, callID: dispatch.callID,
|
|
1578
|
+
unitID: reservation.unit_id, ...(dispatch.childSessionID ? { childSessionID: dispatch.childSessionID } : {}),
|
|
1579
|
+
disposition: dispatch.cancelled ? "cancelled" : "failed", resultClass: dispatch.cancelled ? "interrupted" : "process-defect", evidence: [] });
|
|
1580
|
+
goalReservations.delete(dispatch.callID);
|
|
1581
|
+
if (finishCoordinatorTask(root, dispatch.callID))
|
|
1582
|
+
fastLane.workerCompleted(root);
|
|
1583
|
+
}
|
|
1584
|
+
const remaining = (await ledger.readGoal()).state.outstanding_reservations;
|
|
1585
|
+
if (remaining.length === 0 || state.goal_id === null || input.client?.session?.messages === undefined)
|
|
1535
1586
|
return;
|
|
1536
1587
|
const messages = input.client.session.messages;
|
|
1537
1588
|
const response = await messages.call(input.client.session, { path: { id: root },
|
|
@@ -1615,7 +1666,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
1615
1666
|
return undefined;
|
|
1616
1667
|
return candidates[0].callID;
|
|
1617
1668
|
}
|
|
1618
|
-
for (const reservation of
|
|
1669
|
+
for (const reservation of remaining) {
|
|
1619
1670
|
const matches = [];
|
|
1620
1671
|
for (const message of data) {
|
|
1621
1672
|
if (!isRecord(message) || !isRecord(message.info) || message.info.role !== "assistant" ||
|
|
@@ -2240,11 +2291,12 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
2240
2291
|
const reservation = goalReservations.get(callID);
|
|
2241
2292
|
if (reservation === undefined)
|
|
2242
2293
|
return;
|
|
2243
|
-
goalReservations.delete(callID);
|
|
2244
2294
|
const ledger = await goalLedger(reservation.root);
|
|
2245
2295
|
const state = (await ledger.readGoal()).state;
|
|
2246
|
-
if (state.goal_id === null)
|
|
2296
|
+
if (state.goal_id === null || !state.outstanding_reservations.some(item => item.reservation_id === reservation.reservationID)) {
|
|
2297
|
+
goalReservations.delete(callID);
|
|
2247
2298
|
return;
|
|
2299
|
+
}
|
|
2248
2300
|
const outputText = typeof output.output === "string" ? output.output : "";
|
|
2249
2301
|
// Task text and Task result metadata are model/child-controlled. Only an exact declared command
|
|
2250
2302
|
// observed in the child tool hooks with a native host exit may produce goal evidence.
|
|
@@ -2288,7 +2340,10 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
2288
2340
|
goal_id: state.goal_id, unit_id: reservation.unitID,
|
|
2289
2341
|
disposition: validated ? "succeeded" : interrupted ? "cancelled" : "failed", result_class: resultClass,
|
|
2290
2342
|
progress_fingerprint: progress ? goalFingerprint(acceptedEvidence) : null,
|
|
2291
|
-
evidence: acceptedEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started),
|
|
2343
|
+
evidence: acceptedEvidence, elapsed_ms: Math.max(0, Date.now() - reservation.started),
|
|
2344
|
+
cost_usd: await nativeUnitCost(childSessionID, new Date(reservation.started).toISOString()),
|
|
2345
|
+
...(childSessionID ? { native_session_id: childSessionID, native_started_at: new Date(reservation.started).toISOString() } : {}) });
|
|
2346
|
+
goalReservations.delete(callID);
|
|
2292
2347
|
await input.runtimeBridge?.onSerialSettlement?.({
|
|
2293
2348
|
rootSessionID: reservation.root, callID, unitID: reservation.unitID,
|
|
2294
2349
|
...(childSessionID === undefined ? {} : { childSessionID }),
|
|
@@ -7761,6 +7816,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
7761
7816
|
// Planning must account for terminal native Tasks before reserving new units.
|
|
7762
7817
|
// Otherwise a cancelled predecessor keeps a stale unit reservation across missions.
|
|
7763
7818
|
await recoverCompletedGoalReservations(root);
|
|
7819
|
+
await reconcileUnitUsage(root);
|
|
7764
7820
|
const state = await currentGoal(root);
|
|
7765
7821
|
if (state.goal_id === null || state.budget === null)
|
|
7766
7822
|
return null;
|
|
@@ -7769,7 +7825,8 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
7769
7825
|
reserved_units: reserved, remaining_units: Math.max(0, state.budget.max_units - state.consumed_units - reserved),
|
|
7770
7826
|
settled_cost_usd: state.consumed_cost_usd, cost_limit_usd: state.budget.cost_usd,
|
|
7771
7827
|
cost_status: state.consumed_cost_usd === null ? "unknown-usage" : reserved > 0 ? "in-flight-not-final" : "settled",
|
|
7772
|
-
|
|
7828
|
+
cost_source: "native-usage-price-table",
|
|
7829
|
+
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." };
|
|
7773
7830
|
},
|
|
7774
7831
|
registerGoalDeclaration: async (root, prompt, missionRevision) => {
|
|
7775
7832
|
if (!isCoordinatorSession(root) && !await recoverCoordinatorRoot(root))
|
|
@@ -7949,6 +8006,7 @@ export const SortieDogsPlugin = async (input, options) => {
|
|
|
7949
8006
|
fastLane.grantSerialUnits(sessionID, maximum);
|
|
7950
8007
|
},
|
|
7951
8008
|
cancelChildren: async (sessionID) => {
|
|
8009
|
+
await recoverCompletedGoalReservations(sessionID);
|
|
7952
8010
|
for (const [callID, reservation] of [...goalReservations]) {
|
|
7953
8011
|
if (reservation.root !== sessionID)
|
|
7954
8012
|
continue;
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
/** Display-only native Task progress. Never prompts a model or changes execution ownership. */
|
|
2
1
|
type Sink = (value: Record<string, unknown>) => Promise<void>;
|
|
3
2
|
export declare function bindMissionProgress(root: string, sink: Sink): () => void;
|
|
4
3
|
export declare function publishMissionProgress(root: string, value: Record<string, unknown>): Promise<void>;
|
|
4
|
+
/** Read durable transitions even when settlement ran in a different/reloaded plugin instance. */
|
|
5
|
+
export declare function missionProgressReader(directory: string, root: string, callID: string): () => Promise<Record<string, unknown> | undefined>;
|
|
5
6
|
export {};
|
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
/** Display-only native Task progress. Never prompts a model or changes execution ownership. */
|
|
2
|
+
import { OperatorMissionRuntime } from "../core/operator-mission.js";
|
|
3
|
+
import { OperatorRuntime } from "../core/operator-runtime.js";
|
|
4
|
+
import { V010_RUNTIME_PROFILE } from "../core/runtime-profile.js";
|
|
1
5
|
const sinks = new Map();
|
|
2
6
|
export function bindMissionProgress(root, sink) {
|
|
3
7
|
sinks.set(root, sink);
|
|
@@ -7,3 +11,27 @@ export function bindMissionProgress(root, sink) {
|
|
|
7
11
|
export async function publishMissionProgress(root, value) {
|
|
8
12
|
await sinks.get(root)?.(value).catch(() => undefined);
|
|
9
13
|
}
|
|
14
|
+
/** Read durable transitions even when settlement ran in a different/reloaded plugin instance. */
|
|
15
|
+
export function missionProgressReader(directory, root, callID) {
|
|
16
|
+
const missions = new OperatorMissionRuntime(directory, V010_RUNTIME_PROFILE);
|
|
17
|
+
return async () => {
|
|
18
|
+
const mission = await missions.read(root);
|
|
19
|
+
if (!mission || mission.callID !== callID)
|
|
20
|
+
return undefined;
|
|
21
|
+
// OperatorRuntime caches its own writes; a display observer must read a fresh disk snapshot.
|
|
22
|
+
const run = await new OperatorRuntime(directory, V010_RUNTIME_PROFILE).read(root);
|
|
23
|
+
const units = run?.runID === mission.runID ? run.units : [];
|
|
24
|
+
const running = units.find(unit => unit.status === "running");
|
|
25
|
+
const last = mission.progress.at(-1);
|
|
26
|
+
const status = running ? "running" : last?.status ?? mission.phase;
|
|
27
|
+
const title = running?.unit.title ?? last?.title ?? mission.requirements[0]?.text ?? "Coordinator";
|
|
28
|
+
return { description: `🐾 ${status}: ${title}`, sortie_progress: {
|
|
29
|
+
mission_id: mission.id, phase: mission.phase, unit: running ? `${run.runID}/${running.unit.id}` : last?.unit ?? null,
|
|
30
|
+
title, status, child_session_id: running?.childSessionID ?? null,
|
|
31
|
+
completed_units: units.filter(unit => unit.status === "succeeded").length,
|
|
32
|
+
failed_units: units.filter(unit => unit.status === "failed").length,
|
|
33
|
+
historical_failed_attempts: mission.progress.filter(unit => unit.status === "failed").length,
|
|
34
|
+
accepted: mission.phase === "completed",
|
|
35
|
+
} };
|
|
36
|
+
};
|
|
37
|
+
}
|
package/dist/plugin/profiled.js
CHANGED
|
@@ -10,7 +10,7 @@ import { BUILT_IN_MODEL_CATALOG } from "./model-routing.js";
|
|
|
10
10
|
import { goalFingerprint } from "../core/goal-bound.js";
|
|
11
11
|
import { decoratePreviewHeadings, returnReportPanel } from "./receipt-presentation.js";
|
|
12
12
|
import { sanitizeTerminalReport, terminalRunOutcome } from "./run-metrics.js";
|
|
13
|
-
import {
|
|
13
|
+
import { normalizeCommand } from "./gate.js";
|
|
14
14
|
import { normalizeRelativePath } from "../core/path.js";
|
|
15
15
|
import { MISSION_EVIDENCE_GAP_REVIEW_LIMIT, OperatorMissionRuntime, missionPacket, missionPlan, missionReviewAccepted, missionReviewTask, missionReviewTraces, missionReviewVerdict } from "../core/operator-mission.js";
|
|
16
16
|
import { publishMissionProgress } from "./mission-progress.js";
|
|
@@ -362,6 +362,36 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
362
362
|
state.units.some(unit => unit.status === "running" && unit.callID === callID &&
|
|
363
363
|
/^task_id: (.+)$/m.exec(unit.task.prompt)?.[1] === taskID);
|
|
364
364
|
},
|
|
365
|
+
recoverMissionDispatch: async (root, taskID) => {
|
|
366
|
+
let run = await operators.read(root);
|
|
367
|
+
const unit = run?.units.find(item => /^task_id: (.+)$/m.exec(item.task.prompt)?.[1] === taskID);
|
|
368
|
+
if (!run || !unit?.callID)
|
|
369
|
+
return undefined;
|
|
370
|
+
if (run.phase !== "cancelled") {
|
|
371
|
+
const history = await messages(run.operatorSessionID ?? root);
|
|
372
|
+
const terminal = history.some(message => Array.isArray(message.parts) && message.parts.some(part => record(part) && part.type === "tool" && part.callID === unit.callID && record(part.state) &&
|
|
373
|
+
["completed", "error"].includes(String(part.state.status))));
|
|
374
|
+
if (!terminal)
|
|
375
|
+
return undefined;
|
|
376
|
+
if (unit.childSessionID) {
|
|
377
|
+
const child = payload(await session("get", { path: { id: unit.childSessionID }, query: { directory: input.directory } }));
|
|
378
|
+
if (!record(child) || child.parentID !== (run.operatorSessionID ?? root) ||
|
|
379
|
+
!["succeeded", "failed", "interrupted"].includes(String(child.outcome)))
|
|
380
|
+
return undefined;
|
|
381
|
+
}
|
|
382
|
+
return { callID: unit.callID, ...(unit.childSessionID ? { childSessionID: unit.childSessionID } : {}), cancelled: false };
|
|
383
|
+
}
|
|
384
|
+
await stopCancelledChildren(root, run);
|
|
385
|
+
run = await operators.required(root);
|
|
386
|
+
// A Task rejected before creating a child still has a native terminal record.
|
|
387
|
+
if (!unit.childSessionID) {
|
|
388
|
+
const history = await messages(run.operatorSessionID ?? root);
|
|
389
|
+
if (!history.some(message => Array.isArray(message.parts) && message.parts.some(part => record(part) && part.type === "tool" && part.callID === unit.callID && record(part.state) &&
|
|
390
|
+
["completed", "error"].includes(String(part.state.status)))))
|
|
391
|
+
return undefined;
|
|
392
|
+
}
|
|
393
|
+
return { callID: unit.callID, ...(unit.childSessionID ? { childSessionID: unit.childSessionID } : {}), cancelled: true };
|
|
394
|
+
},
|
|
365
395
|
allowsInvestigativeShell: async (child) => {
|
|
366
396
|
const root = await rootFor(child);
|
|
367
397
|
if (!root)
|
|
@@ -487,7 +517,8 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
487
517
|
if (previous?.phase !== "cancelled" || mission.runID !== null)
|
|
488
518
|
return mission;
|
|
489
519
|
if (mission.supersededRunID !== undefined) {
|
|
490
|
-
|
|
520
|
+
// The predecessor remains archived for costs/results, not as obligations for a new request.
|
|
521
|
+
if (mission.requirementsReplaced || mission.supersededRunID !== previous.runID || !cancelledMissionRetainsAcceptance(previous))
|
|
491
522
|
return mission;
|
|
492
523
|
return missions.carryForward(root, mission.id, previous.acceptance, previous.runID);
|
|
493
524
|
}
|
|
@@ -638,6 +669,37 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
638
669
|
const reopenProposalScope = profileTool(profile, "sortie_reopen_operator_proposal_scope");
|
|
639
670
|
const reconcileOrphan = profileTool(profile, "sortie_reconcile_orphaned_operator_dispatch");
|
|
640
671
|
const reviseApprovedIntent = profileTool(profile, "sortie_revise_approved_operator_intent");
|
|
672
|
+
async function stopCancelledChildren(root, run) {
|
|
673
|
+
if (run.phase !== "cancelled")
|
|
674
|
+
return;
|
|
675
|
+
const children = new Set(run.units.flatMap(unit => unit.childSessionID ? [unit.childSessionID] : []));
|
|
676
|
+
if (run.operatorSessionID) {
|
|
677
|
+
// Older V2 private servers expose get/interrupt but no session.list. The durable
|
|
678
|
+
// dispatch IDs suffice to cancel our work; a missing listing must not veto that operation.
|
|
679
|
+
const nested = payload(await session("children", { path: { id: run.operatorSessionID }, query: { directory: input.directory } }).catch(() => undefined));
|
|
680
|
+
if (Array.isArray(nested))
|
|
681
|
+
for (const child of nested)
|
|
682
|
+
if (record(child) && typeof child.id === "string" &&
|
|
683
|
+
child.parentID === run.operatorSessionID)
|
|
684
|
+
children.add(child.id);
|
|
685
|
+
children.add(run.operatorSessionID);
|
|
686
|
+
}
|
|
687
|
+
for (const child of children) {
|
|
688
|
+
if (run.stoppedChildren?.includes(child))
|
|
689
|
+
continue;
|
|
690
|
+
const who = payload(await session("get", { path: { id: child }, query: { directory: input.directory } }));
|
|
691
|
+
if (!record(who) || who.parentID !== (child === run.operatorSessionID ? root : run.operatorSessionID ?? root)) {
|
|
692
|
+
throw new Error(`mission-cancellation-lineage-unavailable: ${child}`);
|
|
693
|
+
}
|
|
694
|
+
if (!["succeeded", "failed", "interrupted"].includes(String(who.outcome))) {
|
|
695
|
+
const result = await session("abort", { path: { id: child }, query: { directory: input.directory } });
|
|
696
|
+
if (result === undefined || result === false || (record(result) && result.data === false)) {
|
|
697
|
+
throw new Error(`mission-cancellation-stop-unconfirmed: ${child}`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
await operators.recordStoppedChild(root, run.runID, child);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
641
703
|
async function stop(root, reason, retireRoot = true) {
|
|
642
704
|
if (retireRoot)
|
|
643
705
|
retired.add(root);
|
|
@@ -664,6 +726,9 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
664
726
|
const result = await session("abort", { path: { id: child }, query: { directory: input.directory } });
|
|
665
727
|
if (result === undefined || result === false || (record(result) && result.data === false))
|
|
666
728
|
throw new Error("operator-child-stop-unconfirmed");
|
|
729
|
+
const run = await operators.read(root);
|
|
730
|
+
if (run)
|
|
731
|
+
await operators.recordStoppedChild(root, run.runID, child);
|
|
667
732
|
}
|
|
668
733
|
await control?.cancelChildren(root);
|
|
669
734
|
if (retireRoot)
|
|
@@ -733,6 +798,11 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
733
798
|
if (!root)
|
|
734
799
|
throw new Error("operator-grant-invalid");
|
|
735
800
|
const activeMission = await reconcileMissionDispatch(root);
|
|
801
|
+
if (activeMission && !["cancelled", "completed"].includes(activeMission.phase) && activeMission.runID === null && context.sessionID !== root) {
|
|
802
|
+
const budget = await control.currentBudget(root);
|
|
803
|
+
return JSON.stringify({ ...missionDispatchPacket(activeMission, await operators.read(root)), budget,
|
|
804
|
+
next_action: "This mission has no unit plan yet. Call plan_units with the next useful work under the current requirements; prior run results are historical." });
|
|
805
|
+
}
|
|
736
806
|
if (activeMission && context.sessionID === root && !activeMission.dispatchOpen &&
|
|
737
807
|
["open", "running"].includes(activeMission.phase)) {
|
|
738
808
|
return JSON.stringify({ ...missionDispatchPacket(activeMission, await operators.read(root)), budget: await control.currentBudget(root) });
|
|
@@ -786,9 +856,10 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
786
856
|
const mission = root && context.sessionID === root ? await reconcileMissionDispatch(root) : root ? await missions.read(root) : undefined;
|
|
787
857
|
// Observation is available to every owned role. Only the root reconciles dispatch above.
|
|
788
858
|
if (mission) {
|
|
859
|
+
const budget = await control.currentBudget(root);
|
|
789
860
|
const run = await operators.read(root);
|
|
790
861
|
const completion = run?.phase === "awaiting-acceptance" ? await control.completionReadiness(root) : undefined;
|
|
791
|
-
return JSON.stringify({ ...missionDispatchPacket(mission, run), budget
|
|
862
|
+
return JSON.stringify({ ...missionDispatchPacket(mission, run), budget,
|
|
792
863
|
...(completion ? { completion, ...(!completion.ready ? { next_action: completion.blockers.map(item => item.next_action).join("\n") } : {}) } : {}) });
|
|
793
864
|
}
|
|
794
865
|
if (root && context.sessionID !== root) {
|
|
@@ -1226,20 +1297,23 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1226
1297
|
} };
|
|
1227
1298
|
const startMission = `${profile.toolPrefix}start_mission`, planUnits = `${profile.toolPrefix}plan_units`, reviewMission = `${profile.toolPrefix}review_mission`, submitMission = `${profile.toolPrefix}submit_mission`, completeMission = `${profile.toolPrefix}complete_mission`, expandUnit = `${profile.toolPrefix}expand_unit`;
|
|
1228
1299
|
const stringList = { type: "array", items: { type: "string" } };
|
|
1229
|
-
tools[startMission] = { description: "Operator: save
|
|
1300
|
+
tools[startMission] = { description: "Operator: save the current user requirements. Use intent=replace when the user changes an existing request (version, parallelism, target): host cancels the previous run and archives its requirements/results while retaining spend. Supply the complete current requirements, retaining constraints the user has not changed. For separate work in a different location use intent=new. Dispatch the Coordinator immediately, or plan_units for a known single-unit procedure; item count and runtime alone do not require a Coordinator.",
|
|
1230
1301
|
args: { requirements: { ...stringList, minItems: 1, maxItems: 64 },
|
|
1231
|
-
intent: { type: "string", enum: ["", "continue", "new"], "x-sortie-optional": true } }, execute: async (args, context) => {
|
|
1302
|
+
intent: { type: "string", enum: ["", "continue", "new", "replace"], "x-sortie-optional": true } }, execute: async (args, context) => {
|
|
1232
1303
|
await requireRoot(context.sessionID);
|
|
1233
|
-
if (args.intent !== undefined && !["", "continue", "new"].includes(args.intent))
|
|
1304
|
+
if (args.intent !== undefined && !["", "continue", "new", "replace"].includes(args.intent))
|
|
1234
1305
|
throw new Error("mission-intent-invalid");
|
|
1235
1306
|
return serializeDispatchTransition(context.sessionID, async () => {
|
|
1236
1307
|
const existing = await reconcileMissionDispatch(context.sessionID);
|
|
1308
|
+
if (args.intent === "replace" && existing && !["completed", "cancelled"].includes(existing.phase)) {
|
|
1309
|
+
await stop(context.sessionID, "explicit-cancellation", false);
|
|
1310
|
+
}
|
|
1237
1311
|
if (args.intent !== "new" && (!existing || ["completed", "cancelled"].includes(existing.phase))) {
|
|
1238
1312
|
const relocated = await relocatedMission(context.sessionID);
|
|
1239
1313
|
if (relocated)
|
|
1240
1314
|
return JSON.stringify({ ...relocated, budget: await control.currentBudget(context.sessionID) });
|
|
1241
1315
|
}
|
|
1242
|
-
const mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements));
|
|
1316
|
+
const mission = await retainCancelledMissionAcceptance(context.sessionID, await missions.start(context.sessionID, args.requirements, args.intent === "replace" || args.intent === "new"));
|
|
1243
1317
|
return JSON.stringify({ ...locationObservation(context.sessionID), ...(mission.dispatchOpen
|
|
1244
1318
|
? missionDispatchPacket(mission, await operators.read(context.sessionID))
|
|
1245
1319
|
: { mission_id: mission.id, requirements: mission.requirements, task: missions.task(mission),
|
|
@@ -1270,12 +1344,25 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1270
1344
|
previous.units.some(unit => (previous.decision === "agent-changed" || unit.status === "cancelled") && unit.childSessionID !== null)));
|
|
1271
1345
|
const terminalChildren = cancelledPredecessor
|
|
1272
1346
|
? await terminalCancelledMissionChildren(profile, root, previous, budget, {
|
|
1273
|
-
get: async (id) =>
|
|
1274
|
-
|
|
1347
|
+
get: async (id) => {
|
|
1348
|
+
const value = payload(await session("get", { path: { id }, query: { directory: input.directory } }));
|
|
1349
|
+
const stopped = (await operators.required(root)).stoppedChildren?.includes(id);
|
|
1350
|
+
return record(value) && stopped ? { ...value, outcome: "interrupted" } : value;
|
|
1351
|
+
},
|
|
1352
|
+
children: async (id) => {
|
|
1353
|
+
try {
|
|
1354
|
+
return payload(await session("children", { path: { id }, query: { directory: input.directory } }));
|
|
1355
|
+
}
|
|
1356
|
+
catch {
|
|
1357
|
+
const run = await operators.required(root);
|
|
1358
|
+
// The cancellation acknowledgements are scoped to this exact durable run.
|
|
1359
|
+
return id === run.operatorSessionID ? run.units.flatMap(unit => unit.childSessionID ? [{ id: unit.childSessionID }] : []) : [];
|
|
1360
|
+
}
|
|
1361
|
+
},
|
|
1275
1362
|
}) : [];
|
|
1276
1363
|
const dispatcher = actor === root ? undefined : { sessionID: actor, callID: mission.callID };
|
|
1277
1364
|
const state = replanning ? await operators.replanMission(root, previous.runID, plan, dispatcher)
|
|
1278
|
-
: await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren);
|
|
1365
|
+
: await operators.prepareMission(root, plan, dispatcher, mission.supersededRunID, terminalChildren, mission.requirementsReplaced);
|
|
1279
1366
|
await control.registerGoalDeclaration(root, state.units[0].task.prompt, true);
|
|
1280
1367
|
control.enableUnits(root, state.units.filter(unit => unit.status === "pending").length);
|
|
1281
1368
|
await missions.update(root, item => {
|
|
@@ -1290,7 +1377,8 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1290
1377
|
}
|
|
1291
1378
|
tools[planUnits] = { description: "Coordinator (or single-unit Fast-lane Operator): declare useful units, then dispatch the returned Worker immediately. Host generates IDs, handoff, manifest and proof mapping. Keep every original requirement covered. Use write: [] for read-only verification; use dir/** for directory outputs including not-yet-created trees. Native absolute paths support global installs and external outputs under host permissions; include their actual paths in read/write for evidence. Final validation command in each unit proves that unit; read-only diagnostic commands need no registration. Recalling with reason replaces settled work within unchanged requirements and cumulative budget; include required scope extensions here. Rejected budget, contract or control-storage preparation preserves the existing run so you can correct the plan directly.",
|
|
1292
1379
|
args: { units: { type: "array", minItems: 1, maxItems: 32, items: { type: "object", additionalProperties: false,
|
|
1293
|
-
properties: { title: { type: "string" }, objective: { type: "string" },
|
|
1380
|
+
properties: { title: { type: "string" }, objective: { type: "string" },
|
|
1381
|
+
read: { ...stringList, description: "Inputs that affect validation, not an allowlist for observation. Do not include whole live session/database/log trees just to inspect them." }, write: stringList,
|
|
1294
1382
|
validation: stringList, requirement_ids: stringList }, required: ["title", "objective", "write", "validation"] } },
|
|
1295
1383
|
reason: { type: "string", "x-sortie-optional": true } }, execute: async (args, context) => {
|
|
1296
1384
|
const { root, mission } = await missionAuthority(context.sessionID);
|
|
@@ -1669,9 +1757,8 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
1669
1757
|
if (["read", "glob", "grep", "list", "execute", "todowrite", "todoread", "webfetch", "websearch", "skill"].includes(request.tool))
|
|
1670
1758
|
return;
|
|
1671
1759
|
if (["bash", "shell"].includes(request.tool)) {
|
|
1672
|
-
|
|
1673
|
-
|
|
1674
|
-
throw new Error("mission-coordinator-shell-readonly: delegate source changes to Worker");
|
|
1760
|
+
// Native shell permissions own execution. Parsing shell syntax cannot enforce read-only
|
|
1761
|
+
// behavior (interpreter bodies were already opaque); the role still delegates source edits.
|
|
1675
1762
|
return;
|
|
1676
1763
|
}
|
|
1677
1764
|
throw new Error("mission-coordinator-no-edit-tool");
|
|
@@ -2025,6 +2112,11 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
2025
2112
|
(output.system ??= []).push(`SORTIE_RUNTIME_PROFILE ${profile.id}; marker ${assetVersion}. ` +
|
|
2026
2113
|
`Shared MkII protocol role names are logical: ${protocolMap}. Use only ${profile.toolPrefix} tools for this profile. ` +
|
|
2027
2114
|
"Never rewrite user acceptance or evidence to rename protocol roles. Final acceptance belongs only to the root coordinator.");
|
|
2115
|
+
if ((await identity(request.sessionID)).role === "dog-worker") {
|
|
2116
|
+
const context = await operators.workerContext(root, request.sessionID);
|
|
2117
|
+
if (context)
|
|
2118
|
+
(output.system ??= []).push(context);
|
|
2119
|
+
}
|
|
2028
2120
|
const proposal = await proposals.read(root);
|
|
2029
2121
|
if (proposal?.phase !== "approved" && proposal?.proposal_session_id === request.sessionID) {
|
|
2030
2122
|
// Only immutable identity and the frozen budget caps belong here. Consumed counters move with
|
|
@@ -2079,6 +2171,12 @@ export function createProfiledPlugin(profile, assetVersion) {
|
|
|
2079
2171
|
const root = await rootFor(request.sessionID);
|
|
2080
2172
|
if (!root)
|
|
2081
2173
|
return;
|
|
2174
|
+
if ((await identity(request.sessionID)).role === "dog-worker") {
|
|
2175
|
+
const context = await operators.workerContext(root, request.sessionID);
|
|
2176
|
+
if (context)
|
|
2177
|
+
(output.context ??= []).push(context +
|
|
2178
|
+
" Preserve the actual edits, tool outcomes and next action in the summary; do not replace them with an empty-work claim.");
|
|
2179
|
+
}
|
|
2082
2180
|
if ((await identity(request.sessionID)).role === "dog-operator") {
|
|
2083
2181
|
const mission = await missions.read(root);
|
|
2084
2182
|
if (mission?.coordinator === request.sessionID) {
|
|
@@ -31,6 +31,12 @@ export interface RuntimeBridge {
|
|
|
31
31
|
ownsCanonicalValidation?(rootSessionID: string, unitID: string, childSessionID: string, command: string): Promise<boolean>;
|
|
32
32
|
/** A mission's hash-pinned Task has already passed durable run/acceptance admission. */
|
|
33
33
|
ownsMissionDispatch?(rootSessionID: string, callID: string, taskID: string): Promise<boolean>;
|
|
34
|
+
/** Durable operator dispatch identity survives adapter reload and missed after hooks. */
|
|
35
|
+
recoverMissionDispatch?(rootSessionID: string, taskID: string): Promise<{
|
|
36
|
+
callID: string;
|
|
37
|
+
childSessionID?: string;
|
|
38
|
+
cancelled: boolean;
|
|
39
|
+
} | undefined>;
|
|
34
40
|
onHostHandoffRepaired?(rootSessionID: string, taskID: string, handoffPath: string, original: string, repaired: string): Promise<void>;
|
|
35
41
|
allowsInvestigativeShell?(sessionID: string): Promise<boolean>;
|
|
36
42
|
onSerialSettlement?(settlement: SerialDispatchSettlement): Promise<void>;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { estimateModelUsageCost } from "./model-cost.js";
|
|
2
|
+
const record = (value) => !!value && typeof value === "object" && !Array.isArray(value);
|
|
3
|
+
/** Price complete native requests; absent/interrupted usage is unknown, never a fabricated zero. */
|
|
4
|
+
export function settledUnitUsage(messages, startedAt) {
|
|
5
|
+
let cost = 0, requests = 0;
|
|
6
|
+
const seen = new Set();
|
|
7
|
+
for (const message of messages) {
|
|
8
|
+
if (!record(message) || !record(message.info) || message.info.role !== "assistant")
|
|
9
|
+
continue;
|
|
10
|
+
const info = message.info;
|
|
11
|
+
if (typeof info.id !== "string" || seen.has(info.id))
|
|
12
|
+
continue;
|
|
13
|
+
seen.add(info.id);
|
|
14
|
+
const time = record(info.time) ? info.time : undefined;
|
|
15
|
+
if (startedAt) {
|
|
16
|
+
if (typeof time?.created !== "number")
|
|
17
|
+
return null;
|
|
18
|
+
if (time.created < Date.parse(startedAt))
|
|
19
|
+
continue;
|
|
20
|
+
}
|
|
21
|
+
if (typeof time?.completed !== "number")
|
|
22
|
+
return null;
|
|
23
|
+
const tokens = record(info.tokens) ? info.tokens : undefined;
|
|
24
|
+
const cache = record(tokens?.cache) ? tokens.cache : undefined;
|
|
25
|
+
if (info.error && [tokens?.input, tokens?.output, tokens?.reasoning, cache?.read, cache?.write]
|
|
26
|
+
.every(value => value === 0 || value === undefined))
|
|
27
|
+
return null;
|
|
28
|
+
const estimate = estimateModelUsageCost({
|
|
29
|
+
providerID: typeof info.providerID === "string" ? info.providerID : undefined,
|
|
30
|
+
modelID: typeof info.modelID === "string" ? info.modelID : undefined,
|
|
31
|
+
uncachedInputTokens: typeof tokens?.input === "number" ? tokens.input : undefined,
|
|
32
|
+
outputTokens: typeof tokens?.output === "number" ? tokens.output : undefined,
|
|
33
|
+
reasoningTokens: typeof tokens?.reasoning === "number" ? tokens.reasoning : undefined,
|
|
34
|
+
cacheReadTokens: typeof cache?.read === "number" ? cache.read : undefined,
|
|
35
|
+
cacheWriteTokens: typeof cache?.write === "number" ? cache.write : undefined,
|
|
36
|
+
serviceTier: typeof info.serviceTier === "string" ? info.serviceTier : undefined,
|
|
37
|
+
});
|
|
38
|
+
if (estimate.status !== "priced")
|
|
39
|
+
return null;
|
|
40
|
+
requests++;
|
|
41
|
+
cost += estimate.usd;
|
|
42
|
+
}
|
|
43
|
+
return requests ? cost : null;
|
|
44
|
+
}
|
package/dist/plugin/v2.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { SortieDogsV010Plugin } from "./profiled.js";
|
|
2
|
-
import { bindMissionProgress } from "./mission-progress.js";
|
|
2
|
+
import { bindMissionProgress, missionProgressReader } from "./mission-progress.js";
|
|
3
3
|
import { owningServiceSessionList } from "./v2-session-history.js";
|
|
4
4
|
import { createHash } from "node:crypto";
|
|
5
5
|
import { readFileSync } from "node:fs";
|
|
@@ -332,18 +332,50 @@ async function registerV2Hooks(context, hooks) {
|
|
|
332
332
|
return execute(value, execution);
|
|
333
333
|
const report = execution.progress;
|
|
334
334
|
let metadata = {};
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
335
|
+
let pending = Promise.resolve();
|
|
336
|
+
const update = (update) => {
|
|
337
|
+
metadata = { ...metadata, ...update };
|
|
338
|
+
const snapshot = { ...metadata };
|
|
339
|
+
pending = pending.catch(() => undefined).then(() => report(snapshot));
|
|
340
|
+
return pending;
|
|
341
|
+
};
|
|
342
|
+
const unbind = bindMissionProgress(String(execution.sessionID), update);
|
|
343
|
+
const readProgress = missionProgressReader(context.location.directory, String(execution.sessionID), String(execution.id));
|
|
344
|
+
let previous = "", reading;
|
|
345
|
+
const refresh = () => {
|
|
346
|
+
if (reading)
|
|
347
|
+
return reading;
|
|
348
|
+
reading = (async () => {
|
|
349
|
+
try {
|
|
350
|
+
const progress = await readProgress(), key = JSON.stringify(progress);
|
|
351
|
+
if (progress && key !== previous) {
|
|
352
|
+
await update(progress);
|
|
353
|
+
previous = key;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
console.warn("[sortie-dogs-v010] mission progress unavailable", error instanceof Error ? error.message : "unknown");
|
|
358
|
+
}
|
|
359
|
+
})().finally(() => { reading = undefined; });
|
|
360
|
+
return reading;
|
|
361
|
+
};
|
|
362
|
+
// The native executor is a stable snapshot. Poll only local durable state, not models or
|
|
363
|
+
// session status APIs; this also recovers progress after another plugin instance settles work.
|
|
364
|
+
const timer = setInterval(() => { void refresh(); }, 1000);
|
|
365
|
+
timer.unref();
|
|
339
366
|
try {
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
367
|
+
await refresh();
|
|
368
|
+
const result = await execute(value, { ...execution, progress: update });
|
|
369
|
+
await refresh();
|
|
370
|
+
await pending.catch(() => undefined);
|
|
371
|
+
// Native completion replaces metadata, so retain our display fields alongside its own.
|
|
372
|
+
return { ...result, metadata: { ...metadata, ...(record(result.metadata) ? result.metadata : {}) } };
|
|
344
373
|
}
|
|
345
374
|
finally {
|
|
375
|
+
clearInterval(timer);
|
|
346
376
|
unbind();
|
|
377
|
+
await reading;
|
|
378
|
+
await pending.catch(() => undefined);
|
|
347
379
|
}
|
|
348
380
|
};
|
|
349
381
|
});
|
|
@@ -39,7 +39,7 @@ ${controls(profile, ["start_mission", "plan_units", "operator_next", "operator_s
|
|
|
39
39
|
---
|
|
40
40
|
# ${profileAgent(profile, "dog-coordinator")}
|
|
41
41
|
|
|
42
|
-
You are Operator, the user-facing strategic authority. Preserve every
|
|
42
|
+
You are Operator, the user-facing strategic authority. Preserve every current requirement, prohibition,
|
|
43
43
|
quality threshold and explicit model/budget choice. Follow AGENTS.md and use the user's language.
|
|
44
44
|
|
|
45
45
|
1. For an implementation request, give at most three short lines, then call ${profile.toolPrefix}start_mission
|
|
@@ -49,7 +49,10 @@ quality threshold and explicit model/budget choice. Follow AGENTS.md and use the
|
|
|
49
49
|
the host session_move operation to its resume_location.directory and read status there; do not create
|
|
50
50
|
a substitute mission in the current worktree. Multiple candidates are selected by the user's request.
|
|
51
51
|
For intentionally separate work in a new location, start_mission(intent: "new") keeps other missions
|
|
52
|
-
|
|
52
|
+
intact and retains cumulative spend. Choose from the user's intent; no routine approval round trip.
|
|
53
|
+
When the user replaces a version, target, parallelism or other requirement, call start_mission with
|
|
54
|
+
intent: "replace" and the complete current requirements. The host cancels/archives the old run and
|
|
55
|
+
retains spend/results; superseded instructions are history, not additional obligations.
|
|
53
56
|
2. Dispatch the returned ${profileAgent(profile, "dog-operator")} task immediately. It owns investigation,
|
|
54
57
|
unit boundaries, Worker/Scout/Advisor/independent Reviewer calls, write-scope extensions and corrections
|
|
55
58
|
within the original request and cumulative budget. Do not investigate or approve each unit at the root.
|
|
@@ -58,12 +61,16 @@ quality threshold and explicit model/budget choice. Follow AGENTS.md and use the
|
|
|
58
61
|
(or the host accepted it at the evidence-gap limit, with the gaps reported), call
|
|
59
62
|
${profile.toolPrefix}complete_mission. Only its succeeded receipt authorizes DONE.
|
|
60
63
|
|
|
61
|
-
Fast-lane
|
|
64
|
+
Fast-lane: Simple known procedures that one Worker can complete, with known scope and meaningful validation, may use
|
|
62
65
|
${profile.toolPrefix}plan_units directly after start_mission, then dispatch its exact Worker task.
|
|
63
66
|
Use title, objective, read/write file or directory scopes, and validation commands. The final command
|
|
64
67
|
proves the unit; investigation commands need no registration. After success, record the low-risk review
|
|
65
68
|
skip with review_mission (risk_tags: [], one concise trace per requirement), then complete_mission.
|
|
66
69
|
Everything else goes through Coordinator. Unit start or a passing tiny task is never whole-task completion.
|
|
70
|
+
Item count, parallelism inside an existing runner, or long duration alone do not require Coordinator.
|
|
71
|
+
For example, a configured 23-case benchmark run can be one unit. A subsequent result-dependent
|
|
72
|
+
reproduce/fix/PR loop needs Coordinator, which should start the known runner promptly and use actual
|
|
73
|
+
results to guide the following units. Do not invent preparation units or plan-approval rounds.
|
|
67
74
|
|
|
68
75
|
Copy returned task fields exactly (V2: subagent_type -> agent, task_id -> sessionID). Do not append to a
|
|
69
76
|
reference prompt or name another model unless the user explicitly selected it. Preserve explicit selections.
|
|
@@ -110,10 +117,20 @@ You are Coordinator. Own most of the practical work and dispatch within the save
|
|
|
110
117
|
Read/search and confirmation shell commands are available; source edits belong to Worker. Do not edit
|
|
111
118
|
through shell. There is no proposal/approval/contract-repair round trip in this route.
|
|
112
119
|
|
|
120
|
+
For release/benchmark work, resolve the package from its release receipt and hash, not a matching version
|
|
121
|
+
or filename. Record the runner's own revision separately. Before repairing infrastructure on an old branch,
|
|
122
|
+
check current main for an existing fix; preserve local edits and use a current-main worktree when needed.
|
|
123
|
+
Report setup/route failures as such, with observed inference count, instead of calling runner exits a score.
|
|
124
|
+
|
|
113
125
|
Investigate only enough to start the first useful Worker. Prefer a targeted read/reproduction over a broad
|
|
114
126
|
inventory or speculative full design. Call ${profile.toolPrefix}plan_units with concise units:
|
|
115
127
|
title, objective, read/write file or directory scopes, validation commands, optionally requirement_ids.
|
|
116
128
|
For read-only verification, use write: []; do not invent an output file or request write access to inputs.
|
|
129
|
+
For ordinary diagnostics, use native read/search/shell directly, including while an old run is being
|
|
130
|
+
reconciled. Do not create a dummy validation/console.log unit just to inspect status. The read list is
|
|
131
|
+
the input set whose bytes affect the unit's validation, not every directory you may inspect. Keep live
|
|
132
|
+
session databases, logs and transient progress outside that proof input set unless they are the artifact
|
|
133
|
+
actually being validated. Native host permissions continue to govern observation.
|
|
117
134
|
Use absolute native paths for requested global installations or other external outputs; dir/** declares
|
|
118
135
|
a directory including a not-yet-created tree. These are execution/evidence scopes, not an additional
|
|
119
136
|
permission grant: the host's native permissions still apply. Include the actual external input/output
|