@deksden-com/dd-flow-cli 0.9.0-beta.143 → 0.9.0-beta.145
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 +65 -0
- package/README.md +13 -8
- package/dist/build-info.json +7 -7
- package/dist/cli/command-inputs.js +9 -3
- package/dist/cli/command-metadata.js +33 -14
- package/dist/cli/help.js +32 -1
- package/dist/cli/hook-ingress.js +27 -3
- package/dist/cli/memory-flow-admission.js +7 -3
- package/dist/cli/memory-init-invocation.js +10 -3
- package/dist/cli/memory-init.js +74 -68
- package/dist/cli/memory-storage-admission.js +3 -2
- package/dist/cli/memory-storage-qualification.js +105 -0
- package/dist/cli/memory-storage-transition.js +123 -0
- package/dist/cli/memory-upgrade.js +189 -0
- package/dist/cli/native-hook-ingress.js +14 -4
- package/dist/cli/native-hook-preparation.js +55 -0
- package/dist/cli/response-file.js +2 -1
- package/dist/cli/run-cli.js +70 -32
- package/dist/cli/setup.js +2 -1
- package/dist/cli.js +12 -0
- package/dist/domain/stage-handler-identities.js +8 -0
- package/dist/harness-runtime/bin/dd-agy.mjs +3 -3
- package/dist/harness-runtime/bin/dd-grok.mjs +3 -3
- package/dist/harness-runtime/bin/dd-zcode.mjs +5 -5
- package/dist/harness-runtime/lib/agy-error-boundary.mjs +58 -0
- package/dist/harness-runtime/lib/dd-agy-daemon.mjs +62 -20
- package/dist/harness-runtime/lib/dd-agy.mjs +17 -3
- package/dist/harness-runtime/lib/dd-droid.mjs +1 -1
- package/dist/harness-runtime/lib/dd-grok-daemon.mjs +70 -13
- package/dist/harness-runtime/lib/dd-grok.mjs +6 -3
- package/dist/harness-runtime/lib/dd-zcode.mjs +44 -14
- package/dist/harness-runtime/lib/delegation-instructions.mjs +1 -1
- package/dist/harness-runtime/lib/entity-references.d.mts +2 -0
- package/dist/harness-runtime/lib/entity-references.mjs +9 -0
- package/dist/harness-runtime/lib/managed-daemon.mjs +13 -6
- package/dist/harness-runtime/lib/native-hook-command.mjs +4 -4
- package/dist/harness-runtime/lib/opencode-plugin.mjs +3 -3
- package/dist/harness-runtime/lib/operation-errors.mjs +1 -1
- package/dist/harness-runtime/lib/provider-overload-policy.d.mts +4 -2
- package/dist/harness-runtime/lib/provider-overload-policy.mjs +19 -6
- package/dist/harness-runtime/lib/runner-events.mjs +3 -1
- package/dist/harness-runtime/lib/setup-progress.mjs +8 -6
- package/dist/schemas/mb-upgrade-stage-result.schema.json +863 -0
- package/dist/schemas/memory-repair-work-result.schema.json +9 -2
- package/dist/schemas/release-impact.schema.json +3 -3
- package/dist/schemas/stage-start-response.schema.json +4 -0
- package/dist/services/accepted-intent.js +13 -1
- package/dist/services/canon.js +43 -7
- package/dist/services/cli-operation-classifier.js +1 -1
- package/dist/services/cli-upgrade-allowlist.js +1 -1
- package/dist/services/code-checks.js +11 -4
- package/dist/services/compatibility-preflight.js +5 -2
- package/dist/services/controller-fanout.js +21 -4
- package/dist/services/controller-memory-task.js +45 -0
- package/dist/services/controller-memory-worker.js +48 -0
- package/dist/services/dashboard.js +2 -2
- package/dist/services/descriptor-run-start.js +26 -19
- package/dist/services/descriptor-stage-coordinator.js +30 -10
- package/dist/services/descriptor-stage-start.js +40 -4
- package/dist/services/engines.js +64 -14
- package/dist/services/eval-snapshots.js +104 -93
- package/dist/services/external-work-launch.js +5 -3
- package/dist/services/flow-catalog.js +8 -4
- package/dist/services/frozen-engine.js +113 -11
- package/dist/services/hook-memory-routing.js +188 -0
- package/dist/services/hook-recovery-guard.js +14 -0
- package/dist/services/hook-session-observation.js +24 -0
- package/dist/services/hooks.js +27 -8
- package/dist/services/hybrid-contracts.js +2 -13
- package/dist/services/hybrid-digest.js +14 -0
- package/dist/services/installed-flow-source.js +25 -4
- package/dist/services/lifecycle-invocations.js +33 -5
- package/dist/services/memory-accepted-composition.js +5 -4
- package/dist/services/memory-completed-correction-wave.js +23 -15
- package/dist/services/memory-compose-handler.js +163 -21
- package/dist/services/memory-composition-acceptance.js +186 -10
- package/dist/services/memory-composition-graph.js +42 -11
- package/dist/services/memory-composition-registration.js +23 -8
- package/dist/services/memory-composition-work-custody.js +14 -9
- package/dist/services/memory-composition-work-results.js +15 -3
- package/dist/services/memory-corrected-review-batch.js +10 -3
- package/dist/services/memory-corrected-review-findings.js +4 -2
- package/dist/services/memory-corrected-review-inputs.js +10 -5
- package/dist/services/memory-corrected-review-receipt.js +17 -9
- package/dist/services/memory-corrected-review-registration.js +3 -2
- package/dist/services/memory-correction-check-receipt.js +20 -4
- package/dist/services/memory-custody-snapshot.js +36 -0
- package/dist/services/memory-finish-evidence.js +44 -22
- package/dist/services/memory-finish-handler.js +37 -23
- package/dist/services/memory-finish-integration.js +218 -48
- package/dist/services/memory-finish-readiness.js +66 -19
- package/dist/services/memory-git-integration.js +87 -18
- package/dist/services/memory-historical-corrected-review.js +21 -15
- package/dist/services/memory-historical-original-review.js +22 -9
- package/dist/services/memory-historical-repair-registration.js +14 -8
- package/dist/services/memory-historical-second-review.js +30 -11
- package/dist/services/memory-init-contexts.js +28 -2
- package/dist/services/memory-init-controller-start.js +19 -8
- package/dist/services/memory-init-git-policy.js +50 -21
- package/dist/services/memory-init-lint-capability.js +21 -3
- package/dist/services/memory-init-native-capacity.js +13 -0
- package/dist/services/memory-init-preflight.js +11 -1
- package/dist/services/memory-init-request-candidate.js +73 -17
- package/dist/services/memory-init-request-input.js +23 -7
- package/dist/services/memory-init-run-bundle.js +49 -12
- package/dist/services/memory-init-storage.js +17 -7
- package/dist/services/memory-layer.js +68 -4
- package/dist/services/memory-lint-config.js +111 -2
- package/dist/services/memory-native-review-copy.js +5 -67
- package/dist/services/memory-normal-flow-entry.js +12 -0
- package/dist/services/memory-ordinary-defaults.js +22 -5
- package/dist/services/memory-preflight-handler.js +15 -0
- package/dist/services/memory-repair-current-view.js +1 -1
- package/dist/services/memory-repair-document-delta.js +70 -33
- package/dist/services/memory-repair-history.js +12 -7
- package/dist/services/memory-repair-physical-binding.js +1 -119
- package/dist/services/memory-repair-publication-plan.js +5 -3
- package/dist/services/memory-repair-publication-source.js +1 -1
- package/dist/services/memory-repair-publication.js +3 -2
- package/dist/services/memory-repair-registration.js +2 -1
- package/dist/services/memory-repair-work-baseline.js +4 -2
- package/dist/services/memory-repair-work-completion.js +32 -12
- package/dist/services/memory-repair-work-results.js +21 -2
- package/dist/services/memory-repair-workspace.js +11 -2
- package/dist/services/memory-repair-writer-copy.js +2 -1
- package/dist/services/memory-result-binding.js +78 -12
- package/dist/services/memory-retained-review-authority.js +17 -8
- package/dist/services/memory-retained-second-baseline.js +1 -1
- package/dist/services/memory-retained-second-repairs.js +12 -6
- package/dist/services/memory-review-correction-plan.js +47 -12
- package/dist/services/memory-review-handler.js +17 -17
- package/dist/services/memory-review-inputs.js +55 -9
- package/dist/services/memory-review-limits.js +10 -0
- package/dist/services/memory-review-receipt.js +2 -2
- package/dist/services/memory-review-registration.js +13 -4
- package/dist/services/memory-review-work-custody.js +3 -1
- package/dist/services/memory-review-work-input.js +26 -7
- package/dist/services/memory-review-work-results.js +26 -4
- package/dist/services/memory-second-corrected-review-inputs.js +5 -3
- package/dist/services/memory-second-correction-plan.js +2 -1
- package/dist/services/memory-second-repair-launch.js +4 -4
- package/dist/services/memory-second-repair-source.js +1 -1
- package/dist/services/memory-stage-dispatch.js +38 -1
- package/dist/services/memory-stage-results.js +113 -22
- package/dist/services/memory-upgrade-analysis-handler.js +243 -0
- package/dist/services/memory-upgrade-bootstrap-readiness.js +251 -0
- package/dist/services/memory-upgrade-evidence.js +100 -0
- package/dist/services/memory-upgrade-inventory.js +442 -0
- package/dist/services/memory-upgrade-publication-custody.js +73 -0
- package/dist/services/memory-upgrade-publication.js +413 -0
- package/dist/services/memory-upgrade-selection.js +430 -0
- package/dist/services/memory-upgrade-start.js +60 -0
- package/dist/services/memory-upgrade-storage-readiness.js +340 -0
- package/dist/services/memory-verification-acceptance.js +38 -17
- package/dist/services/memory-verification-checks.js +77 -23
- package/dist/services/memory-verification-receipt.js +6 -5
- package/dist/services/memory-verify-handler.js +15 -13
- package/dist/services/memory-work-result-json.js +12 -0
- package/dist/services/memory-work-result-preparation.js +5 -4
- package/dist/services/merge-server.js +4 -2
- package/dist/services/migrations.js +127 -98
- package/dist/services/physical-startup.js +45 -0
- package/dist/services/project-registry.js +97 -0
- package/dist/services/project-summary.js +1 -1
- package/dist/services/projects.js +3 -93
- package/dist/services/prompt-delivery.js +71 -0
- package/dist/services/provider-capacity.js +74 -9
- package/dist/services/recovery-snapshot-database.js +32 -4
- package/dist/services/repair-intents.js +1 -12
- package/dist/services/repair-receipt.js +13 -0
- package/dist/services/run-control-worker.js +35 -18
- package/dist/services/run-controller-process.js +2 -6
- package/dist/services/run-controller-recovery.js +6 -1
- package/dist/services/run-controller.js +131 -80
- package/dist/services/run-recovery-guard.js +30 -0
- package/dist/services/run-recovery.js +11 -44
- package/dist/services/run-start-custody.js +17 -5
- package/dist/services/runtime-scope-worker.js +46 -19
- package/dist/services/sessions.js +3 -24
- package/dist/services/setup.js +63 -13
- package/dist/services/stage-handlers.js +9 -7
- package/dist/services/stage-pause.js +108 -44
- package/dist/services/version-status.js +1 -1
- package/dist/services/vnext-fanout.js +21 -9
- package/dist/services/vnext-plan-review.js +3 -5
- package/dist/services/work-registry.js +80 -33
- package/dist/services/workspace-files.js +1 -1
- package/dist/shared/entity-references.js +1 -8
- package/dist/storage/data-preservation.js +65 -0
- package/dist/storage/database.js +53 -19
- package/dist/storage/import-guard.js +84 -27
- package/dist/storage/memory-upgrade-schema-bootstrap.js +38 -0
- package/dist/storage/paired-writer-migration.js +424 -0
- package/dist/storage/writer-contract.js +11 -4
- package/dist/storage/writer-migration.js +313 -52
- package/dist/storage/writer-restore.js +217 -0
- package/package.json +8 -4
|
@@ -255,7 +255,12 @@ export async function resumeControllerSession(context, row, manifest, state, ass
|
|
|
255
255
|
|| current.process.lease_expires_at <= context.now() || metadata.run_generation !== row.generation || !processIsAlive(current.process))
|
|
256
256
|
throw new AppError("recovery_capacity_owner_changed", "Replacement native owner changed during capacity wait", 1);
|
|
257
257
|
},
|
|
258
|
-
decision: () =>
|
|
258
|
+
decision: (boundary) => {
|
|
259
|
+
if (boundary && (boundary.lifecycle_operation !== "recovery_accept" || boundary.recovery_id !== recovery.recovery_id
|
|
260
|
+
|| !context.db.get("SELECT 1 FROM run_recovery_bindings b JOIN hook_events h ON h.event_key = b.hook_event_key WHERE b.recovery_id = ? AND b.status = 'accepted' AND b.daemon_id = ? AND b.provider_session_id = ? AND h.project_id = ? AND h.status = 'claimed' AND h.event_key = ?", [recovery.recovery_id, daemonId, session.id, row.project_id, boundary.native_hook_event_key])))
|
|
261
|
+
return "reconciliation_blocked";
|
|
262
|
+
return recoveryGuard(context, row.project_id, row.run_id)?.status === "recovered" ? "completed" : "continue";
|
|
263
|
+
},
|
|
259
264
|
retainIntent: intent => retainRecoveryCapacityIntent(executionContext, { projectId: row.project_id, runId: row.run_id, recoveryId: recovery.recovery_id,
|
|
260
265
|
controllerId: row.controller_id, generation: row.generation, daemonId, intent, prompt: continuationText }),
|
|
261
266
|
prompt: (operationId, prompt) => sendAck(operationId, prompt) });
|
|
@@ -25,11 +25,13 @@ import { sameSessionProfile, runtimeHarness } from "./execution-policy.js";
|
|
|
25
25
|
import { selectControllerExecution } from "./controller-execution.js";
|
|
26
26
|
import { resolveHarnessCommand, harnessRuntimeArguments } from "./harness-config.js";
|
|
27
27
|
import { adapterSessionId, prepareHarnessFlowExecutable, runHarnessAdapter } from "./harness-adapter.js";
|
|
28
|
-
import { assertManagedProcessLease, confirmManagedProcess, renewManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
|
|
28
|
+
import { assertManagedProcessLease, confirmManagedProcess, prepareManagedProcessConfirmation, renewManagedProcess, finishManagedProcess, processStartedAt, processTreeIsAlive, resourceHome } from "./managed-processes.js";
|
|
29
29
|
import { managedLeaseMonitor } from "./managed-lease-monitor.js";
|
|
30
|
+
import { physicalStartup } from "./physical-startup.js";
|
|
30
31
|
import { readNativeDaemonState, requireManagedDaemonBinding } from "./managed-daemon-binding.js";
|
|
31
32
|
import { initCodexHome } from "./hooks.js";
|
|
32
33
|
import { launchExternalWork } from "./external-work-launch.js";
|
|
34
|
+
import { runControllerMemoryTask } from "./controller-memory-task.js";
|
|
33
35
|
import { controllerNativeChildren, controllerStageEntryPrompt, controllerStageResumePrompt, nextControllerFanout } from "./controller-fanout.js";
|
|
34
36
|
import { claimQueuedMergeRequest } from "./merge-server.js";
|
|
35
37
|
import { prepareVnextCodeWorkspace } from "./vnext-code.js";
|
|
@@ -38,7 +40,7 @@ import { assertNoCorrelatedRejection } from "./continuation-outcome.js";
|
|
|
38
40
|
import { spawnController, recordControllerLauncher, claimControllerProcess, markControllerDispatchReady, assertControllerLeaseAllowed } from "./run-controller-process.js";
|
|
39
41
|
import { controllerAdapter, controllerEnvironment, sessionArguments } from "./run-controller-adapter.js";
|
|
40
42
|
import { retainedChildObserver } from "./controller-child-observer.js";
|
|
41
|
-
import { capacityDelay, continueProviderCapacity, eligibleProviderChildOverload, providerChildFailure } from "./provider-capacity.js";
|
|
43
|
+
import { capacityDelay, continueProviderCapacity, eligibleProviderChildOverload, providerChildFailure, ownedProviderBoundary } from "./provider-capacity.js";
|
|
42
44
|
import { resumeControllerSession, failControllerRecovery } from "./run-controller-recovery.js";
|
|
43
45
|
import { captureControllerBoundary } from "./run-controller-capture.js";
|
|
44
46
|
import { requestRunControl } from "./run-control-worker.js";
|
|
@@ -215,92 +217,120 @@ export async function serveRunController(context, input) {
|
|
|
215
217
|
const row = requireController(context, input.controllerId);
|
|
216
218
|
if (row.project_id !== project.id || row.run_id !== input.runId || row.owner_token !== context.env.DD_FLOW_CONTROLLER_TOKEN)
|
|
217
219
|
throw new AppError("controller_owner_mismatch", "Managed serve does not own this launch", 1);
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
const state = JSON.parse(row.state_json);
|
|
224
|
-
// Resource and RUN commits are distinct effects. A RUN keepalive failure must
|
|
225
|
-
// neither erase a resource receipt nor grant RUN dispatch authority.
|
|
226
|
-
const mergeLease = controllerLeaseMonitor(attemptMs => {
|
|
227
|
-
const expiry = new Date(Date.now() + 120_000).toISOString();
|
|
228
|
-
const db = context.db.withDeadline(Date.now() + attemptMs);
|
|
229
|
-
if (db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching' LIMIT 1", [row.project_id, row.run_id, row.controller_id]))
|
|
230
|
-
db.writeTransaction(() => {
|
|
231
|
-
const guard = recoveryGuard({ ...context, db }, row.project_id, row.run_id);
|
|
232
|
-
if ((guard?.generation ?? 0) !== row.generation || ["draining", "sealed", "resuming"].includes(guard?.status ?? ""))
|
|
233
|
-
throw new AppError("controller_lease_lost", "RUN generation changed before MERGE keepalive", 1);
|
|
234
|
-
db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching'", [expiry, context.now(), row.project_id, row.run_id, row.controller_id]);
|
|
235
|
-
});
|
|
236
|
-
return { owned: true, lease_expires_at: expiry };
|
|
237
|
-
});
|
|
238
|
-
const assertLeaseFence = () => {
|
|
239
|
-
assertControllerLeaseAllowed(context, row);
|
|
240
|
-
};
|
|
241
|
-
const heartbeat = setInterval(() => {
|
|
242
|
-
lease.background(assertLeaseFence, () => { });
|
|
243
|
-
if (state.merge_claim)
|
|
244
|
-
mergeLease.background(assertLeaseFence, () => { });
|
|
245
|
-
}, 30_000);
|
|
246
|
-
heartbeat.unref();
|
|
247
|
-
const manifest = JSON.parse(row.manifest_json);
|
|
248
|
-
const assertPhysicalOwner = () => {
|
|
249
|
-
lease.assertNoRenewal();
|
|
250
|
-
if (state.merge_claim)
|
|
251
|
-
mergeLease.assertNoRenewal();
|
|
252
|
-
// This isolated read already bounds SQLite contention. A wall deadline
|
|
253
|
-
// before opening the reader mistakes scheduler delay for heartbeat loss;
|
|
254
|
-
// the observed row's actual expiry/token still fences productive dispatch.
|
|
255
|
-
assertManagedProcessLease(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
|
|
256
|
-
const current = requireController(context, row.controller_id);
|
|
257
|
-
if (current.owner_pid !== process.pid || current.owner_token !== row.owner_token || !["running", "capturing", "waiting_for_user", "waiting_for_context", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(current.status))
|
|
258
|
-
throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
|
|
259
|
-
};
|
|
260
|
-
const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
|
|
220
|
+
let lease;
|
|
221
|
+
let mergeLease;
|
|
222
|
+
let heartbeat;
|
|
223
|
+
let state;
|
|
224
|
+
let claimedProcess = false;
|
|
261
225
|
let primaryFailure;
|
|
262
226
|
const retainSecondary = (phase, error) => {
|
|
263
227
|
process.stderr.write(`${JSON.stringify({ controller_id: row.controller_id, phase, primary: errorRecord(primaryFailure), secondary: errorRecord(error) })}\n`);
|
|
264
228
|
};
|
|
265
229
|
try {
|
|
230
|
+
const currentState = state = JSON.parse(row.state_json);
|
|
231
|
+
const startup = physicalStartup(context.databaseDeadlineAt);
|
|
232
|
+
const assertStartingOwner = () => {
|
|
233
|
+
const current = requireController(context, row.controller_id);
|
|
234
|
+
if (current.owner_token !== row.owner_token || current.generation !== row.generation || current.owner_pid !== null || current.status !== "starting")
|
|
235
|
+
throw new AppError("controller_owner_mismatch", "Controller startup no longer owns this launch", 1);
|
|
236
|
+
if (["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
|
|
237
|
+
throw new AppError("invalid_run_state", "A terminal RUN cannot start a physical controller", 1);
|
|
238
|
+
};
|
|
239
|
+
const owner = await startup.birth(assertStartingOwner);
|
|
240
|
+
try {
|
|
241
|
+
claimControllerProcess(context, row, owner);
|
|
242
|
+
}
|
|
243
|
+
finally {
|
|
244
|
+
claimedProcess = row.owner_pid === process.pid;
|
|
245
|
+
}
|
|
246
|
+
if (!row.process_id || !row.process_lease_token)
|
|
247
|
+
throw new AppError("controller_owner_mismatch", "Managed owner has no resource lease", 1);
|
|
248
|
+
const confirmation = { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid, processGroupId: process.pid, ownerPid: process.pid };
|
|
249
|
+
const originalLease = prepareManagedProcessConfirmation(context, confirmation).lease_expires_at;
|
|
250
|
+
const confirmed = await startup.observe(() => confirmManagedProcess(context, confirmation), () => assertControllerLeaseAllowed(context, row), originalLease);
|
|
251
|
+
lease = controllerLeaseMonitor(attemptMs => renewManagedProcess({ ...context, resourceDatabaseMode: "prepared_existing", databaseDeadlineAt: Date.now() + attemptMs }, { id: row.process_id, leaseToken: row.process_lease_token }), confirmed.lease_expires_at);
|
|
252
|
+
// Resource and RUN commits are distinct effects. A RUN keepalive failure must
|
|
253
|
+
// neither erase a resource receipt nor grant RUN dispatch authority.
|
|
254
|
+
mergeLease = controllerLeaseMonitor(attemptMs => {
|
|
255
|
+
const expiry = new Date(Date.now() + 120_000).toISOString();
|
|
256
|
+
const db = context.db.withDeadline(Date.now() + attemptMs);
|
|
257
|
+
if (db.get("SELECT 1 FROM merge_requests WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching' LIMIT 1", [row.project_id, row.run_id, row.controller_id]))
|
|
258
|
+
db.writeTransaction(() => {
|
|
259
|
+
const guard = recoveryGuard({ ...context, db }, row.project_id, row.run_id);
|
|
260
|
+
if ((guard?.generation ?? 0) !== row.generation || ["draining", "sealed", "resuming"].includes(guard?.status ?? ""))
|
|
261
|
+
throw new AppError("controller_lease_lost", "RUN generation changed before MERGE keepalive", 1);
|
|
262
|
+
db.run("UPDATE merge_requests SET dispatch_lease_expires_at = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status = 'dispatching'", [expiry, context.now(), row.project_id, row.run_id, row.controller_id]);
|
|
263
|
+
});
|
|
264
|
+
return { owned: true, lease_expires_at: expiry };
|
|
265
|
+
});
|
|
266
|
+
const assertLeaseFence = () => {
|
|
267
|
+
assertControllerLeaseAllowed(context, row);
|
|
268
|
+
};
|
|
269
|
+
heartbeat = setInterval(() => {
|
|
270
|
+
lease.background(assertLeaseFence, () => { });
|
|
271
|
+
if (currentState.merge_claim)
|
|
272
|
+
mergeLease.background(assertLeaseFence, () => { });
|
|
273
|
+
}, 30_000);
|
|
274
|
+
heartbeat.unref();
|
|
275
|
+
const manifest = JSON.parse(row.manifest_json);
|
|
276
|
+
const assertPhysicalOwner = () => {
|
|
277
|
+
lease.assertNoRenewal();
|
|
278
|
+
if (currentState.merge_claim)
|
|
279
|
+
mergeLease.assertNoRenewal();
|
|
280
|
+
// This isolated read already bounds SQLite contention. A wall deadline
|
|
281
|
+
// before opening the reader mistakes scheduler delay for heartbeat loss;
|
|
282
|
+
// the observed row's actual expiry/token still fences productive dispatch.
|
|
283
|
+
assertManagedProcessLease(context, { id: row.process_id, leaseToken: row.process_lease_token, pid: process.pid });
|
|
284
|
+
const current = requireController(context, row.controller_id);
|
|
285
|
+
if (current.owner_pid !== process.pid || current.owner_token !== row.owner_token || !["running", "capturing", "waiting_for_user", "waiting_for_context", "waiting_for_children", "waiting_for_capacity", "waiting_for_merge"].includes(current.status))
|
|
286
|
+
throw new AppError("controller_owner_mismatch", "Controller no longer owns productive dispatch", 1);
|
|
287
|
+
};
|
|
288
|
+
const assertOwner = () => { assertPhysicalOwner(); assertRunMutationAllowed(context, row.project_id, row.run_id, row.generation); };
|
|
266
289
|
await lease.ensureConfirmed(assertLeaseFence);
|
|
267
|
-
if (
|
|
290
|
+
if (currentState.merge_claim)
|
|
268
291
|
await mergeLease.ensureConfirmed(assertLeaseFence);
|
|
269
292
|
context.db.writeTransaction(() => {
|
|
270
293
|
markControllerDispatchReady(context, row);
|
|
271
|
-
if (!
|
|
294
|
+
if (!currentState.recovery && (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) === row.generation && requireRun(context, row.project_id, row.run_id).status === "paused")
|
|
272
295
|
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, false);
|
|
273
296
|
});
|
|
274
297
|
appendEvent(context, row.controller_id, "owner_started", { pid: process.pid, generation: row.generation });
|
|
275
|
-
if (
|
|
276
|
-
await resumeControllerSession(context, row, manifest,
|
|
277
|
-
await executeController(context, row, manifest,
|
|
298
|
+
if (currentState.recovery && !["done", "failed", "cancelled"].includes(requireRun(context, row.project_id, row.run_id).status))
|
|
299
|
+
await resumeControllerSession(context, row, manifest, currentState, assertPhysicalOwner);
|
|
300
|
+
await executeController(context, row, manifest, currentState, assertOwner, assertPhysicalOwner, async () => { await lease.ensureConfirmed(assertLeaseFence); if (currentState.merge_claim)
|
|
278
301
|
await mergeLease.ensureConfirmed(assertLeaseFence); });
|
|
279
302
|
return controllerReceipt(requireController(context, row.controller_id));
|
|
280
303
|
}
|
|
281
304
|
catch (error) {
|
|
282
305
|
primaryFailure = error;
|
|
283
306
|
try {
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
if (control) {
|
|
295
|
-
// Control is a later state transition, not evidence that the causal
|
|
296
|
-
// failure which fenced admission disappeared.
|
|
297
|
-
context.db.run("UPDATE run_controllers SET status = CASE WHEN last_error_json IS NULL THEN 'control_requested' ELSE status END, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
|
|
298
|
-
appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
|
|
299
|
-
return controllerReceipt(requireController(context, row.controller_id));
|
|
307
|
+
const controlled = context.db.writeTransaction(() => {
|
|
308
|
+
const currentOwner = requireController(context, row.controller_id);
|
|
309
|
+
const sameBinding = currentOwner.owner_token === row.owner_token && currentOwner.generation === row.generation
|
|
310
|
+
&& currentOwner.process_id === row.process_id && currentOwner.process_lease_token === row.process_lease_token;
|
|
311
|
+
const ownsFailure = claimedProcess ? currentOwner.owner_pid === process.pid : row.owner_pid === null && currentOwner.owner_pid === null && row.status === "starting" && currentOwner.status === "starting";
|
|
312
|
+
if (!sameBinding || !ownsFailure || ["completed", "cancelled", "superseded"].includes(currentOwner.status))
|
|
313
|
+
throw error;
|
|
314
|
+
try {
|
|
315
|
+
if (state)
|
|
316
|
+
failControllerRecovery(context, row, state, error);
|
|
300
317
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
318
|
+
catch (secondary) {
|
|
319
|
+
retainSecondary("fail_controller_recovery", secondary);
|
|
320
|
+
}
|
|
321
|
+
if ((error instanceof AppError && ["run_recovery_guarded", "run_recovery_generation_stale"].includes(error.code) || (recoveryGuard(context, row.project_id, row.run_id)?.generation ?? 0) !== row.generation)
|
|
322
|
+
&& context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
|
|
323
|
+
const guard = recoveryGuard(context, row.project_id, row.run_id);
|
|
324
|
+
const control = guard && ["draining", "sealed"].includes(guard.status) && context.db.get("SELECT control_id, action FROM run_controls WHERE project_id = ? AND run_id = ? AND recovery_id = ? AND status IN ('requested','settled')", [row.project_id, row.run_id, guard.recovery_id]);
|
|
325
|
+
if (control) {
|
|
326
|
+
// Control is a later state transition, not evidence that the causal
|
|
327
|
+
// failure which fenced admission disappeared.
|
|
328
|
+
context.db.run("UPDATE run_controllers SET status = CASE WHEN last_error_json IS NULL THEN 'control_requested' ELSE status END, updated_at = ? WHERE controller_id = ? AND owner_token = ?", [context.now(), row.controller_id, row.owner_token]);
|
|
329
|
+
appendEvent(context, row.controller_id, "control_boundary_reached", { control_id: control.control_id, action: control.action, generation: guard.generation, settled: false });
|
|
330
|
+
return controllerReceipt(requireController(context, row.controller_id));
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
try {
|
|
304
334
|
// A failed capture must not reopen admission when this process exits.
|
|
305
335
|
const changed = context.db.run("UPDATE run_controllers SET status = CASE WHEN status IN ('capturing','capture_failed') THEN 'capture_failed' ELSE 'recovery_required' END, last_error_json = ?, updated_at = ? WHERE controller_id = ? AND owner_token = ? AND status NOT IN ('completed','cancelled','superseded')", [JSON.stringify(errorRecord(error)), context.now(), row.controller_id, row.owner_token]);
|
|
306
336
|
context.db.run("UPDATE merge_requests SET status = 'recovery_required', last_error_json = ?, updated_at = ? WHERE project_id = ? AND run_id = ? AND dispatch_owner = ? AND status IN ('dispatching','active')", [JSON.stringify(errorRecord(error)), context.now(), row.project_id, row.run_id, row.controller_id]);
|
|
@@ -308,11 +338,14 @@ export async function serveRunController(context, input) {
|
|
|
308
338
|
setFlowRunRecoveryPaused(context, row.project_id, row.run_id, true);
|
|
309
339
|
}
|
|
310
340
|
appendEvent(context, row.controller_id, "recovery_required", errorRecord(error));
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
341
|
+
}
|
|
342
|
+
catch (failure) {
|
|
343
|
+
retainSecondary("persist_controller_failure", failure);
|
|
344
|
+
}
|
|
345
|
+
return null;
|
|
346
|
+
});
|
|
347
|
+
if (controlled)
|
|
348
|
+
return controlled;
|
|
316
349
|
}
|
|
317
350
|
catch (secondary) {
|
|
318
351
|
retainSecondary("controller_failure_boundary", secondary);
|
|
@@ -320,12 +353,15 @@ export async function serveRunController(context, input) {
|
|
|
320
353
|
throw error;
|
|
321
354
|
}
|
|
322
355
|
finally {
|
|
323
|
-
lease
|
|
324
|
-
mergeLease
|
|
325
|
-
|
|
356
|
+
lease?.close();
|
|
357
|
+
mergeLease?.close();
|
|
358
|
+
if (heartbeat)
|
|
359
|
+
clearInterval(heartbeat);
|
|
326
360
|
finishOwner();
|
|
327
361
|
}
|
|
328
362
|
function finishOwner() {
|
|
363
|
+
if (!claimedProcess || row.owner_pid !== process.pid || !row.process_id || !row.process_lease_token)
|
|
364
|
+
return;
|
|
329
365
|
try {
|
|
330
366
|
finishManagedProcess(context, { id: row.process_id, leaseToken: row.process_lease_token, state: "stopped", reason: "controller_owner_exited" });
|
|
331
367
|
}
|
|
@@ -706,7 +742,7 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
706
742
|
assertCurrent: () => {
|
|
707
743
|
assertOwner();
|
|
708
744
|
},
|
|
709
|
-
decision: async () => {
|
|
745
|
+
decision: async (boundary) => {
|
|
710
746
|
if (kind === "child") {
|
|
711
747
|
const target = Object.entries(state.child_capacity ?? {}).find(([, retained]) => retained.operation_id === firstOperationId);
|
|
712
748
|
if (!target)
|
|
@@ -718,6 +754,11 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
718
754
|
}
|
|
719
755
|
const view = getFlowRunStatus(executionContext, { projectRoot: manifest.request.project_root, runId: row.run_id });
|
|
720
756
|
const next = view.continuation;
|
|
757
|
+
if (boundary) {
|
|
758
|
+
if (!ownedProviderBoundary(context, { projectId: row.project_id, runId: row.run_id, stage: state.stage, sessionId: session.id, generation: row.generation, scope: "stage" }, boundary))
|
|
759
|
+
return "reconciliation_blocked";
|
|
760
|
+
return boundary.outcome === "paused" ? "paused" : "completed";
|
|
761
|
+
}
|
|
721
762
|
if (next.kind === "blocked")
|
|
722
763
|
return "reconciliation_blocked";
|
|
723
764
|
if (next.kind === "paused" && next.stage !== state.stage)
|
|
@@ -1088,7 +1129,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
1088
1129
|
continue;
|
|
1089
1130
|
}
|
|
1090
1131
|
}
|
|
1091
|
-
const
|
|
1132
|
+
const memoryFlow = ["mb-init", "mb-upgrade"].includes(run.flow_kind);
|
|
1133
|
+
const fanoutInput = { projectRoot: run.project_root, runId: run.id, stage: next.stage, harness: runtimeHarness(session.profile), sessionId: session.id, children, policy, profiles, dispatchFence: retainedFailures.length > 0 };
|
|
1134
|
+
const fanout = memoryFlow ? await runControllerMemoryTask(executionContext, { operation: "fanout", input: fanoutInput }, cancellation.signal)
|
|
1135
|
+
: await nextControllerFanout(executionContext, fanoutInput);
|
|
1136
|
+
if (memoryFlow)
|
|
1137
|
+
await renewLease();
|
|
1092
1138
|
assertOwner();
|
|
1093
1139
|
if (fanout.kind === "none") {
|
|
1094
1140
|
// An ACK-only recovery Turn may leave the original root Work unfinished.
|
|
@@ -1129,7 +1175,12 @@ async function executeController(context, row, manifest, state, assertOwnership,
|
|
|
1129
1175
|
try {
|
|
1130
1176
|
await renewLease();
|
|
1131
1177
|
assertOwner();
|
|
1132
|
-
const receipt = await
|
|
1178
|
+
const receipt = memoryFlow ? await runControllerMemoryTask(executionContext, { operation: "external", input: { projectRoot: run.project_root, runId: run.id, workId: work.work_id, stage: next.stage } }, cancellation.signal)
|
|
1179
|
+
: await launchExternalWork(executionContext, { workId: work.work_id, stage: next.stage, signal: cancellation.signal });
|
|
1180
|
+
if (memoryFlow) {
|
|
1181
|
+
await renewLease();
|
|
1182
|
+
assertOwner();
|
|
1183
|
+
}
|
|
1133
1184
|
if (fatal)
|
|
1134
1185
|
throw fatal;
|
|
1135
1186
|
appendEvent(context, row.controller_id, "external_work_observed", { work_id: work.work_id, receipt });
|
|
@@ -1,6 +1,36 @@
|
|
|
1
|
+
import crypto from "node:crypto";
|
|
1
2
|
import { AppError } from "../shared/errors.js";
|
|
2
3
|
import { CAPACITY_POLICY, MAX_OVERLOAD_RETRIES, overloadExhausted } from "../harness-runtime/lib/provider-overload-policy.mjs";
|
|
3
4
|
import { assertRunStartupSettled } from "./run-start-custody.js";
|
|
5
|
+
export function recoveryPacketReceipt(binding) {
|
|
6
|
+
if (!binding.packet_json || !binding.packet_sha256 || crypto.createHash("sha256").update(binding.packet_json).digest("hex") !== binding.packet_sha256)
|
|
7
|
+
throw new AppError("recovery_packet_missing", "Recovery requires its original verified Work packet", 1);
|
|
8
|
+
return { packet_json: binding.packet_json, packet_sha256: binding.packet_sha256, packet: JSON.parse(binding.packet_json) };
|
|
9
|
+
}
|
|
10
|
+
/** A cooperative drain permits only the WorkSession present at its fence. */
|
|
11
|
+
export function assertWorkSettlementAllowed(context, projectId, runId, workId) {
|
|
12
|
+
const guard = recoveryGuard(context, projectId, runId);
|
|
13
|
+
if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
|
|
14
|
+
const admission = context.db.get("SELECT a.work_session_id FROM run_control_admissions a JOIN run_controls c ON c.control_id = a.control_id JOIN work_sessions ws ON ws.id = a.work_session_id JOIN works w ON w.work_id = a.work_id WHERE c.project_id = ? AND c.run_id = ? AND c.recovery_id = ? AND c.action = 'pause' AND c.status = 'requested' AND a.work_id = ? AND ws.work_id = a.work_id AND ws.session_id = a.session_id AND ws.status = 'running' AND w.status = 'running' AND w.project_id = c.project_id AND w.run_id = c.run_id LIMIT 1", [projectId, runId, guard.recovery_id, workId]);
|
|
15
|
+
if (admission)
|
|
16
|
+
return;
|
|
17
|
+
}
|
|
18
|
+
assertRunMutationAllowed(context, projectId, runId);
|
|
19
|
+
}
|
|
20
|
+
/** Completion rights are pinned to the Stage attempt present at the pause fence. */
|
|
21
|
+
export function assertStageSettlementAllowed(context, projectId, runId, stage, expectedGeneration) {
|
|
22
|
+
const guard = recoveryGuard(context, projectId, runId);
|
|
23
|
+
const generation = guard?.generation ?? 0;
|
|
24
|
+
if (expectedGeneration !== undefined && generation !== expectedGeneration)
|
|
25
|
+
throw new AppError("run_recovery_generation_stale", "RUN execution generation changed while the operation was in flight", 1, { run_id: runId, expected_generation: expectedGeneration, generation });
|
|
26
|
+
if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_control_stage_admissions'")) {
|
|
27
|
+
const admitted = context.db.get(`SELECT a.work_session_id FROM run_control_stage_admissions a JOIN run_controls c ON c.control_id = a.control_id JOIN run_control_admissions w ON w.control_id = a.control_id AND w.work_session_id = a.work_session_id JOIN work_sessions ws ON ws.id = w.work_session_id JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id, json_each(r.index_json, '$.stage_runs') live
|
|
28
|
+
WHERE c.project_id = ? AND c.run_id = ? AND c.recovery_id = ? AND c.action = 'pause' AND c.status = 'requested' AND a.stage = ? AND json_extract(live.value, '$.stage') = a.stage AND COALESCE(json_extract(live.value, '$.attempt'), 'try-001') = a.attempt AND ws.work_id = w.work_id AND ws.session_id = w.session_id AND NOT EXISTS (SELECT 1 FROM work_sessions newer WHERE newer.work_id = w.work_id AND newer.status = 'running' AND newer.id <> ws.id) LIMIT 1`, [projectId, runId, guard.recovery_id, stage]);
|
|
29
|
+
if (admitted)
|
|
30
|
+
return generation;
|
|
31
|
+
}
|
|
32
|
+
return assertRunMutationAllowed(context, projectId, runId, expectedGeneration);
|
|
33
|
+
}
|
|
4
34
|
export function recoveryGuard(context, projectId, runId) { return context.db.get("SELECT recovery_id, project_id, run_id, generation, status, interruption_json, settlement_json, capture_path, created_at, updated_at, sealed_at, resumed_at FROM run_recovery_guards WHERE project_id = ? AND run_id = ? ORDER BY generation DESC LIMIT 1", [projectId, runId]) ?? null; }
|
|
5
35
|
export function assertRunRecoveryAllowed(context, projectId, runId, expectedGeneration) {
|
|
6
36
|
const guard = recoveryGuard(context, projectId, runId);
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { hasLifecyclePacketAssignments, managedLifecycleCommand, recoverLifecycleCommands } from "./lifecycle-invocations.js";
|
|
2
|
-
import { recoveryGuard,
|
|
2
|
+
import { recoveryGuard, recoveryPacketReceipt } from "./run-recovery-guard.js";
|
|
3
|
+
export { assertStageSettlementAllowed, assertWorkSettlementAllowed, recoveryPacketReceipt } from "./run-recovery-guard.js";
|
|
3
4
|
export { recoveryGuard, assertRunMutationAllowed, assertRunRecoveryAllowed } from "./run-recovery-guard.js";
|
|
4
5
|
import crypto from "node:crypto";
|
|
5
6
|
import fs from "node:fs";
|
|
@@ -19,11 +20,6 @@ import { findRecentMatchingHookEvent, hookSessionIdentity, recoveryAcceptMatchKe
|
|
|
19
20
|
import { flowCommand } from "./stage-pause.js";
|
|
20
21
|
import { materializePendingWorkResults } from "./work-registry.js";
|
|
21
22
|
import { currentStagePrompt } from "./stage-context.js";
|
|
22
|
-
export function recoveryPacketReceipt(binding) {
|
|
23
|
-
if (!binding.packet_json || !binding.packet_sha256 || crypto.createHash("sha256").update(binding.packet_json).digest("hex") !== binding.packet_sha256)
|
|
24
|
-
throw new AppError("recovery_packet_missing", "Recovery requires its original verified Work packet", 1);
|
|
25
|
-
return { packet_json: binding.packet_json, packet_sha256: binding.packet_sha256, packet: JSON.parse(binding.packet_json) };
|
|
26
|
-
}
|
|
27
23
|
/** Read-only custody permission for one prepared recovery ACK, not lifecycle admission. */
|
|
28
24
|
export class PreparedRecoveryCoordinatorRead {
|
|
29
25
|
#scope;
|
|
@@ -90,12 +86,16 @@ export function recoveredCoordinatorBinding(original, input) {
|
|
|
90
86
|
export function capturedStageDelivery(capture, scope, originalPrompt) {
|
|
91
87
|
const db = new DatabaseSync(path.join(capture, "runtime", "db.sqlite"), { readOnly: true });
|
|
92
88
|
try {
|
|
89
|
+
const resumed = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name='audit_events'").get()
|
|
90
|
+
? db.prepare("SELECT json_extract(payload_json, '$.pause_id') AS pause_id FROM audit_events WHERE event_type = 'flow_run.stage_resumed' AND json_extract(payload_json, '$.run_id') = ? AND json_extract(payload_json, '$.work_id') = ? AND json_extract(payload_json, '$.stage') = ? ORDER BY id DESC LIMIT 1").get(scope.runId, scope.workId, scope.stage) : undefined;
|
|
93
91
|
const rows = db.prepare("SELECT command, outcome_json FROM lifecycle_invocations WHERE status = 'settled' AND json_extract(scope_json, '$.projectRoot') = ? AND json_extract(scope_json, '$.runId') = ? AND json_extract(scope_json, '$.rootSessionId') = ? AND json_extract(scope_json, '$.generation') < ? ORDER BY rowid DESC").all(scope.projectRoot, scope.runId, scope.sessionId, scope.generation);
|
|
94
92
|
for (const row of rows) {
|
|
95
93
|
const result = row.outcome_json ? JSON.parse(row.outcome_json).result : null;
|
|
96
94
|
const parsed = parseLifecycleCommand(row.command);
|
|
97
|
-
if (parsed.kind === "none" || !parsed.supported || parsed.invocation.operation
|
|
98
|
-
|| result?.stage !== scope.stage || result.attempt !== scope.attempt || result.work_id !== scope.workId || typeof result.worker_prompt_markdown !== "string")
|
|
95
|
+
if (parsed.kind === "none" || !parsed.supported || !["stage_start", "stage_resume"].includes(parsed.invocation.operation) || parsed.invocation.args.options.get("stage")?.[0] !== scope.stage
|
|
96
|
+
|| result?.stage !== scope.stage || parsed.invocation.operation === "stage_start" && result.attempt !== scope.attempt || result.work_id !== scope.workId || typeof result.worker_prompt_markdown !== "string")
|
|
97
|
+
continue;
|
|
98
|
+
if (resumed && (parsed.invocation.operation !== "stage_resume" || result.pause_id !== resumed.pause_id))
|
|
99
99
|
continue;
|
|
100
100
|
const text = result.worker_prompt_markdown;
|
|
101
101
|
if (!text.startsWith(originalPrompt))
|
|
@@ -105,6 +105,8 @@ export function capturedStageDelivery(capture, scope, originalPrompt) {
|
|
|
105
105
|
throw new AppError("recovery_packet_missing", "Captured Stage delivery has no committed lifecycle assignment", 1);
|
|
106
106
|
return { text, sha256 };
|
|
107
107
|
}
|
|
108
|
+
if (resumed)
|
|
109
|
+
throw new AppError("recovery_packet_missing", "Accepted HITL answer has no captured settled current delivery; reconcile its retained resume receipt, never fall back to the pre-answer prompt", 1, { work_id: scope.workId, pause_id: resumed.pause_id });
|
|
108
110
|
return null;
|
|
109
111
|
}
|
|
110
112
|
finally {
|
|
@@ -174,42 +176,7 @@ export function prepareRecoveryPayload(value, label) {
|
|
|
174
176
|
throw new AppError("recovery_not_settled", "Recovery capture requires an explicit settled tree receipt", 1);
|
|
175
177
|
return payload;
|
|
176
178
|
}
|
|
177
|
-
|
|
178
|
-
export function assertWorkSettlementAllowed(context, projectId, runId, workId) {
|
|
179
|
-
const guard = recoveryGuard(context, projectId, runId);
|
|
180
|
-
if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_controls'")) {
|
|
181
|
-
const admission = context.db.get("SELECT a.work_session_id FROM run_control_admissions a JOIN run_controls c ON c.control_id = a.control_id JOIN work_sessions ws ON ws.id = a.work_session_id JOIN works w ON w.work_id = a.work_id WHERE c.project_id = ? AND c.run_id = ? AND c.recovery_id = ? AND c.action = 'pause' AND c.status = 'requested' AND a.work_id = ? AND ws.work_id = a.work_id AND ws.session_id = a.session_id AND ws.status = 'running' AND w.status = 'running' AND w.project_id = c.project_id AND w.run_id = c.run_id LIMIT 1", [projectId, runId, guard.recovery_id, workId]);
|
|
182
|
-
if (admission)
|
|
183
|
-
return;
|
|
184
|
-
}
|
|
185
|
-
assertRunMutationAllowed(context, projectId, runId);
|
|
186
|
-
}
|
|
187
|
-
/** Completion rights are pinned to the Stage attempt present at the pause fence. */
|
|
188
|
-
export function assertStageSettlementAllowed(context, projectId, runId, stage, expectedGeneration) {
|
|
189
|
-
const guard = recoveryGuard(context, projectId, runId);
|
|
190
|
-
const generation = guard?.generation ?? 0;
|
|
191
|
-
if (expectedGeneration !== undefined && generation !== expectedGeneration)
|
|
192
|
-
throw new AppError("run_recovery_generation_stale", "RUN execution generation changed while the operation was in flight", 1, { run_id: runId, expected_generation: expectedGeneration, generation });
|
|
193
|
-
if (guard?.status === "draining" && context.db.get("SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'run_control_stage_admissions'")) {
|
|
194
|
-
const admitted = context.db.get(`SELECT a.work_session_id FROM run_control_stage_admissions a JOIN run_controls c ON c.control_id = a.control_id JOIN run_control_admissions w ON w.control_id = a.control_id AND w.work_session_id = a.work_session_id JOIN work_sessions ws ON ws.id = w.work_session_id JOIN runs r ON r.project_id = c.project_id AND r.id = c.run_id, json_each(r.index_json, '$.stage_runs') live
|
|
195
|
-
WHERE c.project_id = ? AND c.run_id = ? AND c.recovery_id = ? AND c.action = 'pause' AND c.status = 'requested' AND a.stage = ? AND json_extract(live.value, '$.stage') = a.stage AND COALESCE(json_extract(live.value, '$.attempt'), 'try-001') = a.attempt AND ws.work_id = w.work_id AND ws.session_id = w.session_id AND NOT EXISTS (SELECT 1 FROM work_sessions newer WHERE newer.work_id = w.work_id AND newer.status = 'running' AND newer.id <> ws.id) LIMIT 1`, [projectId, runId, guard.recovery_id, stage]);
|
|
196
|
-
if (admitted)
|
|
197
|
-
return generation;
|
|
198
|
-
}
|
|
199
|
-
return assertRunMutationAllowed(context, projectId, runId, expectedGeneration);
|
|
200
|
-
}
|
|
201
|
-
/** A recovered RUN never grants the retired process a new execution segment. */
|
|
202
|
-
export function assertRecoveryHookCurrent(context, projectId, runId, identity) {
|
|
203
|
-
const generation = assertRunMutationAllowed(context, projectId, runId);
|
|
204
|
-
if (!generation)
|
|
205
|
-
return;
|
|
206
|
-
const current = identity.daemonId && context.db.get("SELECT b.recovery_id FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND g.run_id = ? AND g.generation = ? AND g.status = 'recovered' AND b.status = 'accepted' AND b.daemon_id = ? LIMIT 1", [projectId, runId, generation, identity.daemonId]);
|
|
207
|
-
if (current)
|
|
208
|
-
return;
|
|
209
|
-
const retired = identity.daemonId && (context.db.get(`SELECT ws.id FROM work_sessions ws JOIN works w ON w.work_id = ws.work_id JOIN hook_events he ON he.project_id = w.project_id AND he.event_key = ws.hook_event_id WHERE w.project_id = ? AND w.run_id = ? AND he.daemon_id = ? LIMIT 1`, [projectId, runId, identity.daemonId]) || context.db.get("SELECT b.recovery_id FROM run_recovery_bindings b JOIN run_recovery_guards g ON g.recovery_id = b.recovery_id WHERE g.project_id = ? AND g.run_id = ? AND b.daemon_id = ? AND (g.generation <> ? OR b.status = 'abandoned') LIMIT 1", [projectId, runId, identity.daemonId, generation]));
|
|
210
|
-
if (!identity.daemonId || retired)
|
|
211
|
-
throw new AppError("recovery_daemon_stale", "Lifecycle receipt belongs to an unidentified or retired recovery daemon", 1, { run_id: runId, generation, daemon_id: identity.daemonId });
|
|
212
|
-
}
|
|
179
|
+
export { assertRecoveryHookCurrent } from "./hook-recovery-guard.js";
|
|
213
180
|
export function beginRunRecovery(context, input, interruption = prepareRecoveryPayload(input.interruption, "interruption")) {
|
|
214
181
|
const project = requireProjectByRoot(context, resolveProjectRoot(input.projectRoot));
|
|
215
182
|
const run = requireRecoveryRun(context, project.id, input.runId);
|
|
@@ -1,8 +1,12 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import path from "node:path";
|
|
3
3
|
import { AppError } from "../shared/errors.js";
|
|
4
|
-
import { hybridDigest } from "./hybrid-
|
|
4
|
+
import { hybridDigest } from "./hybrid-digest.js";
|
|
5
|
+
import { runContextRelocations, projectPacketLocators } from "./stage-context.js";
|
|
5
6
|
export const runStartPayloadLimit = 64 * 1024 * 1024;
|
|
7
|
+
// One exact immutable payload per database, not cached custody or returned
|
|
8
|
+
// objects. Every read still checks current SQL hashes, markers and receipts.
|
|
9
|
+
const validatedPayloadBytes = new WeakMap();
|
|
6
10
|
export function runStartBytesSha256(bytes) { return createHash("sha256").update(bytes).digest("hex"); }
|
|
7
11
|
export function refuseRunStart(reason) {
|
|
8
12
|
throw new AppError("run_start_custody_invalid", "RUN creation requires its exact retained request", 1, { reason, effect: "no_effect", next_action: "reconcile_exact_request" });
|
|
@@ -59,8 +63,11 @@ export function validateRunStartPayload(value) {
|
|
|
59
63
|
|| !entry.path || entry.path.includes("\\") || entry.path.includes("\0") || entry.path.split("/").some(part => !part || part === "." || part === "..")
|
|
60
64
|
|| seen.has(entry.path) || !canonicalBase64(entry.base64))
|
|
61
65
|
refuseRunStart("invalid_allocation_assets");
|
|
62
|
-
|
|
63
|
-
|| Buffer.from(entry.base64, "base64").
|
|
66
|
+
const upgradeEvidence = value.initial_index.flow_kind === "mb-upgrade" && (["upgrade-baseline.json", "upgrade-target.json"].includes(entry.path)
|
|
67
|
+
|| entry.path === `upgrade-target-${runStartBytesSha256(Buffer.from(entry.base64, "base64"))}.bin`
|
|
68
|
+
|| entry.path === `upgrade-baseline-${runStartBytesSha256(Buffer.from(entry.base64, "base64"))}.bin`);
|
|
69
|
+
if (key === "artifacts" && (!["engine-binding.json", "operational-policy.source.md", "operational-contract.json"].includes(entry.path) && !upgradeEvidence
|
|
70
|
+
|| Buffer.from(entry.base64, "base64").length > (upgradeEvidence ? 16 : 1) * 1024 * 1024))
|
|
64
71
|
refuseRunStart("invalid_allocation_artifact");
|
|
65
72
|
seen.add(entry.path);
|
|
66
73
|
}
|
|
@@ -96,7 +103,10 @@ function readRunStartRequestSnapshot(context, projectId, requestId) {
|
|
|
96
103
|
catch {
|
|
97
104
|
return refuseRunStart("invalid_allocation_json");
|
|
98
105
|
}
|
|
99
|
-
|
|
106
|
+
if (validatedPayloadBytes.get(context.db) !== row.payload_json) {
|
|
107
|
+
validateRunStartPayload(payload);
|
|
108
|
+
validatedPayloadBytes.set(context.db, row.payload_json);
|
|
109
|
+
}
|
|
100
110
|
if (!object(receipt))
|
|
101
111
|
refuseRunStart("invalid_allocation_receipt");
|
|
102
112
|
const run = context.db.get("SELECT project_root, workspace_root, run_root, flow_kind, subject_type, subject_id, index_json FROM runs WHERE project_id = ? AND id = ?", [projectId, row.run_id]);
|
|
@@ -114,6 +124,8 @@ function readRunStartRequestSnapshot(context, projectId, requestId) {
|
|
|
114
124
|
if (!object(index))
|
|
115
125
|
refuseRunStart("allocated_run_changed");
|
|
116
126
|
const marker = index.start_request, initial = payload.initial_index;
|
|
127
|
+
const relocations = runContextRelocations(context, projectId, row.run_id);
|
|
128
|
+
const currentPath = (value) => projectPacketLocators(value, relocations);
|
|
117
129
|
if (!payload || payload.schema_id !== "dd-flow/run-start-allocation@1" || payload.project_id !== projectId
|
|
118
130
|
|| payload.request_id !== requestId || payload.run_id !== row.run_id || !Array.isArray(payload.blobs) || !Array.isArray(payload.artifacts)
|
|
119
131
|
|| !initial || initial.run_id !== row.run_id || initial.project?.id !== projectId || initial.start_request !== undefined
|
|
@@ -121,7 +133,7 @@ function readRunStartRequestSnapshot(context, projectId, requestId) {
|
|
|
121
133
|
|| !marker || marker.schema_id !== "dd-flow/run-start-request@1" || marker.request_id !== requestId
|
|
122
134
|
|| marker.request_sha256 !== payload.request_sha256 || marker.allocation_sha256 !== row.payload_sha256
|
|
123
135
|
|| receipt.run_id !== row.run_id || receipt.request_id !== requestId || receipt.request_sha256 !== payload.request_sha256 || receipt.allocation_sha256 !== row.payload_sha256
|
|
124
|
-
|| run.project_root !== payload.project_root || run.workspace_root !== payload.workspace_root || run.run_root !== payload.run_root
|
|
136
|
+
|| run.project_root !== currentPath(payload.project_root) || run.workspace_root !== currentPath(payload.workspace_root) || run.run_root !== currentPath(payload.run_root)
|
|
125
137
|
|| run.flow_kind !== initial.flow_kind || run.subject_id !== payload.subject_id || run.subject_type !== payload.subject_type
|
|
126
138
|
|| !object(index.execution_bundle) || hybridDigest(index.execution_bundle) !== hybridDigest(initial.execution_bundle))
|
|
127
139
|
refuseRunStart("allocated_run_changed");
|