import { existsSync } from 'node:fs'; import { resolveWorkspaceRootFromEnv } from './workspace-root.js'; import { join } from 'node:path'; import { artifactPathFor, writeArtifact } from './artifact-store.js'; import { readStageCheckpoint, writeStageCheckpoint } from './checkpoints.js'; import { autoFixDirectorStoryboardContent, runDirectorPreflight } from './director-preflight.js'; import { appendProjectEvent } from './events.js'; import { buildExecutionPlan } from './execution-plan.js'; import { buildShowPreflight } from './show-preflight.js'; import { writeStoryBibleForProject } from './story-bible.js'; import { auditProjectConsistency, hasVisionKey } from './consistency-audit.js'; import { writeTextFileAtomic } from './atomic-write.js'; import { EXECUTION_RUN_HEARTBEAT_MS, clearExecutionRunMarker, touchExecutionRunMarker, writeExecutionRunMarker, type ExecutionRunMarkerHandle, } from './execution-run-marker.js'; import { appendGenerationTelemetry, buildGenerationTelemetryFromReport, } from './generation-telemetry.js'; import { promptHashOf } from './lane-queue.js'; import { createLaneCoordinator } from './lane-coordinator.js'; import type { LaneCoordinatorPort } from './lane-coordinator.js'; import { ROUTE_CAPABILITIES } from './provider-platform/route-capabilities.js'; import { buildExecutionPayload, ChainFromPrevSourceMissingError, submitExecutionPayload, } from './execution-runtime.js'; import { appendCandidate, maxRoundForScene, nextCandidateId, } from './scene-candidates.js'; import { readSceneCandidatesArtifact, sceneCandidatesPathFor, withSceneArtifactsLock, writeSceneCandidatesArtifact, } from './scene-candidate-store.js'; import { markPending, } from './scene-selection.js'; import { readSceneSelectionArtifact, writeSceneSelectionArtifact, } from './scene-selection-store.js'; import { buildRunContract, captureSubmittedReferenceHashes, readRunContract, writeRunContract } from './run-status.js'; import { previewProviderWire, type ProviderWirePreview } from './provider-wire-preview.js'; import { recordLaneReceipt, terminalReceiptPayload } from './lane-receipts.js'; import { assertApprovedRunContract, runContractApprovalHash, submitEnvironmentFingerprint, submitEnvironmentFor } from './run-contract-approval.js'; import { VclawError } from './errors.js'; import { announceProviderHumanCheck, recordProviderHumanCheckCooldown } from './provider-human-check.js'; import { regenerateRunSurface } from './preview-portal/index.js'; import { buildProjectStatusReport } from './status.js'; import { buildStoryboardApprovalCommand, isStoryboardApproved, writeStoryboardMarkdownReview } from './storyboard-markdown.js'; import { appendVideoContextChangelog } from './video-context.js'; import { updateProjectManifestState, ensureProjectWorkspace } from './workspace.js'; import type { ProviderRouteId } from './provider-platform/types.js'; import type { SceneCandidate, SceneCandidatesArtifact, SceneSelectionArtifact, VideoExecutionReport, VideoProductionMode, } from './types.js'; function shellQuote(value: string): string { return JSON.stringify(value); } function buildStoryboardRefreshCommand(projectSlug: string, root: string): string { return [ 'vclaw', 'video', 'storyboard-review', '--project', shellQuote(projectSlug), '--root', shellQuote(root), '--mode', 'director', ].join(' '); } export async function executeProject( projectSlug: string, options: { root?: string; productionMode?: VideoProductionMode; dryRun?: boolean; env?: NodeJS.ProcessEnv; /** * Restrict execution to these scene indices. When omitted, all storyboard * scenes are submitted (legacy behavior). Presence of this flag also * forces candidate-mode on for this run even if `scene-candidates.json` * does not yet exist. */ sceneIndices?: number[]; /** * PHASE-3 opt-in continuity loop. When true, chain-from-prev tasks get a * continuity cue (Gemini from the prior keyframe, else deterministic * story-bible descriptors) and the full descriptor block re-pasted into the * prompt. Default-off path is byte-identical to today. */ continuityFeedback?: boolean; /** Injected fetch for the continuity Gemini call (tests). */ continuityFetcher?: typeof fetch; /** * Pin this single run to one provider route, overriding the project's * routePreference / mode defaults. Used by the `render-scenes` fallback * ladder, which submits each scene on one specific route per attempt and * escalates to the next route when this one yields a blocked report. Omitted * → the plan resolves the route as usual (byte-identical legacy). */ routeOverride?: ProviderRouteId; /** * The `contractApprovalHash` a reviewed `--dry-run` printed. When set, the * payload about to be submitted is hashed the same way BEFORE the lane is * taken or the provider is called, and a mismatch refuses the run. */ requireContractHash?: string; /** Injectable render-queue coordinator (tests). Default: chosen from the environment. */ laneCoordinator?: LaneCoordinatorPort; } = {}, ): Promise<{ reportPath: string; report: VideoExecutionReport }> { const root = options.root ?? resolveWorkspaceRootFromEnv(); const productionMode = options.productionMode ?? 'storyboard'; const dryRun = options.dryRun ?? false; if (options.requireContractHash && options.continuityFeedback) { // The continuity loop can ask a vision model for cues (temperature > 0, no // seed) and writes the answer into the prompt, which the contract hashes. The // dry run and the live run would make two different calls, so the gate could // never pass and another dry run would only mint a third hash. throw new VclawError( 'invalid_flag_value', '--require-contract cannot be combined with --continuity-feedback: the continuity cues are generated afresh on every run, so the reviewed prompt and the submitted prompt can never match. Review and submit without --continuity-feedback, or drop --require-contract for this run.', { flags: ['--require-contract', '--continuity-feedback'] }, ); } const sceneIndices = options.sceneIndices; const workspace = await ensureProjectWorkspace(projectSlug, root); const plan = await buildExecutionPlan(projectSlug, root, productionMode, { env: options.env, ...(options.routeOverride ? { routeOverride: options.routeOverride } : {}), // A scene-scoped run (`--scenes`, or any per-scene driver rung) only gates // on ITS scenes: one earlier scene left unselected by a crashed poll no // longer blocks every later scene of the batch. ...(sceneIndices ? { sceneScope: sceneIndices } : {}), }); const generatedAt = new Date().toISOString(); const assetManifestPath = artifactPathFor(workspace, 'asset-manifest'); const assetsCheckpointArtifacts = (reportPath: string): Record => ({ ...(existsSync(assetManifestPath) ? { 'asset-manifest': assetManifestPath } : {}), 'execution-report': reportPath, }); // Candidate-mode detection. A project enters candidate mode when either: // 1. `scene-candidates.json` already exists (operator has opted in, or a // prior run was in candidate mode); or // 2. `--scene ` was passed for this run (partial rerun implies // candidates). // When neither signal is present, we stay on the legacy path — direct // asset-manifest writes, no candidate artifact touched. const candidateArtifactExists = existsSync(sceneCandidatesPathFor(root, projectSlug)); const candidateMode = candidateArtifactExists || Array.isArray(sceneIndices); if (!plan.ready || !plan.recommendedRouteId) { const blockedReport: VideoExecutionReport = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun, generatedAt, blockers: [...plan.blockers], executedSteps: ['validated-readiness', 'selected-provider-route'], }; const blockedReportPath = await writeArtifact(workspace, 'execution-report', blockedReport); await appendProjectEvent(workspace, { type: 'execution.report.written', recordedAt: blockedReport.generatedAt, payload: { reportPath: blockedReportPath, status: blockedReport.status, routeId: blockedReport.routeId, dryRun: blockedReport.dryRun }, }); await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt: blockedReport.generatedAt, artifacts: assetsCheckpointArtifacts(blockedReportPath), summary: 'Execution blocked.', issues: blockedReport.blockers, nextAction: 'Resolve blockers or add runtime support.', }); await updateProjectManifestState(workspace, { updatedAt: blockedReport.generatedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); return { reportPath: blockedReportPath, report: blockedReport }; } const skipDirectorPreflight = (options.env ?? process.env).SKIP_DIRECTOR_PREFLIGHT === '1'; if (plan.productionMode === 'director' && (options.env ?? process.env).DIRECTOR_AUTO_FIX_CONTENT === '1') { const autoFix = await autoFixDirectorStoryboardContent(projectSlug, root); if (autoFix) { await writeStoryBibleForProject(workspace); await appendVideoContextChangelog( root, `${generatedAt} director-auto-fix: applied ${autoFix.changeCount} content substitution(s) for project ${projectSlug}.`, ); } } const directorPreflight = plan.productionMode === 'director' && !skipDirectorPreflight ? await runDirectorPreflight(projectSlug, root) : null; if (directorPreflight && !directorPreflight.pass) { const review = await writeStoryboardMarkdownReview({ projectSlug, root, executionPlan: plan, preflight: directorPreflight, generatedAt, }); const report: VideoExecutionReport = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun, generatedAt, blockers: directorPreflight.errors.map((issue) => issue.message), executedSteps: ['validated-readiness', 'selected-provider-route', 'ran-director-preflight', 'rendered-storyboard-review'], }; const reportPath = await writeArtifact(workspace, 'execution-report', report); await appendProjectEvent(workspace, { type: 'director.preflight.blocked', recordedAt: generatedAt, payload: { markdownPath: review.markdownPath, reportPath, errorCount: directorPreflight.errors.length }, }); await writeStageCheckpoint(workspace, { stage: 'storyboard', status: 'failed', generatedAt, artifacts: { storyboard: artifactPathFor(workspace, 'storyboard'), 'execution-report': reportPath, }, summary: 'Director preflight blocked execution before provider submission.', issues: directorPreflight.errors.map((issue) => issue.message), nextAction: `Review ${review.markdownPath}, fix the preflight errors, and rerun director execution.`, }); await updateProjectManifestState(workspace, { updatedAt: generatedAt, currentStage: 'storyboard', lastCompletedStage: 'brief', lastCheckpointStatus: 'failed', }); await appendVideoContextChangelog( root, `${generatedAt} director-preflight: blocked project ${projectSlug} before provider submission.`, ); return { reportPath, report }; } if (plan.productionMode === 'director' && skipDirectorPreflight) { await appendProjectEvent(workspace, { type: 'director.preflight.skipped', recordedAt: generatedAt, payload: { reason: 'SKIP_DIRECTOR_PREFLIGHT=1' }, }); await appendVideoContextChangelog( root, `${generatedAt} director-preflight: skipped for project ${projectSlug}.`, ); } const approvalStatus = plan.productionMode === 'director' ? await buildProjectStatusReport(projectSlug, root, plan.productionMode) : null; if (plan.productionMode === 'director' && approvalStatus?.storyboardReviewStale) { const reviewPath = approvalStatus.storyboardReviewPath ?? join(workspace.projectDir, 'storyboard.md'); const refreshCommand = buildStoryboardRefreshCommand(projectSlug, root); const report: VideoExecutionReport = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun, generatedAt, blockers: [ `Storyboard review is stale. Refresh ${reviewPath} with: ${refreshCommand}`, ], executedSteps: ['validated-readiness', 'selected-provider-route', 'checked-review-freshness'], }; const reportPath = await writeArtifact(workspace, 'execution-report', report); await appendProjectEvent(workspace, { type: 'storyboard.review.stale.blocked', recordedAt: generatedAt, payload: { reportPath, markdownPath: reviewPath }, }); await writeStageCheckpoint(workspace, { stage: 'storyboard', status: 'awaiting-approval', generatedAt, artifacts: { storyboard: artifactPathFor(workspace, 'storyboard'), 'execution-report': reportPath, }, summary: 'Director execution blocked because the storyboard review is stale.', issues: report.blockers, nextAction: `Refresh ${reviewPath} with: ${refreshCommand}`, }); await updateProjectManifestState(workspace, { updatedAt: generatedAt, currentStage: 'storyboard', lastCompletedStage: 'brief', lastCheckpointStatus: 'awaiting-approval', }); await appendVideoContextChangelog( root, `${generatedAt} storyboard-review: stale review blocked execution for project ${projectSlug}.`, ); return { reportPath, report }; } if (plan.productionMode === 'director' && !isStoryboardApproved(options.env ?? process.env)) { const review = await writeStoryboardMarkdownReview({ projectSlug, root, executionPlan: plan, preflight: directorPreflight ?? undefined, generatedAt, }); const report: VideoExecutionReport = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun, generatedAt, blockers: [ `Storyboard approval required before director execution. Review ${review.markdownPath} and rerun with: ${buildStoryboardApprovalCommand(projectSlug, root)}`, ], executedSteps: ['validated-readiness', 'selected-provider-route', 'ran-director-preflight', 'rendered-storyboard-review'], }; const reportPath = await writeArtifact(workspace, 'execution-report', report); await appendProjectEvent(workspace, { type: 'storyboard.approval.required', recordedAt: generatedAt, payload: { markdownPath: review.markdownPath, reportPath }, }); await writeStageCheckpoint(workspace, { stage: 'storyboard', status: 'awaiting-approval', generatedAt, artifacts: { storyboard: artifactPathFor(workspace, 'storyboard'), 'execution-report': reportPath, }, summary: 'Director execution is waiting for storyboard approval.', issues: [], nextAction: `Review ${review.markdownPath} and rerun with: ${buildStoryboardApprovalCommand(projectSlug, root)}`, }); await updateProjectManifestState(workspace, { updatedAt: generatedAt, currentStage: 'storyboard', lastCompletedStage: 'brief', lastCheckpointStatus: 'awaiting-approval', }); await appendVideoContextChangelog( root, `${generatedAt} storyboard-approval: review required for project ${projectSlug}.`, ); return { reportPath, report }; } if (plan.productionMode === 'director' && isStoryboardApproved(options.env ?? process.env)) { const storyboardCheckpoint = await readStageCheckpoint(workspace, 'storyboard'); await writeStageCheckpoint(workspace, { stage: 'storyboard', status: 'completed', generatedAt, artifacts: { storyboard: artifactPathFor(workspace, 'storyboard'), }, summary: storyboardCheckpoint?.status === 'awaiting-approval' ? 'Storyboard approved for director execution.' : (storyboardCheckpoint?.summary ?? 'Storyboard artifact created.'), issues: [], nextAction: 'Proceed to asset-stage execution.', }); await updateProjectManifestState(workspace, { updatedAt: generatedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'completed', }); } // Show-preflight gate (the cartoon-show method, ENFORCED). Only runs when a // show-bible.json exists — a project that never adopted the bible is inert // (buildShowPreflight returns bibleAdopted:false / ready:true), so legacy // pipelines never start refusing. Honors SKIP_SHOW_PREFLIGHT=1 (mirrors // SKIP_DIRECTOR_PREFLIGHT). Wiring it here covers every produce/execute path — // --auto-chain, studio's shelled-out produce, and approve — automatically. const skipShowPreflight = (options.env ?? process.env).SKIP_SHOW_PREFLIGHT === '1'; if (!skipShowPreflight) { const preflight = await buildShowPreflight(projectSlug, root, plan.productionMode, { env: options.env ?? process.env, }); if (preflight.bibleAdopted && preflight.blockers.length > 0) { const report: VideoExecutionReport = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun, generatedAt, blockers: preflight.blockers, executedSteps: ['validated-readiness', 'selected-provider-route', 'ran-show-preflight'], }; const reportPath = await writeArtifact(workspace, 'execution-report', report); await appendProjectEvent(workspace, { type: 'execution.report.written', recordedAt: report.generatedAt, payload: { reportPath, status: report.status, routeId: report.routeId, dryRun: report.dryRun }, }); await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt: report.generatedAt, artifacts: assetsCheckpointArtifacts(reportPath), summary: 'Execution blocked: show-preflight found missing cartoon-show references.', issues: report.blockers, nextAction: preflight.nextAction, }); await updateProjectManifestState(workspace, { updatedAt: report.generatedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); return { reportPath, report }; } } let payload; try { payload = await buildExecutionPayload(projectSlug, plan, root, { ...(sceneIndices ? { sceneIndices } : {}), resolveChainSeeds: candidateMode, env: options.env ?? process.env, ...(options.continuityFeedback ? { continuityFeedback: true } : {}), ...(options.continuityFetcher ? { continuityFetcher: options.continuityFetcher } : {}), }); } catch (error) { // chain-from-prev-source-missing is an operator-visible hard fail — wire it // through the execution report so the CLI returns a structured blocker. if (error instanceof ChainFromPrevSourceMissingError) { const report: VideoExecutionReport = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun, generatedAt, blockers: [error.message], executedSteps: ['validated-readiness', 'selected-provider-route', 'prepared-provider-adapter-payload'], }; const reportPath = await writeArtifact(workspace, 'execution-report', report); await appendProjectEvent(workspace, { type: 'execution.report.written', recordedAt: report.generatedAt, payload: { reportPath, status: report.status, routeId: report.routeId, dryRun: report.dryRun }, }); await writeStageCheckpoint(workspace, { stage: 'assets', status: 'failed', generatedAt: report.generatedAt, artifacts: assetsCheckpointArtifacts(reportPath), summary: 'Execution blocked: chain-from-prev source missing.', issues: report.blockers, nextAction: `Select a candidate for scene ${error.sourceSceneIndex} or unchain scene ${error.sceneIndex}.`, }); await updateProjectManifestState(workspace, { updatedAt: report.generatedAt, currentStage: 'assets', lastCompletedStage: 'storyboard', lastCheckpointStatus: 'failed', }); return { reportPath, report }; } throw error; } // The exact wire body per scene, from the transport's own planner, frozen // into the contract so the portal shows what the vendor will parse and a dry // run shows the transport's refusal before any spend. Best-effort and pure // (no hosting, no network); a route without a planner yields nothing. const providerWire: Map = await previewProviderWire(payload, options.env ?? process.env).catch((error: unknown) => { process.stderr.write(`[execute] provider wire preview failed; the contract will carry none: ${error instanceof Error ? error.message : String(error)}\n`); return new Map(); }); if (options.requireContractHash) { // Rule 5, held by the tool: hash what is about to be submitted exactly as the // reviewed dry run was hashed (reference BYTES included), before the queue slot // is taken, a candidate is recorded or the provider is called. A throw here // writes no report and no contract. It is NOT side-effect free: director mode // has already recorded its storyboard approval above, and on seedance-direct // `buildExecutionPayload` has already hosted any chain-seed frame. assertApprovedRunContract({ requiredHash: options.requireContractHash, pending: buildRunContract({ payload, referenceHashes: await captureSubmittedReferenceHashes(workspace.projectDir, payload.tasks), submittedAt: generatedAt, }), approved: await readRunContract(root, projectSlug).catch(() => null), submitEnvironment: submitEnvironmentFingerprint(payload.routeId, await submitEnvironmentFor(payload.routeId, payload.workspaceRoot, options.env ?? process.env)), }); } // ---- render lane --------------------------------------------------------- // The provider slot, not our process, is the scarce resource. Two drivers on the // SAME Higgsfield lane produced 88 `free slot stayed busy` events and 0 NSFW // rejections over ~7 hours on 2026-08-10 — and it read as moderation failure the // whole time. A route with no declared `maxConcurrentJobs` is unenforced, so // nothing that works today changes. // // Acquire is NON-BLOCKING: a caller that would wait is told its position rather // than silently hanging. Drivers that simply want to wait should call // `vclaw video lane await` instead of hand-rolling a poll loop. const laneEnv = options.env ?? process.env; const laneLimit = plan.recommendedRouteId ? (ROUTE_CAPABILITIES[plan.recommendedRouteId]?.maxConcurrentJobs ?? null) : null; const laneId = `${plan.recommendedRouteId}:${laneEnv.VCLAW_LANE_ACCOUNT ?? 'default'}`; const laneRequestHash = promptHashOf(JSON.stringify({ routeId: plan.recommendedRouteId, tasks: payload.tasks, })); const laneQueue = !dryRun && laneLimit !== null && laneEnv.VCLAW_LANE_DISABLE !== '1' ? (options.laneCoordinator ?? createLaneCoordinator({ env: laneEnv })) : null; const laneTicket = laneQueue ? laneQueue.acquire({ lane: laneId, project: projectSlug, promptHash: laneRequestHash, limit: laneLimit, ttlSec: 3600, holder: `${process.pid}`, }) : null; const laneBusy = laneTicket !== null && laneTicket.status !== 'granted'; // A refused caller must NOT keep its place in the queue. It is being told to go // away and use `lane await`, which takes its own ticket — so an abandoned ticket // here is a ghost: it gets promoted when the slot frees and then holds a one-slot // lane for a full TTL with nothing running. Found by the first live test, which // is exactly the failure this system exists to prevent, reintroduced by its fix. if (laneBusy && laneQueue && laneTicket) { laneQueue.release(laneTicket.ticketId, laneId, laneLimit); } // #468 run marker lifecycle. `heartbeat` MUST be cleared here: render-scenes // calls executeProject N times per process and src/index.ts exports it to // library callers — an uncleared timer would resurrect a deleted marker. let runMarker: ExecutionRunMarkerHandle | null = null; let heartbeat: NodeJS.Timeout | null = null; // Measured once, before the submit, on every live run; the run contract reuses // it so the receipt and the contract record the same bytes. let preSubmitReferenceHashes: Awaited> | null = null; try { let report: VideoExecutionReport; if (laneBusy && laneTicket) { const eta = laneTicket.etaSeconds !== null ? `, est. ${Math.round(laneTicket.etaSeconds / 60)} min` : ''; report = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun: false, generatedAt, blockers: [ `render lane ${laneId} is busy — queued at position ${laneTicket.position}${eta}. ` + 'NOTHING was submitted, so no credits were spent and no duplicate job exists. ' + `Wait for the slot with: vclaw video lane await --route ${plan.recommendedRouteId} ` + `--project ${projectSlug} --prompt-hash ${laneRequestHash}`, ], executedSteps: ['validated-readiness', 'selected-provider-route', 'prepared-provider-adapter-payload'], taskCount: payload.tasks.length, }; } else if (dryRun) { report = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'dry-run-complete', dryRun: true, generatedAt, // A scene the transport's planner would refuse is a blocker the operator // must see on stdout, not only in the contract file. The status stays // dry-run-complete: a `blocked` dry run writes no contract and no run // page, which would delete the very evidence that carries the refusal. blockers: [...providerWire.entries()] .filter((entry): entry is [number, { transport: string; refused: string }] => 'refused' in entry[1]) .map(([sceneIndex, wire]) => `scene ${sceneIndex}: ${wire.transport} would refuse this submit — ${wire.refused}`), executedSteps: ['validated-readiness', 'selected-provider-route', 'prepared-provider-adapter-payload', 'simulated-execution-plan'], taskCount: payload.tasks.length, }; } else { // #468: the ONLY thing on disk that proves a run is live during the submit // window. Written per run (parallel --scene runs coexist), heartbeated, and // removed in the finally below once the report is on disk. This write is // deliberately fatal: a run that cannot record itself refuses here, before // any provider call, rather than submitting blind. See execution-run-marker.ts. runMarker = await writeExecutionRunMarker(workspace, { routeId: plan.recommendedRouteId, productionMode: plan.productionMode, sceneIndexes: sceneIndices ?? null, payloadHash: laneRequestHash, }); const markerHandle = runMarker; heartbeat = setInterval(() => { void touchExecutionRunMarker(markerHandle); }, EXECUTION_RUN_HEARTBEAT_MS); heartbeat.unref(); // Evidence for the shared queue (a no-op on the local one, and best-effort // always): who holds the slot and for what, then what was attached. The // bytes are measured BEFORE the submit, so they are what was sent. const heldTicketId = laneTicket?.status === 'granted' ? laneTicket.ticketId : null; const receiptBase = { projectSlug, routeId: plan.recommendedRouteId, requestHash: laneRequestHash }; recordLaneReceipt({ coordinator: laneQueue, lane: laneId, ticketId: heldTicketId, phase: 'lease-acquired', payload: { ...receiptBase, scenes: payload.tasks.map((task) => task.sceneIndex) } }); // Measured for EVERY live run, not only the ones holding a ticket: the run // contract then always freezes the bytes as they were before the submit, // which is what `--require-contract` compared against (it measures before // the queue slot is taken). Measuring after the submit made the contract // mean one thing on a queued route and another everywhere else. preSubmitReferenceHashes = await captureSubmittedReferenceHashes(workspace.projectDir, payload.tasks); if (heldTicketId) { recordLaneReceipt({ coordinator: laneQueue, lane: laneId, ticketId: heldTicketId, phase: 'references-applied', payload: { ...receiptBase, references: preSubmitReferenceHashes.map((scene) => ({ sceneIndex: scene.sceneIndex, files: scene.entries.map((entry) => ({ status: entry.status, ...(entry.sha256 ? { sha256: entry.sha256 } : {}) })), })), }, }); } try { const submission = await submitExecutionPayload(payload, { env: options.env }); recordLaneReceipt({ coordinator: laneQueue, lane: laneId, ticketId: heldTicketId, phase: 'provider-submitted', payload: { ...receiptBase, externalJobId: submission.externalJobId ?? null } }); // A transport can return a job AND a warning about it — on // `reapi-seedance` a create whose answer was lost comes back as a job // whose task may exist and may be billing. Left inside `rawResult` that // notice reaches nobody, so it is lifted onto the report's own advisory // field (the consistency audit appends to the same list later). const submitWarnings = Array.isArray((submission.rawResult as { warnings?: unknown } | null | undefined)?.warnings) ? ((submission.rawResult as { warnings?: unknown[] }).warnings ?? []).filter((warning): warning is string => typeof warning === 'string') : []; report = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'live-submitted', dryRun: false, generatedAt, blockers: [], executedSteps: ['validated-readiness', 'selected-provider-route', 'prepared-provider-adapter-payload', 'submitted-provider-adapter'], taskCount: payload.tasks.length, ...(laneTicket && laneLimit !== null ? { laneLease: { laneId, ticketId: laneTicket.ticketId, requestHash: laneRequestHash, limit: laneLimit, }, } : {}), ...(submitWarnings.length > 0 ? { warnings: submitWarnings } : {}), submission, }; } catch (error) { // An adapter that THROWS a human check at submit: pause the lane for // every other driver BEFORE giving the ticket back (only a held ticket // may report one). The free Seedance engine does not throw here — it // mints a job id and reports the wall in the poll's issues, which // execution-status.ts handles at its own release. Best-effort; the // blocked report below is the operator's signal either way. const humanCheck = laneQueue && laneTicket ? recordProviderHumanCheckCooldown({ coordinator: laneQueue, ticketId: laneTicket.ticketId, lane: laneId, message: error instanceof Error ? error.message : String(error), env: laneEnv, }) : null; if (humanCheck) announceProviderHumanCheck(laneId, humanCheck); // The submit never landed, so the provider slot was never really taken. // Hand it back now rather than making the next agent wait out the TTL. // The run ended at submit: say so on the ticket while it is still held. recordLaneReceipt({ coordinator: laneQueue, lane: laneId, ticketId: heldTicketId, phase: 'provider-terminal', payload: terminalReceiptPayload({ projectSlug, routeId: plan.recommendedRouteId, externalJobId: null, status: 'submit-failed' }) }); if (laneQueue && laneTicket) laneQueue.release(laneTicket.ticketId, laneId, laneLimit); report = { projectSlug, productionMode: plan.productionMode, operationKind: plan.operationKind, routeId: plan.recommendedRouteId, status: 'blocked', dryRun: false, generatedAt, blockers: [...plan.blockers, (error as Error).message], executedSteps: ['validated-readiness', 'selected-provider-route', 'prepared-provider-adapter-payload'], taskCount: payload.tasks.length, }; } } // Candidate ingest — only in candidate mode, only when the payload actually // went out the door (live-submitted or dry-run-complete). We do NOT append // candidates for blocked runs because no job id exists to track them. if (candidateMode && (report.status === 'live-submitted' || report.status === 'dry-run-complete')) { const externalJobId = report.submission?.externalJobId ?? undefined; // Capture the route id (non-null per the earlier readiness guard) OUTSIDE the // async lock closure — inside the closure TS widens `plan.recommendedRouteId` // back to `string | null`. const recommendedRouteId = plan.recommendedRouteId; // Concurrency-safe ingest: the whole read-modify-write runs under the single // per-project artifact lock and RE-READS the current on-disk state inside the // lock, so a concurrent pool scene's candidate/selection write is never lost. // The provider submit (slow) already happened above, OUTSIDE the lock. const created = await withSceneArtifactsLock(root, projectSlug, async () => { let nextCandidates: SceneCandidatesArtifact = await readSceneCandidatesArtifact(root, projectSlug); let nextSelection: SceneSelectionArtifact = await readSceneSelectionArtifact(root, projectSlug); const ingested: Array<{ sceneIndex: number; candidateId: string }> = []; for (const task of payload.tasks) { const candidateId = nextCandidateId(nextCandidates, task.sceneIndex); const round = maxRoundForScene(nextCandidates, task.sceneIndex) + 1; const candidate: SceneCandidate = { id: candidateId, generationRound: round, prompt: task.prompt, route: recommendedRouteId, submittedAt: report.generatedAt, status: 'pending', outputs: [], source: { executionRound: round, adapter: 'builtin', ...(externalJobId ? { externalJobId } : {}), ...(laneTicket?.status === 'granted' ? { laneTicketId: laneTicket.ticketId, laneId, } : {}), chainedFromCandidateId: task.chainedFromCandidateId ?? null, }, }; nextCandidates = appendCandidate(nextCandidates, task.sceneIndex, candidate); nextSelection = markPending(nextSelection, task.sceneIndex, [candidateId]); ingested.push({ sceneIndex: task.sceneIndex, candidateId }); } await writeSceneCandidatesArtifact(root, projectSlug, nextCandidates); await writeSceneSelectionArtifact(root, projectSlug, nextSelection); return ingested; }); for (const entry of created) { await appendProjectEvent(workspace, { type: 'scene-candidate.submitted', recordedAt: report.generatedAt, payload: { sceneIndex: entry.sceneIndex, candidateId: entry.candidateId, routeId: plan.recommendedRouteId, ...(externalJobId ? { externalJobId } : {}), }, }); } report = { ...report, candidatesByScene: created }; } // Automated character-consistency vision audit (advisory, non-fatal). After a // REAL run that actually submitted (live-submitted) — never on a dry-run, never // on a blocked run — and ONLY when a Gemini vision key is configured AND at // least one scene has a rendered output to look at, audit identity/costume // drift and append the findings to the report as warnings. It NEVER changes // status and NEVER throws into the run (a failed audit is swallowed). With no // rendered outputs / no key it does not run, so the report stays byte-identical. if (!dryRun && report.status === 'live-submitted' && hasVisionKey(options.env ?? process.env)) { const outputsDir = join(workspace.projectDir, 'outputs'); if (existsSync(outputsDir)) { try { const audit = await auditProjectConsistency(projectSlug, root); // Persist the full structured audit alongside the execution report. await writeTextFileAtomic( join(workspace.artifactsDir, 'consistency-audit.json'), `${JSON.stringify(audit, null, 2)}\n`, ); if (audit.findings.length > 0) { report = { ...report, warnings: [ ...(report.warnings ?? []), ...audit.findings.map((finding) => `consistency-audit: ${finding}`), ], }; } } catch { // Advisory — a failed audit must never block or fail a real run. } } } // Persist the FROZEN submit contract per scene — the baseline the live run // dashboard diffs the CURRENT contract against (catching the @tag-hijack class // of bug). Written on every non-blocked run (live-submitted AND // dry-run-complete, so dry runs also get a diff baseline). Purely additive: // never read by the execution path, only the dashboard. Best-effort — a write // failure must never fail the render run. if (report.status === 'live-submitted' || report.status === 'dry-run-complete') { try { // The bytes behind each submitted reference, measured now — every other // identity in the contract covers a path, and a swapped file keeps its path. // Measured before the submit on a live run; a dry run has none to reuse. const referenceHashes = preSubmitReferenceHashes ?? await captureSubmittedReferenceHashes(workspace.projectDir, payload.tasks); const contract = buildRunContract({ payload, referenceHashes, providerWire, submittedAt: report.generatedAt, ...(report.candidatesByScene ? { candidatesByScene: report.candidatesByScene } : {}), externalJobId: report.submission?.externalJobId ?? null, }); const contractPath = await writeRunContract(root, projectSlug, contract); // Point the operator at the contract instead of leaving them to guess it // exists: the report's own body is counts-only, and a dry run whose whole // purpose is pre-spend review is useless if the payload is unfindable. report = { ...report, contractPath, contractApprovalHash: runContractApprovalHash(contract, submitEnvironmentFingerprint(payload.routeId, await submitEnvironmentFor(payload.routeId, payload.workspaceRoot, options.env ?? process.env))) }; } catch { // run-contract is dashboard-only; do not block the render run on it. } } const reportPath = await writeArtifact(workspace, 'execution-report', report); await appendProjectEvent(workspace, { type: 'execution.report.written', recordedAt: report.generatedAt, payload: { reportPath, status: report.status, routeId: report.routeId, dryRun: report.dryRun }, }); await appendGenerationTelemetry(workspace, buildGenerationTelemetryFromReport({ report, payload, recordedAt: report.generatedAt, })); await writeStageCheckpoint(workspace, { stage: 'assets', status: report.status === 'dry-run-complete' ? 'completed' : report.status === 'live-submitted' ? 'pending' : 'failed', generatedAt: report.generatedAt, artifacts: assetsCheckpointArtifacts(reportPath), summary: report.status === 'dry-run-complete' ? 'Execution dry-run completed.' : report.status === 'live-submitted' ? 'Execution submitted to provider adapter.' : 'Execution blocked.', issues: report.blockers, nextAction: report.status === 'dry-run-complete' ? 'Proceed to live provider execution.' : report.status === 'live-submitted' ? 'Poll provider job status and ingest outputs.' : 'Resolve blockers or add runtime support.', }); await updateProjectManifestState(workspace, { updatedAt: report.generatedAt, currentStage: report.status === 'dry-run-complete' ? 'review' : 'assets', lastCompletedStage: report.status === 'dry-run-complete' ? 'assets' : 'storyboard', lastCheckpointStatus: report.status === 'dry-run-complete' ? 'completed' : report.status === 'live-submitted' ? 'pending' : 'failed', }); await appendVideoContextChangelog( root, `${report.generatedAt} produce: ${report.status} for project ${projectSlug}${report.routeId ? ` via ${report.routeId}` : ''}.`, ); // Auto-regenerate the live run dashboard so projects//run.html reflects // the just-submitted contract without a manual `vclaw video portal --surface // run`. Best-effort + env kill-switch (VCLAW_NO_RUN_SURFACE=1); a probe // failure (e.g. ffprobe-less CI) degrades gracefully inside discovery. await regenerateRunSurface(root, projectSlug, options.env); return { reportPath, report }; } finally { if (heartbeat) clearInterval(heartbeat); if (runMarker) await clearExecutionRunMarker(runMarker); } }