@deksden-com/dd-flow-cli 0.9.0-beta.124 → 0.9.0-beta.127
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/CHANGELOG.md +16 -0
- package/README.md +10 -1
- package/dist/build-info.json +7 -7
- package/dist/cli/argument-values.js +38 -0
- package/dist/cli/command-inputs.js +4 -3
- package/dist/cli/help.js +1 -0
- package/dist/cli/hook-ingress.js +1 -1
- package/dist/cli/run-cli.js +126 -193
- package/dist/cli/runtime-maintenance-diagnostics.js +46 -0
- package/dist/cli/runtime-maintenance-ingress.js +51 -0
- package/dist/cli/runtime-process.js +128 -0
- package/dist/cli.js +8 -5
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -2
- package/dist/harness-runtime/bin/dd-codex.mjs +3 -2
- package/dist/harness-runtime/bin/dd-droid.mjs +3 -2
- package/dist/harness-runtime/bin/dd-grok.mjs +16 -6
- package/dist/harness-runtime/bin/dd-opencode.mjs +3 -2
- package/dist/harness-runtime/bin/dd-zcode.mjs +20 -16
- package/dist/harness-runtime/lib/adapter-timeouts.d.mts +5 -0
- package/dist/harness-runtime/lib/adapter-timeouts.mjs +34 -0
- package/dist/harness-runtime/lib/daemon-observation.d.mts +2 -0
- package/dist/harness-runtime/lib/daemon-observation.mjs +163 -0
- package/dist/harness-runtime/lib/daemon-operations.mjs +60 -13
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +151 -72
- package/dist/harness-runtime/lib/dd-agy.mjs +11 -23
- package/dist/harness-runtime/lib/dd-codex-daemon.mjs +9 -14
- package/dist/harness-runtime/lib/dd-codex.mjs +252 -80
- package/dist/harness-runtime/lib/dd-droid-daemon.mjs +14 -19
- package/dist/harness-runtime/lib/dd-droid.mjs +117 -31
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +56 -30
- package/dist/harness-runtime/lib/dd-grok.mjs +15 -13
- package/dist/harness-runtime/lib/dd-opencode-daemon.mjs +18 -27
- package/dist/harness-runtime/lib/dd-opencode.mjs +44 -14
- package/dist/harness-runtime/lib/dd-zcode-daemon.mjs +56 -53
- package/dist/harness-runtime/lib/dd-zcode.mjs +200 -111
- package/dist/harness-runtime/lib/lease-renewal.d.mts +8 -0
- package/dist/harness-runtime/lib/lease-renewal.mjs +23 -0
- package/dist/harness-runtime/lib/lifecycle-command.d.mts +3 -3
- package/dist/harness-runtime/lib/lifecycle-command.mjs +74 -20
- package/dist/harness-runtime/lib/managed-daemon.d.mts +10 -1
- package/dist/harness-runtime/lib/managed-daemon.mjs +228 -40
- package/dist/harness-runtime/lib/native-children.d.mts +13 -0
- package/dist/harness-runtime/lib/native-children.mjs +78 -0
- package/dist/harness-runtime/lib/native-hook-command.d.mts +4 -0
- package/dist/harness-runtime/lib/native-hook-command.mjs +115 -11
- package/dist/harness-runtime/lib/native-streams.mjs +45 -0
- package/dist/harness-runtime/lib/observation-clock.d.mts +18 -0
- package/dist/harness-runtime/lib/observation-clock.mjs +73 -17
- package/dist/harness-runtime/lib/operation-errors.d.mts +14 -0
- package/dist/harness-runtime/lib/operation-errors.mjs +105 -8
- package/dist/harness-runtime/lib/operation-progress.d.mts +7 -0
- package/dist/harness-runtime/lib/operation-progress.mjs +112 -0
- package/dist/harness-runtime/lib/process-json.mjs +136 -38
- package/dist/harness-runtime/lib/tool-observations.d.mts +1 -0
- package/dist/harness-runtime/lib/tool-observations.mjs +5 -2
- package/dist/harness-runtime/lib/zcode-usage.mjs +3 -2
- package/dist/runtime/context.js +8 -4
- package/dist/schemas/run-control-receipt.schema.json +19 -0
- package/dist/services/code-checks.js +79 -9
- package/dist/services/codex-capacity.js +117 -41
- package/dist/services/codex-hook-delivery.js +2 -2
- package/dist/services/compatibility-preflight.js +4 -1
- package/dist/services/controller-fanout.js +9 -76
- package/dist/services/engines.js +70 -20
- package/dist/services/eval-snapshots.js +179 -40
- package/dist/services/external-work-launch.js +13 -8
- package/dist/services/harness-adapter.js +175 -11
- package/dist/services/hooks.js +65 -21
- package/dist/services/lifecycle-invocations.js +404 -32
- package/dist/services/managed-lease-monitor.js +128 -0
- package/dist/services/managed-processes.js +103 -47
- package/dist/services/merge-server.js +58 -21
- package/dist/services/recovery-observation-budget.js +67 -7
- package/dist/services/recovery-snapshot-database.js +7 -4
- package/dist/services/run-control-receipt.js +1 -1
- package/dist/services/run-control-worker.js +177 -118
- package/dist/services/run-control.js +99 -8
- package/dist/services/run-controller-adapter.js +169 -46
- package/dist/services/run-controller-capture.js +14 -2
- package/dist/services/run-controller-process.js +11 -4
- package/dist/services/run-controller-state.js +68 -7
- package/dist/services/run-controller.js +166 -94
- package/dist/services/run-fork.js +8 -3
- package/dist/services/run-recovery-guard.js +75 -0
- package/dist/services/run-recovery-runtime.js +1 -56
- package/dist/services/run-recovery.js +17 -13
- package/dist/services/runs.js +39 -6
- package/dist/services/runtime-budget.js +32 -15
- package/dist/services/runtime-scope-control.js +8 -0
- package/dist/services/runtime-scope-resume.js +30 -8
- package/dist/services/runtime-scope-worker.js +118 -73
- package/dist/services/runtime-service.js +6 -7
- package/dist/services/scope-writer-cleanup.js +3 -2
- package/dist/services/stage-context.js +135 -0
- package/dist/services/stage-lifecycle.js +3 -15
- package/dist/services/stage-pause.js +3 -2
- package/dist/services/usage.js +14 -3
- package/dist/services/vnext-code-review.js +10 -9
- package/dist/services/vnext-code.js +10 -8
- package/dist/services/vnext-contracts.js +10 -1
- package/dist/services/vnext-execution-profile.js +75 -2
- package/dist/services/vnext-fanout.js +5 -0
- package/dist/services/vnext-merge.js +10 -9
- package/dist/services/vnext-plan-review.js +21 -17
- package/dist/services/vnext-plan.js +9 -7
- package/dist/services/vnext-protocolize.js +15 -9
- package/dist/services/vnext-specify.js +6 -6
- package/dist/services/work-registry.js +34 -4
- package/dist/services/workspace-bootstrap.js +14 -3
- package/dist/shared/errors.js +11 -6
- package/dist/storage/agent-output.js +26 -0
- package/dist/storage/database.js +80 -21
- package/package.json +7 -4
|
@@ -3,7 +3,7 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { isDeepStrictEqual } from "node:util";
|
|
5
5
|
import { createContext } from "../runtime/context.js";
|
|
6
|
-
import { AppError } from "../shared/errors.js";
|
|
6
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
7
7
|
import { getResourceDatabase } from "../storage/database.js";
|
|
8
8
|
import { managedProcessBinding, processIsAlive, processTreeIsAlive, resourceHome } from "./managed-processes.js";
|
|
9
9
|
import { runtimeBudgetStatus, scopeLocalProcessRole, validateRetainedRuntimeScopeManifest, prepareRuntimeScopeFence } from "./runtime-budget.js";
|
|
@@ -13,6 +13,9 @@ import { prepareControllerResume, resumeRunController } from "./run-controller-r
|
|
|
13
13
|
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
14
14
|
import { assertHistoricalDaemonSettlement } from "./native-daemon-history.js";
|
|
15
15
|
import { cleanupScopeWriters } from "./scope-writer-cleanup.js";
|
|
16
|
+
import { harnessKey } from "./harness-config.js";
|
|
17
|
+
import { canonicalNativeOperation } from "../harness-runtime/lib/adapter-timeouts.mjs";
|
|
18
|
+
import { retainedDaemonReply } from "../harness-runtime/lib/daemon-observation.mjs";
|
|
16
19
|
const hash = (value) => crypto.createHash("sha256").update(value).digest("hex");
|
|
17
20
|
const nativeLaunchEnvironmentKeys = ["CODEX_HOME", "DD_FLOW_CONFIG_HOME", "DD_FLOW_HOME", "DD_FLOW_BIN", "DD_FLOW_RESOURCE_HOME"];
|
|
18
21
|
function nativeLaunchEnvironment(context, saved) {
|
|
@@ -36,6 +39,7 @@ function nativeLaunchEnvironment(context, saved) {
|
|
|
36
39
|
export async function prepareRuntimeScopeResume(context, scopeId, generation, releaseRequestId) {
|
|
37
40
|
const db = getResourceDatabase(resourceHome(context));
|
|
38
41
|
const assertCurrent = () => {
|
|
42
|
+
context.assertControlOwner?.();
|
|
39
43
|
const latest = runtimeBudgetStatus(context, scopeId);
|
|
40
44
|
if (latest.fence || latest.control?.generation !== generation)
|
|
41
45
|
throw new AppError("runtime_scope_control_superseded", "Scope resume preparation lost its generation", 1);
|
|
@@ -76,7 +80,7 @@ export async function prepareRuntimeScopeResume(context, scopeId, generation, re
|
|
|
76
80
|
runs.push({ ...source, receipt });
|
|
77
81
|
}
|
|
78
82
|
catch (error) {
|
|
79
|
-
runs.push({ ...source, pending: true, reason: error instanceof AppError ? error.code : "runtime_scope_resume_failed", message: String(error) });
|
|
83
|
+
runs.push({ ...source, pending: true, reason: error instanceof AppError ? error.code : "runtime_scope_resume_failed", message: String(error), error: errorRecord(error) });
|
|
80
84
|
}
|
|
81
85
|
finally {
|
|
82
86
|
target?.db.close?.();
|
|
@@ -92,7 +96,8 @@ export async function prepareRuntimeScopeResume(context, scopeId, generation, re
|
|
|
92
96
|
native.push(restarting || retired?.status === "retired" ? await restartScopeNative(context, intent, source, assertCurrent) : retired);
|
|
93
97
|
}
|
|
94
98
|
catch (error) {
|
|
95
|
-
|
|
99
|
+
const failure = errorRecord(error);
|
|
100
|
+
native.push({ ...source, pending: true, reason: failure.code === "unexpected" ? "runtime_scope_resume_failed" : failure.code, message: String(error), error: failure });
|
|
96
101
|
}
|
|
97
102
|
}
|
|
98
103
|
assertCurrent();
|
|
@@ -101,6 +106,7 @@ export async function prepareRuntimeScopeResume(context, scopeId, generation, re
|
|
|
101
106
|
const lockedRuns = new Map(), heldRuns = [];
|
|
102
107
|
let registryHeld = false, runPreparationConsistent = false;
|
|
103
108
|
let released = null, releaseError = null, releaseMessage = null;
|
|
109
|
+
let releaseFailure = null;
|
|
104
110
|
let cleanupErrors = [];
|
|
105
111
|
try {
|
|
106
112
|
for (const home of [...new Set(intent.runs.map((source) => source.runtime_home))].sort())
|
|
@@ -134,8 +140,9 @@ export async function prepareRuntimeScopeResume(context, scopeId, generation, re
|
|
|
134
140
|
catch (error) {
|
|
135
141
|
releaseError = error instanceof AppError ? error.code : "scope_writer_boundary_unavailable";
|
|
136
142
|
releaseMessage = error instanceof Error ? error.message : String(error);
|
|
143
|
+
releaseFailure = errorRecord(error);
|
|
137
144
|
for (const [index, run] of runs.entries())
|
|
138
|
-
runs[index] = { ...run, prepared: false, pending: true, reason: error instanceof AppError ? error.code : "scope_writer_boundary_unavailable" };
|
|
145
|
+
runs[index] = { ...run, prepared: false, pending: true, reason: error instanceof AppError ? error.code : "scope_writer_boundary_unavailable", error: releaseFailure };
|
|
139
146
|
}
|
|
140
147
|
finally {
|
|
141
148
|
cleanupErrors = cleanupScopeWriters([
|
|
@@ -146,9 +153,9 @@ export async function prepareRuntimeScopeResume(context, scopeId, generation, re
|
|
|
146
153
|
if (released)
|
|
147
154
|
return { ...released, status: "released", dispatch_blocked: false, current: true, settled: false, reused: false, ...(cleanupErrors.length ? { cleanup_errors: cleanupErrors } : {}) };
|
|
148
155
|
if (cleanupErrors.length)
|
|
149
|
-
throw new AppError(releaseError ?? "scope_writer_cleanup_failed", releaseMessage ?? "Scope preparation cleanup failed", 1, { scope_cleanup_errors: cleanupErrors });
|
|
156
|
+
throw new AppError(releaseError ?? "scope_writer_cleanup_failed", releaseMessage ?? "Scope preparation cleanup failed", 1, { ...(releaseFailure ? { cause: releaseFailure } : {}), scope_cleanup_errors: cleanupErrors });
|
|
150
157
|
assertCurrent();
|
|
151
|
-
return { scope_id: scopeId, generation, request_id: intent.request_id, status: "preparing", dispatch_blocked: true, settled: false, release_error: releaseError ? { code: releaseError, message: releaseMessage } : null, run_preparation_consistent: runPreparationConsistent, runs, native, probes: intent.probes, pending_reasons: [...new Set(["scope_release_required", ...(releaseError ? [releaseError] : []), ...(!runPreparationConsistent ? ["scope_writer_boundary_unavailable"] : []), ...(runs.some(item => item.prepared !== true) ? ["run_recovery_required"] : []), ...(native.some(item => item?.prepared !== true) ? ["non_work_recovery_required"] : []), ...(intent.probes.length ? ["probe_reconciliation_required"] : [])])] };
|
|
158
|
+
return { scope_id: scopeId, generation, request_id: intent.request_id, status: "preparing", dispatch_blocked: true, settled: false, release_error: releaseError ? { ...releaseFailure, code: releaseError, message: releaseMessage } : null, run_preparation_consistent: runPreparationConsistent, runs, native, probes: intent.probes, pending_reasons: [...new Set(["scope_release_required", ...(releaseError ? [releaseError] : []), ...(!runPreparationConsistent ? ["scope_writer_boundary_unavailable"] : []), ...(runs.some(item => item.prepared !== true) ? ["run_recovery_required"] : []), ...(native.some(item => item?.prepared !== true) ? ["non_work_recovery_required"] : []), ...(intent.probes.length ? ["probe_reconciliation_required"] : [])])] };
|
|
152
159
|
}
|
|
153
160
|
/** Read filesystem/native evidence before reserving writers. The returned
|
|
154
161
|
* SQL-only commit is called with registry -> all-RUN barriers held. */
|
|
@@ -357,6 +364,11 @@ async function restartScopeNative(context, intent, source, assertCurrent) {
|
|
|
357
364
|
if (!replacement || !replacement.kind.endsWith("-daemon") || replacement.id === source.process_id || !processIsAlive(replacement) || replacement.pid !== daemon.pid || replacement.lease_token !== daemon.resource_process?.lease_token || replacement.owner_id !== source.runtime_owner.owner_id || replacement.run_id || replacement.work_id || replacement.project_id !== (source.runtime_owner.project_id ?? null) || metadata?.role !== source.role || metadata?.state_dir !== source.state_dir || metadata?.dd_flow_home !== source.runtime_owner.dd_flow_home || metadata?.adapter_executable !== launch.executable || JSON.stringify(metadata?.budget) !== JSON.stringify(source.runtime_owner.budget) || daemon.daemon_id === source.daemon_id || daemon.daemon_id !== operation.restart_receipt.daemon_id || operation.restart_receipt.already_running === true || JSON.stringify(daemon.config?.runtime_owner) !== JSON.stringify(source.runtime_owner))
|
|
358
365
|
throw new AppError("runtime_scope_owner_unknown", "Native restart has no fresh bound physical owner", 1);
|
|
359
366
|
const binding = managedProcessBinding(replacement, resourceHome(context));
|
|
367
|
+
const retired = db.get("SELECT * FROM managed_processes WHERE id = ?", [source.process_id]);
|
|
368
|
+
if (replacement.kind !== retired.kind)
|
|
369
|
+
throw new AppError("runtime_scope_owner_unknown", "Native replacement changed its frozen harness", 1);
|
|
370
|
+
const harness = harnessKey(replacement.kind.replace(/-daemon$/, ""));
|
|
371
|
+
const loadMethod = canonicalNativeOperation(harness, "session.resume");
|
|
360
372
|
if (operation.replacement_binding && JSON.stringify(operation.replacement_binding) !== JSON.stringify(binding))
|
|
361
373
|
throw new AppError("runtime_scope_owner_unknown", "Native replacement registration changed", 1);
|
|
362
374
|
operation.status = "restarted";
|
|
@@ -393,10 +405,10 @@ async function restartScopeNative(context, intent, source, assertCurrent) {
|
|
|
393
405
|
return load;
|
|
394
406
|
});
|
|
395
407
|
if (!load.receipt) {
|
|
396
|
-
if (dispatchLoad) {
|
|
408
|
+
if (dispatchLoad || loadMethod === "session.inspect") {
|
|
397
409
|
assertReplacement();
|
|
398
410
|
const args = launch.args.slice(2).filter((value, index, all) => value !== "--session-id" && all[index - 1] !== "--session-id");
|
|
399
|
-
load.receipt = await runHarnessAdapter({ executable: launch.executable, args: ["session", "resume", ...args, "--session-id", session.session_id, "--json"], cwd: launch.cwd, env: nativeLaunchEnvironment(context, launch.env), runtimeOwner: source.runtime_owner, operationId, timeoutMs: 30_000 });
|
|
411
|
+
load.receipt = await runHarnessAdapter({ executable: launch.executable, args: ["session", dispatchLoad ? "resume" : "inspect", ...args, "--session-id", session.session_id, "--json"], harness, cwd: launch.cwd, env: nativeLaunchEnvironment(context, launch.env), runtimeOwner: source.runtime_owner, operationId, timeoutMs: 30_000 });
|
|
400
412
|
}
|
|
401
413
|
else {
|
|
402
414
|
const retained = retainedDaemonOperations(source.state_dir).find(item => item.operation_id === operationId && item.operation === "session.resume" && item.daemon_id === daemon.daemon_id && item.session_id === session.session_id && item.state === "completed");
|
|
@@ -406,6 +418,16 @@ async function restartScopeNative(context, intent, source, assertCurrent) {
|
|
|
406
418
|
}
|
|
407
419
|
}
|
|
408
420
|
assertReplacement();
|
|
421
|
+
if (loadMethod !== "session.inspect") {
|
|
422
|
+
const retained = await retainedDaemonReply(source.state_dir, operationId, { operation: loadMethod, daemon_id: daemon.daemon_id, owner_id: source.runtime_owner.owner_id, session_id: session.session_id });
|
|
423
|
+
assertReplacement();
|
|
424
|
+
if (!retained?.ready) {
|
|
425
|
+
load.status = "receipt_observed";
|
|
426
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-resume-native-load' AND resource_key = ?", [JSON.stringify(load), context.now(), operationId]);
|
|
427
|
+
return { ...source, ...operation, sessions, pending: true, reason: retained?.reason ?? (retained ? "native_session_load_settlement_required" : "native_session_load_outcome_unknown"), native_outcome: retained ? "completed" : "unknown", native_result: retained?.outcome, settlement: retained?.settlement };
|
|
428
|
+
}
|
|
429
|
+
load.receipt = retained.value;
|
|
430
|
+
}
|
|
409
431
|
if (adapterSessionId(load.receipt) !== session.session_id || load.receipt.settled !== true)
|
|
410
432
|
throw new AppError("runtime_scope_session_unsettled", "Native load did not confirm the same settled Session", 1);
|
|
411
433
|
load.status = "prepared";
|
|
@@ -4,9 +4,10 @@ import path from "node:path";
|
|
|
4
4
|
import { spawn } from "node:child_process";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
7
|
-
import { AppError } from "../shared/errors.js";
|
|
7
|
+
import { AppError, errorRecord } from "../shared/errors.js";
|
|
8
8
|
import { getResourceDatabase } from "../storage/database.js";
|
|
9
|
-
import { confirmManagedProcess, finishManagedProcess,
|
|
9
|
+
import { assertManagedProcessLease, confirmManagedProcess, finishManagedProcess, renewManagedProcess, processIsAlive, processStartedAt, registerManagedProcess, resourceHome } from "./managed-processes.js";
|
|
10
|
+
import { managedLeaseMonitor } from "./managed-lease-monitor.js";
|
|
10
11
|
import { publicRuntimeScopeWorker, requestRuntimeScopeControl, runtimeBudgetStatus, prepareRuntimeScopeFence, parseRuntimeBudget } from "./runtime-budget.js";
|
|
11
12
|
import { controlRuntimeScopeRuns } from "./runtime-scope-control.js";
|
|
12
13
|
import { prepareRuntimeScopeResume, recordRuntimeScopeResume } from "./runtime-scope-resume.js";
|
|
@@ -158,85 +159,129 @@ export async function serveRuntimeScopeControl(context, scopeId, generation) {
|
|
|
158
159
|
return worker;
|
|
159
160
|
});
|
|
160
161
|
let heartbeat;
|
|
162
|
+
let lease;
|
|
161
163
|
// A fence must never be held indefinitely by an observer that cannot obtain
|
|
162
164
|
// fresh provider evidence. A later operator request continues the retained
|
|
163
165
|
// budget; this owner records why it stopped without claiming settlement.
|
|
164
166
|
const observation = recoveryObservationBudget(worker.snapshot);
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
167
|
+
worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
|
|
168
|
+
let cleanupFailure;
|
|
169
|
+
const result = await (async () => {
|
|
170
|
+
try {
|
|
171
|
+
const record = registerManagedProcess(context, { kind: "scope-control", ownerId: key, operationId: `scope-control:${key}:${token}`, ownerPid: process.pid, metadata: { role: "control", scope_id: scopeId, generation } });
|
|
172
|
+
worker.process_id = record.id;
|
|
173
|
+
worker.process_lease_token = record.lease_token;
|
|
174
|
+
confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid });
|
|
175
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
176
|
+
lease = managedLeaseMonitor(attemptMs => renewManagedProcess({ ...context, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: record.id, leaseToken: record.lease_token }));
|
|
177
|
+
let timerFailure;
|
|
178
|
+
const assertScopeCurrent = () => {
|
|
179
|
+
const latest = runtimeBudgetStatus(context, scopeId);
|
|
180
|
+
if (latest.control?.generation !== generation)
|
|
181
|
+
throw new AppError("runtime_scope_control_superseded", "Scope control was superseded during renewal", 1);
|
|
182
|
+
};
|
|
183
|
+
const ownedContext = { ...context, assertControlOwner: () => {
|
|
184
|
+
lease.assertNoRenewal();
|
|
185
|
+
assertManagedProcessLease(context, { id: record.id, leaseToken: record.lease_token, pid: process.pid });
|
|
186
|
+
assertScopeCurrent();
|
|
187
|
+
} };
|
|
188
|
+
heartbeat = setInterval(() => { lease.background(assertScopeCurrent, error => { timerFailure = error; }); }, 10_000);
|
|
189
|
+
heartbeat.unref();
|
|
190
|
+
for (;;) {
|
|
191
|
+
if (timerFailure)
|
|
192
|
+
throw timerFailure;
|
|
193
|
+
await lease.ensureConfirmed(assertScopeCurrent);
|
|
194
|
+
const status = runtimeBudgetStatus(context, scopeId);
|
|
195
|
+
const release = status.release;
|
|
196
|
+
if (release?.current === true && release.generation === generation) {
|
|
197
|
+
worker.status = "completed";
|
|
198
|
+
worker.snapshot = release;
|
|
199
|
+
break;
|
|
200
|
+
}
|
|
201
|
+
if (status.control?.generation !== generation || status.fence) {
|
|
202
|
+
worker.status = "superseded";
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
observation.observe(status);
|
|
206
|
+
if (observation.exhausted()) {
|
|
207
|
+
const final = runtimeBudgetStatus(context, scopeId);
|
|
208
|
+
const terminal = final.release;
|
|
209
|
+
if (terminal?.current === true && terminal.generation === generation) {
|
|
210
|
+
worker.status = "completed";
|
|
211
|
+
worker.snapshot = terminal;
|
|
212
|
+
break;
|
|
213
|
+
}
|
|
214
|
+
if (final.control?.generation !== generation || final.fence) {
|
|
215
|
+
worker.status = "superseded";
|
|
216
|
+
break;
|
|
217
|
+
}
|
|
218
|
+
worker.status = "failed";
|
|
219
|
+
worker.error = "recovery_observation_budget_exhausted";
|
|
220
|
+
worker.snapshot = {
|
|
221
|
+
...(worker.snapshot ?? {}),
|
|
222
|
+
status: "recovery_blocked",
|
|
223
|
+
settled: false,
|
|
224
|
+
pending_reasons: ["recovery_observation_budget_exhausted"],
|
|
225
|
+
error: { code: "recovery_observation_budget_exhausted", message: "Scope recovery observation exceeded its 120 second budget" },
|
|
226
|
+
recovery_observation: observation.state(),
|
|
227
|
+
};
|
|
228
|
+
return { scope_id: scopeId, generation, status: "recovery_blocked", settled: false, error: worker.error };
|
|
229
|
+
}
|
|
230
|
+
const resume = status.resume;
|
|
231
|
+
const outcome = await withRecoveryObservation(observation, async () => (worker.reconcile_id ? null : await prepareRuntimeScopeResume(ownedContext, scopeId, generation, resume?.generation === generation ? resume.request_id : undefined))
|
|
232
|
+
?? await controlRuntimeScopeRuns(ownedContext, scopeId, worker.request_id, worker.mode, worker.reconcile_id));
|
|
233
|
+
await lease.ensureConfirmed(assertScopeCurrent);
|
|
234
|
+
observation.observe(outcome);
|
|
235
|
+
worker.snapshot = { ...outcome, recovery_observation: observation.state() };
|
|
236
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
237
|
+
if (worker.snapshot.drain?.physical_settled === true) {
|
|
238
|
+
worker.status = "completed";
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
await delay(observation.nextDelay());
|
|
242
|
+
}
|
|
243
|
+
return { scope_id: scopeId, generation, status: worker.status };
|
|
175
244
|
}
|
|
176
|
-
catch {
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
if (observation.exhausted()) {
|
|
184
|
-
worker.status = "failed";
|
|
185
|
-
worker.error = "recovery_observation_budget_exhausted";
|
|
186
|
-
worker.snapshot = {
|
|
187
|
-
...(worker.snapshot ?? {}),
|
|
188
|
-
status: "recovery_blocked",
|
|
189
|
-
settled: false,
|
|
190
|
-
pending_reasons: ["recovery_observation_budget_exhausted"],
|
|
191
|
-
error: { code: "recovery_observation_budget_exhausted", message: "Scope recovery observation exceeded its 120 second budget" },
|
|
192
|
-
recovery_observation: observation.state(),
|
|
193
|
-
};
|
|
194
|
-
return { scope_id: scopeId, generation, status: "recovery_blocked", settled: false, error: worker.error };
|
|
245
|
+
catch (error) {
|
|
246
|
+
if (error instanceof AppError && error.code === "runtime_scope_control_superseded") {
|
|
247
|
+
const release = runtimeBudgetStatus(context, scopeId).release;
|
|
248
|
+
worker.status = release?.current === true && release.generation === generation ? "completed" : "superseded";
|
|
249
|
+
if (worker.status === "completed")
|
|
250
|
+
worker.snapshot = release;
|
|
251
|
+
return { scope_id: scopeId, generation, status: worker.status };
|
|
195
252
|
}
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
253
|
+
worker.status = "failed";
|
|
254
|
+
worker.error = String(error);
|
|
255
|
+
worker.snapshot = { ...(worker.snapshot ?? {}), error: errorRecord(error) };
|
|
256
|
+
throw error;
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
lease?.close();
|
|
260
|
+
if (heartbeat)
|
|
261
|
+
clearInterval(heartbeat);
|
|
262
|
+
observation.exhausted();
|
|
263
|
+
worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
|
|
264
|
+
let persistenceFailure;
|
|
265
|
+
try {
|
|
266
|
+
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
202
267
|
}
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
268
|
+
catch (error) {
|
|
269
|
+
persistenceFailure = error;
|
|
270
|
+
process.stderr.write(`${JSON.stringify({ primary: worker.error, primary_error: worker.snapshot.error, phase: "persist_scope_owner", secondary: errorRecord(error) })}\n`);
|
|
206
271
|
}
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
observation.exhausted();
|
|
211
|
-
worker.snapshot = { ...outcome, recovery_observation: observation.state() };
|
|
212
|
-
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
213
|
-
if (worker.snapshot.drain?.physical_settled === true) {
|
|
214
|
-
worker.status = "completed";
|
|
215
|
-
break;
|
|
272
|
+
try {
|
|
273
|
+
if (worker.process_id && worker.process_lease_token)
|
|
274
|
+
finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
|
|
216
275
|
}
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const release = runtimeBudgetStatus(context, scopeId).release;
|
|
224
|
-
worker.status = release?.current === true && release.generation === generation ? "completed" : "superseded";
|
|
225
|
-
if (worker.status === "completed")
|
|
226
|
-
worker.snapshot = release;
|
|
227
|
-
return { scope_id: scopeId, generation, status: worker.status };
|
|
276
|
+
catch (error) {
|
|
277
|
+
cleanupFailure = error;
|
|
278
|
+
process.stderr.write(`${JSON.stringify({ primary: worker.error ?? String(persistenceFailure), primary_error: worker.snapshot.error ?? (persistenceFailure === undefined ? undefined : errorRecord(persistenceFailure)), phase: "finish_scope_owner", secondary: errorRecord(error) })}\n`);
|
|
279
|
+
}
|
|
280
|
+
if (persistenceFailure)
|
|
281
|
+
cleanupFailure = persistenceFailure;
|
|
228
282
|
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
throw
|
|
232
|
-
|
|
233
|
-
finally {
|
|
234
|
-
if (heartbeat)
|
|
235
|
-
clearInterval(heartbeat);
|
|
236
|
-
observation.exhausted();
|
|
237
|
-
worker.snapshot = { ...(worker.snapshot ?? {}), recovery_observation: observation.state() };
|
|
238
|
-
db.run("UPDATE managed_resources SET metadata_json = ?, updated_at = ? WHERE resource_kind = 'scope-control-worker' AND resource_key = ? AND lease_token = ?", [JSON.stringify(worker), context.now(), key, token]);
|
|
239
|
-
if (worker.process_id && worker.process_lease_token)
|
|
240
|
-
finishManagedProcess(context, { id: worker.process_id, leaseToken: worker.process_lease_token, state: worker.status === "failed" ? "failed" : "stopped", reason: "scope_owner_exited" });
|
|
241
|
-
}
|
|
283
|
+
})();
|
|
284
|
+
if (cleanupFailure !== undefined && !worker.error)
|
|
285
|
+
throw cleanupFailure;
|
|
286
|
+
return result;
|
|
242
287
|
}
|
|
@@ -2,14 +2,14 @@ import fs from "node:fs";
|
|
|
2
2
|
import crypto from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
|
+
import { validateObservationDuration } from "../harness-runtime/lib/observation-clock.mjs";
|
|
5
6
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
6
7
|
import { requireProjectByRoot } from "./projects.js";
|
|
7
8
|
import { resolveRun } from "./runs.js";
|
|
8
9
|
import { codeExecutionEnvironment, runCheck } from "./code-checks.js";
|
|
9
|
-
import { confirmManagedProcess, finishManagedProcess,
|
|
10
|
+
import { confirmManagedProcess, finishManagedProcess, renewManagedProcess, managedProcessStatus, processTreeIsAlive, registerManagedProcess, reservePorts, resourceHome, stopManagedProcess } from "./managed-processes.js";
|
|
10
11
|
export function prepareRuntimeService(context, input) {
|
|
11
|
-
|
|
12
|
-
throw new AppError("validation", "Readiness timeout must be positive and finite", 2);
|
|
12
|
+
validateObservationDuration(input.timeoutMs);
|
|
13
13
|
if (typeof input.command !== "string" || !input.command.trim() || !Array.isArray(input.ports) || !input.ports.length || input.ports.some(name => typeof name !== "string" || !/^[a-z][a-z0-9_]*$/.test(name)) || new Set(input.ports).size !== input.ports.length || !input.ports.includes(input.readyPort) || typeof input.readyPath !== "string" || !input.readyPath.startsWith("/") || input.readyPath.startsWith("//"))
|
|
14
14
|
throw new AppError("validation", "Declare unique lowercase port names, a ready-port from that list and a local /ready-path", 2);
|
|
15
15
|
const project = requireProjectByRoot(context, path.resolve(input.projectRoot));
|
|
@@ -41,16 +41,15 @@ export async function startRuntimeService(context, input) {
|
|
|
41
41
|
assertRunMutationAllowed(context, project.id, run.id, generation);
|
|
42
42
|
stdout = fs.openSync(String(receipt.stdout_path), "a");
|
|
43
43
|
stderr = fs.openSync(String(receipt.stderr_path), "a");
|
|
44
|
-
const running = runCheck(input.command, run.workspace_root, environment, stdout, stderr, elapsed => input.progress?.(`service ${record.id} alive (${elapsed}s); receipt: ${receiptPath}`), pid => confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }),
|
|
44
|
+
const running = runCheck(input.command, run.workspace_root, environment, stdout, stderr, elapsed => input.progress?.(`service ${record.id} alive (${elapsed}s); receipt: ${receiptPath}`), pid => confirmManagedProcess(context, { id: record.id, leaseToken: record.lease_token, pid, processGroupId: process.platform === "win32" ? null : pid }), attemptMs => renewManagedProcess({ ...context, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: record.id, leaseToken: record.lease_token }), { inactivityTimeoutMs: input.timeoutMs, phase: () => ready ? "serving" : "startup" });
|
|
45
45
|
void running.then(() => { settled = true; });
|
|
46
|
-
|
|
47
|
-
while (!settled && performance.now() < deadline) {
|
|
46
|
+
while (!settled) {
|
|
48
47
|
try {
|
|
49
48
|
const response = await fetch(`http://127.0.0.1:${allocation.ports[input.readyPort]}${input.readyPath}`, { signal: AbortSignal.timeout(1_000), redirect: "error" });
|
|
50
49
|
await response.body?.cancel();
|
|
51
50
|
ready = response.ok;
|
|
52
51
|
}
|
|
53
|
-
catch { /*
|
|
52
|
+
catch { /* Only actual startup output advances the owned command clock. */ }
|
|
54
53
|
if (ready)
|
|
55
54
|
break;
|
|
56
55
|
await new Promise(resolve => setTimeout(resolve, 100));
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { errorRecord } from "../shared/errors.js";
|
|
1
2
|
/** Cleanup is secondary to the arbiter's known commit outcome. Always visit
|
|
2
3
|
* every connection, including after a failed rollback or close. */
|
|
3
4
|
export function cleanupScopeWriters(targets) {
|
|
@@ -10,7 +11,7 @@ export function cleanupScopeWriters(targets) {
|
|
|
10
11
|
}
|
|
11
12
|
catch (error) {
|
|
12
13
|
failed = true;
|
|
13
|
-
errors.push({ target: target.name, phase: "rollback", message: String(error) });
|
|
14
|
+
errors.push({ target: target.name, phase: "rollback", message: String(error), error: errorRecord(error) });
|
|
14
15
|
}
|
|
15
16
|
}
|
|
16
17
|
if (target.close === "always" || failed) {
|
|
@@ -18,7 +19,7 @@ export function cleanupScopeWriters(targets) {
|
|
|
18
19
|
target.db.close?.();
|
|
19
20
|
}
|
|
20
21
|
catch (error) {
|
|
21
|
-
errors.push({ target: target.name, phase: "close", message: String(error) });
|
|
22
|
+
errors.push({ target: target.name, phase: "close", message: String(error), error: errorRecord(error) });
|
|
22
23
|
}
|
|
23
24
|
}
|
|
24
25
|
}
|
|
@@ -3,8 +3,134 @@ import fs from "node:fs";
|
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { AppError } from "../shared/errors.js";
|
|
5
5
|
import { prepareFileBytes, prepareJsonText, prepareOutputFile } from "../cli/input-preparation.js";
|
|
6
|
+
export function runContextRelocations(context, projectId, runId) {
|
|
7
|
+
return context.db.all("SELECT id, payload_json FROM audit_events WHERE project_id = ? AND event_type = 'flow_run.publication_relocation' ORDER BY id", [projectId]).flatMap(row => {
|
|
8
|
+
const receipt = JSON.parse(row.payload_json);
|
|
9
|
+
if (receipt.run_id !== runId || !receipt.path_mapping)
|
|
10
|
+
return [];
|
|
11
|
+
return [{ receipt_id: `${row.id}:${receipt.import_id}`, source_manifest_sha256: receipt.source_manifest_sha256, path_mapping: receipt.path_mapping }];
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
function assertContextRelocation(receipt) {
|
|
15
|
+
if (!receipt.receipt_id || !/^[a-f0-9]{64}$/.test(receipt.source_manifest_sha256)
|
|
16
|
+
|| !Array.isArray(receipt.path_mapping) || receipt.path_mapping.some(item => !item || typeof item.from !== "string" || typeof item.to !== "string" || !path.isAbsolute(item.from) || !path.isAbsolute(item.to)))
|
|
17
|
+
throw new AppError("context_relocation_invalid", "Stage context relocation lacks a verified import binding", 2);
|
|
18
|
+
}
|
|
19
|
+
/** Physical packet locators may move; accepted decision/provenance blocks may not. */
|
|
20
|
+
export function projectPacketLocators(text, receipts) {
|
|
21
|
+
receipts.forEach(assertContextRelocation);
|
|
22
|
+
return text.split(/(<run_operational_contract>[\s\S]*?<\/run_operational_contract>|<external_stage_context>[\s\S]*?<\/source_precedence>|DD_FLOW_(?:INVOCATION_SCOPE|CURRENT_INVOCATION|DAEMON_ID)=(?:"(?:\\.|[^"\\])*"|'[^']*'|[^\s'"])+)/g).map(part => {
|
|
23
|
+
if (part.startsWith("<run_operational_contract>") || part.startsWith("<external_stage_context>") || part.startsWith("DD_FLOW_INVOCATION_SCOPE=") || part.startsWith("DD_FLOW_CURRENT_INVOCATION=") || part.startsWith("DD_FLOW_DAEMON_ID="))
|
|
24
|
+
return part;
|
|
25
|
+
for (const receipt of receipts) {
|
|
26
|
+
const mappings = [...receipt.path_mapping].sort((a, b) => b.from.length - a.from.length);
|
|
27
|
+
if (!mappings.length)
|
|
28
|
+
continue;
|
|
29
|
+
const prefixes = mappings.map(item => item.from.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("|");
|
|
30
|
+
// One longest-prefix replacement per receipt: a new root may itself sit
|
|
31
|
+
// below an old prefix and must not be mapped twice by that receipt.
|
|
32
|
+
const locator = new RegExp(`(^|[^A-Za-z0-9_./~:\\\\-])(${prefixes})(?=[/\\\\]|$|[\\s"'\x60<>{}\\[\\](),;!?])`, "g");
|
|
33
|
+
part = part.replace(locator, (_match, boundary, prefix) => `${boundary}${mappings.find(item => item.from === prefix).to}`);
|
|
34
|
+
}
|
|
35
|
+
return part;
|
|
36
|
+
}).join("");
|
|
37
|
+
}
|
|
38
|
+
/** Current delivery packet is derived; imported prompt/context bytes remain provenance. */
|
|
39
|
+
export function currentStagePrompt(context, promptPath, runHome = path.dirname(path.dirname(promptPath)), sourceText) {
|
|
40
|
+
let original = sourceText ?? fs.readFileSync(promptPath, "utf8");
|
|
41
|
+
let sourcePromptPath = promptPath;
|
|
42
|
+
if (original.startsWith("<current_prompt_projection>\n")) {
|
|
43
|
+
const prior = JSON.parse(original.split("\n")[1]);
|
|
44
|
+
const source = path.resolve(runHome, prior.source_prompt);
|
|
45
|
+
const relative = path.relative(runHome, source);
|
|
46
|
+
if (!relative || relative === ".." || relative.startsWith(`..${path.sep}`) || path.isAbsolute(relative) || !fs.existsSync(source))
|
|
47
|
+
throw new AppError("context_checksum_mismatch", "Current packet has no retained source prompt", 2);
|
|
48
|
+
original = fs.readFileSync(source, "utf8");
|
|
49
|
+
if (sha256(original) !== prior.original_prompt_sha256)
|
|
50
|
+
throw new AppError("context_checksum_mismatch", "Retained source prompt changed", 2);
|
|
51
|
+
sourcePromptPath = source;
|
|
52
|
+
}
|
|
53
|
+
const row = context.db.get("SELECT id, project_id, index_json FROM runs WHERE run_root = ?", [runHome]);
|
|
54
|
+
if (!row)
|
|
55
|
+
return { prompt_path: promptPath, worker_prompt_markdown: original };
|
|
56
|
+
const profile = JSON.parse(row.index_json).execution_profile;
|
|
57
|
+
const receipts = runContextRelocations(context, row.project_id, row.id);
|
|
58
|
+
if (!receipts.length)
|
|
59
|
+
return { prompt_path: promptPath, worker_prompt_markdown: original };
|
|
60
|
+
const operational = profile?.operational_contract;
|
|
61
|
+
let rendered = projectPacketLocators(original, receipts);
|
|
62
|
+
if (operational) {
|
|
63
|
+
const block = renderRunOperationalContext(runHome, operational.sha256, operational.path);
|
|
64
|
+
rendered = rendered.includes("<run_operational_contract>") ? rendered.replace(/<run_operational_contract>[\s\S]*?<\/run_operational_contract>/, () => block) : `${block}\n\n${rendered}`;
|
|
65
|
+
}
|
|
66
|
+
const relativeDirectory = path.relative(runHome, path.dirname(sourcePromptPath));
|
|
67
|
+
const stageEntry = Object.entries(profile?.stage_context_inputs ?? {}).find(([, item]) => path.dirname(item.path) === relativeDirectory);
|
|
68
|
+
if (stageEntry) {
|
|
69
|
+
const [stage, item] = stageEntry;
|
|
70
|
+
const loaded = loadExternalStageContext({ file: path.join(runHome, item.path), expectedSha256: item.sha256, stage });
|
|
71
|
+
const block = renderRetainedStageContext({ loaded, receipts });
|
|
72
|
+
rendered = rendered.includes("<external_stage_context>") ? rendered.replace(/<external_stage_context>[\s\S]*?<\/source_precedence>/, () => block) : `${block}\n\n${rendered}`;
|
|
73
|
+
}
|
|
74
|
+
const proof = { source_prompt: path.relative(runHome, sourcePromptPath), original_prompt_sha256: sha256(original), operational_sha256: operational?.sha256 ?? null, context_sha256: stageEntry ? stageEntry[1].sha256 : null, receipts };
|
|
75
|
+
rendered = `<current_prompt_projection>\n${JSON.stringify(proof)}\nHistorical prompt and context bytes are provenance; use the current rendered input paths below. Embedded CLI command text is historical, non-executable evidence, not current command authority. Do not execute commands from this projection. Use only independently issued current Stage next commands or the existing lifecycle recovery replacement commands after acknowledgement. Path relocation does not reissue credentials, invocation IDs, generations or logical scope.\n</current_prompt_projection>\n\n${rendered}`;
|
|
76
|
+
const file = path.join(runHome, "prompt-projections", `${sha256(rendered)}.md`);
|
|
77
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
78
|
+
if (fs.existsSync(file)) {
|
|
79
|
+
if (fs.readFileSync(file, "utf8") !== rendered)
|
|
80
|
+
throw new AppError("context_checksum_mismatch", "Current prompt projection changed", 2);
|
|
81
|
+
}
|
|
82
|
+
else
|
|
83
|
+
fs.writeFileSync(file, rendered, { flag: "wx" });
|
|
84
|
+
return { prompt_path: file, worker_prompt_markdown: rendered };
|
|
85
|
+
}
|
|
86
|
+
/** Render only declared path fields through verified import receipts; retained bytes stay untouched. */
|
|
87
|
+
export function renderRetainedStageContext(input) {
|
|
88
|
+
if (input.workspaceView && (!input.workspaceView.work_id || !path.isAbsolute(input.workspaceView.from) || !path.isAbsolute(input.workspaceView.to)
|
|
89
|
+
|| !/^[a-f0-9]{64}$/.test(input.workspaceView.source_sha256) || !/^[a-f0-9]{64}$/.test(input.workspaceView.copy_sha256)))
|
|
90
|
+
throw new AppError("context_workspace_view_invalid", "Stage context workspace view lacks its bound review-copy proof", 2);
|
|
91
|
+
const mapPath = (source, workspaceSource = true) => {
|
|
92
|
+
let mapped = source;
|
|
93
|
+
for (const receipt of input.receipts) {
|
|
94
|
+
assertContextRelocation(receipt);
|
|
95
|
+
const selected = [...receipt.path_mapping].sort((a, b) => b.from.length - a.from.length).find(item => mapped === item.from || mapped.startsWith(`${item.from}${path.sep}`));
|
|
96
|
+
if (selected)
|
|
97
|
+
mapped = path.join(selected.to, path.relative(selected.from, mapped));
|
|
98
|
+
}
|
|
99
|
+
const view = input.workspaceView;
|
|
100
|
+
if (view && workspaceSource && (mapped === view.from || mapped.startsWith(`${view.from}${path.sep}`)))
|
|
101
|
+
mapped = path.join(view.to, path.relative(view.from, mapped));
|
|
102
|
+
return mapped;
|
|
103
|
+
};
|
|
104
|
+
const value = input.loaded.value;
|
|
105
|
+
const mappedValue = { ...value,
|
|
106
|
+
...(value.task_input ? { task_input: value.task_input.map(item => ({ ...item, path: mapPath(item.path) })) } : {}),
|
|
107
|
+
...(value.sources ? { sources: value.sources.map(item => ({ ...item, path: mapPath(item.path, item.root !== "run") })) } : {}) };
|
|
108
|
+
return [input.receipts.length ? `<context_path_mapping>\nOriginal context SHA-256: ${input.loaded.materializedSha256}\nVerified import receipts: ${JSON.stringify(input.receipts)}\nOnly rendered physical path fields are relocated; original decisions and source bytes are unchanged.\n</context_path_mapping>` : "",
|
|
109
|
+
input.workspaceView ? `<review_workspace_view>\nBound review copy: ${JSON.stringify(input.workspaceView)}\nOnly project/workspace input paths use this read-only view. Original authority and RUN artifacts stay in their retained locations.\n</review_workspace_view>` : "",
|
|
110
|
+
renderExternalStageContext({ loaded: { ...input.loaded, value: mappedValue }, installed: { path: input.loaded.sourcePath } })].filter(Boolean).join("\n\n");
|
|
111
|
+
}
|
|
6
112
|
/** One precedence rule for coordinator and delegated Work packets. */
|
|
7
113
|
export const acceptedDecisionPrecedence = "Read the accepted RUN decisions before applying older general project rules. An accepted, explicitly scoped user decision supersedes a conflicting general rule only within that scope; preserve the rule elsewhere. Do not ask the user again about a decision already settled in those artifacts or invent a broader exception.";
|
|
114
|
+
/** Shared Stage/Work projection; historical RUNs do not gain a new grant. */
|
|
115
|
+
export function renderRunOperationalContext(runHome, expectedSha256, artifactPath) {
|
|
116
|
+
const indexFile = path.join(runHome, "run.json");
|
|
117
|
+
const index = fs.existsSync(indexFile) ? JSON.parse(fs.readFileSync(indexFile, "utf8")) : null;
|
|
118
|
+
const metadata = index?.execution_profile?.operational_contract;
|
|
119
|
+
const file = path.join(runHome, artifactPath ?? metadata?.path ?? "operational-contract.json");
|
|
120
|
+
const binding = expectedSha256 ?? metadata?.sha256;
|
|
121
|
+
if (!binding)
|
|
122
|
+
return "";
|
|
123
|
+
if (!fs.existsSync(file) || sha256(fs.readFileSync(file)) !== binding)
|
|
124
|
+
throw new AppError("operational_contract_changed", "Frozen RUN operational input changed or is missing", 2, { file });
|
|
125
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
126
|
+
if (value.policy_source && (!fs.existsSync(path.join(runHome, value.policy_source.path)) || sha256(fs.readFileSync(path.join(runHome, value.policy_source.path))) !== value.policy_source.sha256))
|
|
127
|
+
throw new AppError("operational_contract_changed", "Retained policy source changed", 2);
|
|
128
|
+
return ["<run_operational_contract>", `- immutable artifact: ${file}`, `- sha256: ${binding}`, `- logical scope: ${JSON.stringify(value.binding)}`,
|
|
129
|
+
`- delivery: ${value.settings.merge_delivery.strategy}; cleanup: ${value.settings.merge_cleanup.source}`,
|
|
130
|
+
...(value.decision ? [`- accepted decision ${value.decision.id}: ${JSON.stringify(value.decision)}`, `- retained policy source: ${value.policy_source ? path.join(runHome, value.policy_source.path) : path.resolve(index?.workspace?.project_root ?? value.binding.project_root, value.decision.policy.path)} (original locators above are provenance only)`, acceptedDecisionPrecedence] : ["No scoped override is accepted. Preserve project policy; frozen defaults do not grant an exception."]),
|
|
131
|
+
"Local completion means proven integration into the declared target commit, not remote publication, deployment, release, or a feature-only handoff. Source retention applies only to this RUN's source. Preserve dirty/unmerged, branch-protection and immutable-tag guards.",
|
|
132
|
+
"A retention review date requests review; it is not an automatic expiry or deletion instruction.", "</run_operational_contract>"].join("\n");
|
|
133
|
+
}
|
|
8
134
|
export function loadExternalStageContext(input) {
|
|
9
135
|
const { file: sourcePath, bytes } = prepareFileBytes(input.file, "Stage context");
|
|
10
136
|
const materializedSha256 = sha256(bytes);
|
|
@@ -52,6 +178,15 @@ export function installExternalStageContext(input) {
|
|
|
52
178
|
/** Install the runner slice and make it visible in the agent packet before a
|
|
53
179
|
* caller performs its lifecycle transition. */
|
|
54
180
|
export function applyExternalStageContext(input) {
|
|
181
|
+
const home = path.dirname(input.stageRoot);
|
|
182
|
+
const row = input.context?.db.get("SELECT index_json FROM runs WHERE run_root = ?", [home]);
|
|
183
|
+
const authoritative = row ? JSON.parse(row.index_json).execution_profile?.operational_contract : undefined;
|
|
184
|
+
const operational = input.context ? authoritative ? renderRunOperationalContext(home, authoritative.sha256, authoritative.path) : "" : renderRunOperationalContext(home);
|
|
185
|
+
if (operational && fs.existsSync(input.promptPath)) {
|
|
186
|
+
const current = fs.readFileSync(input.promptPath, "utf8");
|
|
187
|
+
if (!current.includes("<run_operational_contract>"))
|
|
188
|
+
fs.writeFileSync(input.promptPath, `${operational}\n\n${current}`);
|
|
189
|
+
}
|
|
55
190
|
if (!input.loaded)
|
|
56
191
|
return undefined;
|
|
57
192
|
const installed = installExternalStageContext({ stageRoot: input.stageRoot, loaded: input.loaded });
|
|
@@ -8,7 +8,7 @@ import { persistProtocolState, requireProtocol, readProtocolRuntimeState } from
|
|
|
8
8
|
import { attachFlowRunStage, prepareFlowRunStart, prepareFlowRunStageAttachment, prepareStageDirectory, completeFlowRunStage, getFlowRunStatus, startFlowRun } from "./runs.js";
|
|
9
9
|
import { validateSchema } from "./schema-validation.js";
|
|
10
10
|
import { reconcileSessionCoverage, registerFlowSession } from "./sessions.js";
|
|
11
|
-
import {
|
|
11
|
+
import { usageForRun } from "./usage.js";
|
|
12
12
|
import { boundCanonicalPlan, prepareCanonicalPlan } from "./plan-runtime.js";
|
|
13
13
|
import { prepareJsonFile, prepareJsonSchema, prepareOutputDirectory, prepareOutputFile, prepareTextFile } from "../cli/input-preparation.js";
|
|
14
14
|
import { AppError } from "../shared/errors.js";
|
|
@@ -20,16 +20,6 @@ import { registerProtocol } from "./protocols.js";
|
|
|
20
20
|
import { previewNextEntityId } from "./ids.js";
|
|
21
21
|
import { hookSessionIdentity, sessionIdForHookEvent } from "./hooks.js";
|
|
22
22
|
import { assertRunMutationAllowed } from "./run-recovery.js";
|
|
23
|
-
function stageUsageProjection(context, view, stage, startedAt) {
|
|
24
|
-
if (!startedAt)
|
|
25
|
-
return { status: "unavailable", input_tokens: null, cached_input_tokens: null, output_tokens: null, total_tokens: null, session_count: 0, observed_at: context.now() };
|
|
26
|
-
const value = recalculateRunUsage(context, { projectId: view.run.project_id, runId: view.run.id, stage: { name: stage, startedAt, completedAt: context.now() } });
|
|
27
|
-
const totals = value.totals, coverage = value.coverage;
|
|
28
|
-
const count = (name) => typeof coverage?.[name] === "number" ? coverage[name] : 0;
|
|
29
|
-
const measured = count("measured"), other = Object.values(coverage ?? {}).reduce((total, item) => total + (typeof item === "number" ? item : 0), 0) - measured;
|
|
30
|
-
const tokens = (name) => typeof totals?.[name] === "number" ? totals[name] : null;
|
|
31
|
-
return { status: measured === 0 ? "unavailable" : other > 0 || ["input_tokens", "cache_read_input_tokens", "output_tokens", "total_tokens"].some(name => tokens(name) === null) ? "partial" : "measured", input_tokens: tokens("input_tokens"), cached_input_tokens: tokens("cache_read_input_tokens"), output_tokens: tokens("output_tokens"), total_tokens: tokens("total_tokens"), session_count: Array.isArray(value.sessions) ? value.sessions.length : 0, observed_at: typeof value.observed_at === "string" ? value.observed_at : context.now() };
|
|
32
|
-
}
|
|
33
23
|
const stagePromptSections = [
|
|
34
24
|
"stage_identity",
|
|
35
25
|
"authoritative_runtime_facts",
|
|
@@ -294,7 +284,7 @@ export function prepareStageFinish(context, input) {
|
|
|
294
284
|
return { projectRoot, runId, view, existing, runHome, stageRoot, semanticFile, semantic, status, source, planFinish };
|
|
295
285
|
}
|
|
296
286
|
export function finishStage(context, input, prepared = prepareStageFinish(context, input)) {
|
|
297
|
-
const { projectRoot, runId, view,
|
|
287
|
+
const { projectRoot, runId, view, runHome, stageRoot, semanticFile, semantic, status, source, planFinish } = prepared;
|
|
298
288
|
assertRunMutationAllowed(context, view.run.project_id, view.run.id);
|
|
299
289
|
fs.mkdirSync(stageRoot, { recursive: true });
|
|
300
290
|
const receipt = beginFinishReceipt(context, stageRoot, semanticFile, view.run.id, input.stage, source.text);
|
|
@@ -306,7 +296,6 @@ export function finishStage(context, input, prepared = prepareStageFinish(contex
|
|
|
306
296
|
const lint = runTargetedMemoryBankLint(projectRoot);
|
|
307
297
|
if (planFinish)
|
|
308
298
|
boundCanonicalPlan(context, { projectId: view.run.project_id, protocolId: view.run.subject.id, planPath: planFinish.plan_path }, planFinish.canonical);
|
|
309
|
-
const stageUsage = stageUsageProjection(context, view, input.stage, existing.started_at);
|
|
310
299
|
const report = buildStageReport(context, view, input.stage, status, semantic, stageRoot, lint, planFinish);
|
|
311
300
|
const dataPath = path.join(stageRoot, "stage-report.json");
|
|
312
301
|
const reportPath = path.join(stageRoot, "stage-report.md");
|
|
@@ -327,8 +316,7 @@ export function finishStage(context, input, prepared = prepareStageFinish(contex
|
|
|
327
316
|
stageReport: htmlPath,
|
|
328
317
|
data: dataPath,
|
|
329
318
|
dataSchemaId: String(report.schema_id),
|
|
330
|
-
report: reportPath
|
|
331
|
-
usage: stageUsage
|
|
319
|
+
report: reportPath
|
|
332
320
|
});
|
|
333
321
|
finishReceipt(receipt, { outcome: "accepted", result: { status, report: dataPath } });
|
|
334
322
|
appendAudit(context, {
|