diff --git a/LESSONS.md b/LESSONS.md index 10f412f2b..e6749e07b 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -13,6 +13,13 @@ Dated, short lessons from real FlyBot work. Agents read this **before coding**. ## Open lessons +### 2026-11-04 — An offline dependency miss is not an assertion failure (Seen 1×) + +Prepare and observe each executor separately, then verify the exact cleaned candidate. +Keep non-success classified and old evidence immutable. A killed docker client is +not proof its preparation command exited; retain the original occupancy fence. + + ### 2026-11-04 — Parsing success is not effective permission proof (Seen 1×) CLI 0.166 accepts unknown filesystem alias keys. Use supported `:tmpdir`, inspect diff --git a/PROJECT.md b/PROJECT.md index 0f4d6181e..1984b81c1 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -16,6 +16,8 @@ branch. #2336 establishes owned reservation rollback and a durable pre-spawn fen unknown execution, workspace/context and current authority remain protected. #2337 validates the actual SDK-resolved CLI and role profile before inference; configuration readiness is separate from model/test/exit evidence. +#2334 separates executor dependency readiness from host test outcomes and preserves +same-candidate retry evidence; remote unknown exit retains preparation occupancy. No live task/configuration/credential mutation or deployment is implied. ## Execution recovery — 2026-11-04 diff --git a/ROADMAP.md b/ROADMAP.md index bf92f6e61..0138d3f7f 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -6,7 +6,8 @@ Implement #2336 reservation cleanup first; then #2337 CLI configuration, #2334 environment attribution, #2346 context migration, #2338 receipts, #2347 recovery routing, #2331 controlled merge and #2342 operational-agent collaboration. Separate PRs retain existing UI/control-plane, authority and exact exit/evidence contracts. -#2336 is merged; #2337 adds offline actual-CLI readiness and scratch protection. +#2336/#2337 are merged. #2334 adds executor dependency receipts and classified +verification; actual Docker and paid inference remain distinct acceptance evidence. ## Recovery delivery — 2026-11-04 diff --git a/changelog.d/2334.fixed.md b/changelog.d/2334.fixed.md new file mode 100644 index 000000000..c1b2cd3c9 --- /dev/null +++ b/changelog.d/2334.fixed.md @@ -0,0 +1 @@ +Record executor-scoped dependency readiness and classify verification outcomes; preserve same-candidate retry history and retain remote preparation capacity when exit is unknown. diff --git a/core/src/host/project-chief-control.js b/core/src/host/project-chief-control.js index 23e773eaf..8c72e1a98 100644 --- a/core/src/host/project-chief-control.js +++ b/core/src/host/project-chief-control.js @@ -135,13 +135,14 @@ export function createProjectChiefControl(rt) { const claim = task.collaboration?.claim, worker = rt.state.projectHarnessWorkers?.[claim?.workerId] return { taskId: task.id, chiefActionIssue: task.chiefActionIssue ?? null, chiefDecisionIssue: task.chiefDecisionIssue ?? null, title: task.title, criteria: task.criteria ?? '', projectTaskKind: task.projectTaskKind ?? 'code', status: task.status, taskContract: task.projectTask ? { version: task.projectTask.version, admission: { status: task.projectTask.admission.status, ...(task.projectTask.admission.failure ? { failure: structuredClone(task.projectTask.admission.failure) } : {}) }, requirements: structuredClone(task.projectTask.requirements), wait: structuredClone(task.projectTask.wait ?? null), dependencies: task.projectTask.dependencies.map(row => ({ ...structuredClone(row), current: dependencyFact(rt.state, task, row.taskId) })) } : null, allocationProof: allocationProof(rt.state, task), - execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, lastEventAt: worker?.lastEventAt ?? null } : null, + execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, capabilityFailure: worker?.capabilityFailure ?? null, environmentObservations: worker?.environmentObservations ?? [], lastEventAt: worker?.lastEventAt ?? null } : null, handoff: task.collaboration?.history.at(-1) ? { id: task.collaboration.history.at(-1).id, purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, block: task.collaboration?.block ?? null, ...inspectionResources(rt.state, task), + environment: { preparations: Object.values(attempt?.workspaces ?? {}).filter(row => row.environmentPreparation).slice(-6).map(row => ({ id: row.environmentPreparation.id, role: row.role, executor: row.environmentPreparation.executor, status: row.environmentPreparation.status, sha256: row.environmentPreparation.sha256 })), observations: Object.values(rt.state.projectHarnessWorkers ?? {}).filter(row => row.attemptId === attempt?.id && row.projectId === task.projectId && row.taskId === task.id).flatMap(row => (row.environmentObservations ?? []).map(value => ({ ...value, workerId: row.id, role: row.role, resourceId: row.resourceId }))).slice(-8) }, repositoryRecovery: await rt.projectDelivery.recoveryFacts?.(task) ?? null, integrationInputs: structuredClone(attempt?.integrationInputs ?? []), attemptStatus: attempt?.status ?? null, generation: task.collaboration?.generation ?? 0, destination: task.collaboration?.botId ?? null, attemptId: attempt?.id ?? null, candidateSha: attempt?.candidateSha ?? null, evidence: { implementation: attempt?.evidence.implementation ? { sha: attempt.evidence.implementation.sha, paths: attempt.evidence.implementation.paths } : null, - tests: attempt?.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, + tests: attempt?.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, status: attempt.evidence.tests.status ?? null, reason: attempt.evidence.tests.reason ?? null, testsStarted: attempt.evidence.tests.testsStarted ?? null, preparation: attempt.evidence.tests.preparation ? { id: attempt.evidence.tests.preparation.id, status: attempt.evidence.tests.preparation.status, executor: attempt.evidence.tests.preparation.executor, sha256: attempt.evidence.tests.preparation.sha256 } : null, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, review: attempt?.evidence.review ? { sha: attempt.evidence.review.sha, verdict: attempt.evidence.review.verdict, summary: attempt.evidence.review.summary, repositoryVerdict: attempt.evidence.review.report?.repositoryVerdict ?? 'INC' } : null, outputs: attempt?.evidence.developer?.outputs ?? null, pr: attempt?.pr ? { number: attempt.pr.number, sha: attempt.pr.sha, url: attempt.pr.url } : null }, inspection: facts } })) diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index 172cfa53c..ab497b3ec 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -23,6 +23,7 @@ import { childResultSources, createChildInputAdmission } from './project-child-i import { reconcileExecutionAuthority } from './project-execution-authority.js' import { executionExitConfirmed } from './project-execution-exit.js' import { createProjectNativeSessions, nativeExitConfirmed } from './project-native-sessions.js' +import { prepareDependencyEnvironment, executionOutcome } from './project-execution-environment.js' import { testFailureExcerpt } from './project-test-diagnostics.js' import { EffectCancelled, EffectDeferred } from './project-effects.js' @@ -125,7 +126,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { if (source !== attempt && tests.sha !== attempt.seedSha) return null const text = await readFile(tests.log, 'utf8') if (hash(text) !== tests.sha256) throw reject('Failure diagnostic log changed') - return { sourceAttemptId: source.id, sha: tests.sha, logSha256: tests.sha256, excerpt: testFailureExcerpt(text, process.env), completeLogRetained: true, source: 'project-attempt-log; bounded host-verified diagnostic, not a passing result' } + return { status: tests.status ?? 'test-process-failed', reason: tests.reason ?? null, executor: tests.executor, testsStarted: tests.testsStarted ?? true, preparation: tests.preparation ? { id: tests.preparation.id, status: tests.preparation.status, sha256: tests.preparation.sha256, executor: tests.preparation.executor } : null, sourceAttemptId: source.id, sha: tests.sha, logSha256: tests.sha256, excerpt: testFailureExcerpt(text, process.env), completeLogRetained: true, source: 'project-attempt-log; bounded host-verified diagnostic, not a passing result' } } async function archiveForNative(context, attempt, role, suffix) { if (!context.archive) return @@ -135,12 +136,28 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { await writeFile(context.archivePath, JSON.stringify(context.archive), { mode: 0o600 }) } async function prepareWorkspace(attempt, workspace, settings) { - if (!settings.workspacePrepareCommand) return - const argv = settings.workspacePrepareCommand - // Fixed operator argv, never Task/model text; execution stays on the bound resource. - if (workspace.resourceLease) await rt.projectResources.command(workspace.projectId, workspace.resourceId, { cwd: workspace.path, argv, timeout: 30_000 }) - else await exec(argv[0], argv.slice(1), { cwd: workspace.path, env: cleanEnv(), timeout: 30_000, maxBuffer: 1024 * 1024 }) - ;(attempt.workspacePreparations ??= []).push({ role: workspace.role, workspaceId: workspace.id, commandSha256: hash(JSON.stringify(argv)), resourceId: workspace.resourceId, at: Date.now() }) + if (!settings.workspacePrepareCommand && !settings.dependencyEnvironment) return null + const resource = workspace.resourceLease ? rt.projectResources.setting(workspace.projectId, workspace.resourceId) : null + const scope = { projectId: attempt.projectId, attemptId: attempt.id, workspaceId: workspace.id, role: workspace.role, resourceId: workspace.resourceId ?? null, principalId: workspace.resourceLease?.principalId ?? attempt.executionOwnerId ?? null, candidateSha: attempt.candidateSha ?? null, executor: resource?.type === 'docker' ? 'docker-resource' : resource ? 'local-resource' : 'independent-host-controller' } + const run = (argv, timeout) => resource ? rt.projectResources.command(workspace.projectId, workspace.resourceId, { cwd: workspace.path, argv, timeout, dependencyOffline: settings.dependencyEnvironment && settings.dependencyEnvironment.network !== 'operator-preparation' }) : exec(argv[0] === 'node' ? process.execPath : argv[0], argv.slice(1), { cwd: workspace.path, env: { ...cleanEnv(), ...(settings.dependencyEnvironment && settings.dependencyEnvironment.network !== 'operator-preparation' ? { npm_config_offline: 'true' } : {}) }, timeout, maxBuffer: 1024 * 1024 }) + let receipt, failure + try { receipt = await prepareDependencyEnvironment({ settings, scope, run, preparationNetwork: resource?.dependencyPreparationNetwork ?? settings.dependencyPreparationNetwork ?? 'offline' }) } + catch (error) { failure = error; receipt = error.environmentReceipt; if (!receipt) throw error } + const { logs, ...metadata } = receipt + const directory = join(artifacts, attempt.id) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const id = `environment-${randomUUID()}`, log = join(directory, `${id}.log`) + await writeFile(log, logs, { mode: 0o600 }) + receipt = { ...metadata, id, log, sha256: hash(logs) } + await writeFile(join(directory, `${id}.json`), JSON.stringify(receipt), { mode: 0o600 }) + workspace.environmentPreparation = receipt + if (failure) { + failure.environmentReceipt = receipt + if (!receipt.exitConfirmed && state.projectResourceLeases?.[workspace.resourceLease?.id]?.preparation?.status === 'active') await rt.projectResources.preparationUncertain(workspace.resourceLease) + throw failure + } + ;(attempt.workspacePreparations ??= []).push({ role: workspace.role, workspaceId: workspace.id, commandSha256: hash(JSON.stringify(settings.workspacePrepareCommand ?? [])), resourceId: workspace.resourceId, environmentId: id, at: Date.now() }) + return receipt } async function publicationOf(previous, project) { if (!previous) return undefined @@ -587,20 +604,24 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { const path = workspace.path await workspaceGit(workspace, ['reset', '--hard', attempt.candidateSha]) await workspaceGit(workspace, ['clean', '-ffdx']) - await prepareWorkspace(attempt, workspace, settings) - const [command, ...args] = settings.testCommand - let passed = false - let output = '' + const startedAt = Date.now() + let preparation = null, error = null, output = '', stage = 'preparation' try { - const result = await exec(command, args, { cwd: path, env: cleanEnv(), timeout: Math.min(settings.testTimeoutMs ?? 120_000, 1_800_000), maxBuffer: 1024 * 1024 }) - passed = true + preparation = await prepareWorkspace(attempt, workspace, settings) + stage = 'tests' + const [command, ...args] = settings.testCommand + const result = await exec(command, args, { cwd: path, env: { ...cleanEnv(), ...(settings.dependencyEnvironment ? { npm_config_offline: 'true', ...(settings.dependencyEnvironment.npmCachePath ? { npm_config_cache: join(path, settings.dependencyEnvironment.npmCachePath) } : {}) } : {}) }, timeout: Math.min(settings.testTimeoutMs ?? 120_000, 1_800_000), maxBuffer: 1024 * 1024 }) output = result.stdout + result.stderr - } catch (error) { output = String(error.stdout ?? '') + String(error.stderr ?? '') + '\nTest process failed or exceeded its limit' } + } catch (failure) { error = failure; preparation ??= failure.environmentReceipt ?? null; output = String(failure.stdout ?? '') + String(failure.stderr ?? '') } + const outcome = executionOutcome(error, output, { stage }) + if (stage === 'preparation' && error) Object.assign(outcome, { status: preparation?.status ?? 'environment-blocked', reason: preparation?.reason ?? 'preparation-failed', exitConfirmed: preparation?.exitConfirmed ?? false }) await mkdir(join(artifacts, attempt.id), { recursive: true, mode: 0o700 }) - const log = join(artifacts, attempt.id, 'tests.log') + const log = join(artifacts, attempt.id, `tests-${randomUUID()}.log`) + if (error && !output) output = 'Verification did not complete; inspect the structured outcome and preparation receipt' await writeFile(log, output, { mode: 0o600 }) - attempt.evidence.tests = { logicalRole: deliveryRole(attempt, 'tester'), roleSessionId: attempt.roleSessions?.[deliveryRole(attempt, 'tester')]?.id ?? null, executor: 'independent-host-controller', workspaceId: workspace.id, resourceId: workspace.resourceId, sha: attempt.candidateSha, passed, command: settings.testCommand, log, sha256: hash(output), at: Date.now() } - if (!passed) throw reject('Configured tests failed; see Attempt evidence') + if (attempt.evidence.tests) (attempt.testHistory ??= []).push(structuredClone(attempt.evidence.tests)) + attempt.evidence.tests = { logicalRole: deliveryRole(attempt, 'tester'), roleSessionId: attempt.roleSessions?.[deliveryRole(attempt, 'tester')]?.id ?? null, executor: 'independent-host-controller', workspaceId: workspace.id, resourceId: workspace.resourceId, sha: attempt.candidateSha, passed: !error, status: outcome.status, reason: outcome.reason, exitConfirmed: outcome.exitConfirmed, testsStarted: stage === 'tests', preparation, command: settings.testCommand, log, sha256: hash(output), startedAt, at: Date.now() } + if (error) throw Object.assign(reject(outcome.status === 'environment-blocked' ? 'Test environment is blocked; verification is incomplete' : outcome.status === 'execution-timeout' ? 'Test execution timed out; verification is incomplete' : 'Configured tests failed; see Attempt evidence'), { outcome: outcome.status }) } async function review(attempt, outputs) { await verifyCandidate(attempt) @@ -824,11 +845,13 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { const attempt = ownEntry(attempts(), body.attemptId) if (!attempt) throw reject('Unknown Attempt') rt.requireProjectAccess(actor, attempt.projectId) - const evidence = body.role ? attempt.evidence.execution?.[body.role] : attempt.evidence.tests + if (body.environmentRole && body.role) throw reject('Select one preparation or execution log source') + const source = () => body.environmentRole ? attempt.workspaces[body.environmentRole]?.environmentPreparation : body.role ? attempt.evidence.execution?.[body.role] : attempt.evidence.tests + const evidence = source() if (!evidence?.log) return { text: null, reason: 'No test log is available' } const page = await sealedLogPage(evidence, body) rt.requireProjectAccess(actor, attempt.projectId) - if (ownEntry(attempts(), body.attemptId) !== attempt || (body.role ? attempt.evidence.execution?.[body.role] : attempt.evidence.tests) !== evidence) throw reject('Log source changed during query') + if (ownEntry(attempts(), body.attemptId) !== attempt || source() !== evidence) throw reject('Log source changed during query') return page } if (endpoint === 'project-attempt-integration-state') { @@ -938,19 +961,19 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { else await workspaceGit(workspace, ['reset', '--hard', attempt.candidateSha]) } if (project.repository) { await prepareWorkspace(attempt, workspace, settings); await prepareIntegrations(attempt, workspace) } - context = await buildProjectContext(rt, { retrieval: true, policy, projectId: project.id, taskId: task.id, role, revision: pinned.revision, resourceId: lease?.resourceId, definition, memory: definition.memory, knowledge: ownEntry(state.projectKnowledge ?? {}, project.id) ?? [], requirement: attempt.requirement, feedback: attempt.feedback, projectFacts: { workspaceMaterialization: workspace.materialization ?? null, ...(pinned.change ? { roleDefinitionChange: pinned.change } : {}), integrationInputs: structuredClone(attempt.integrationInputs ?? []), testFailure: await failureContext(attempt), publicationBinding: attempt.publicationBinding ?? null, integrationInstruction: attempt.publicationBinding ? 'Merge the pinned upstreamSha into the published candidate using --no-commit, resolve and stage conflicts before handoff. Host sealing retains both trusted inputs; never fabricate parents or discard backend changes.' : null, workingDraft: !attempt.candidateSha && workspaceFor(attempt, 'developer') ? { kind: 'unverified-workspace-reference', resourceId: workspaceFor(attempt, 'developer').resourceId, path: workspaceFor(attempt, 'developer').resourceId === workspace.resourceId ? workspaceFor(attempt, 'developer').path : null, note: 'Unsealed source, not candidate or verification evidence. Cross-resource drafts require a sealed artifact before verification.' } : null, handoff: task.collaboration?.history.at(-1) ? { purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, candidateSha: attempt.candidateSha ?? null, tests: attempt.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, review: attempt.evidence.review ? { sha: attempt.evidence.review.sha, verdict: attempt.evidence.review.verdict, summary: attempt.evidence.review.summary } : null }, repository: await repositoryContext(project.id, attempt.baseSha, project) }) - } finally { if (preparation) await rt.projectResources.endPreparation(preparation) } + context = await buildProjectContext(rt, { retrieval: true, policy, projectId: project.id, taskId: task.id, role, revision: pinned.revision, resourceId: lease?.resourceId, definition, memory: definition.memory, knowledge: ownEntry(state.projectKnowledge ?? {}, project.id) ?? [], requirement: attempt.requirement, feedback: attempt.feedback, projectFacts: { environmentPreparation: workspace.environmentPreparation ? { id: workspace.environmentPreparation.id, status: workspace.environmentPreparation.status, executor: workspace.environmentPreparation.executor, identitySha256: workspace.environmentPreparation.identitySha256 ?? null, sha256: workspace.environmentPreparation.sha256 } : null, workspaceMaterialization: workspace.materialization ?? null, ...(pinned.change ? { roleDefinitionChange: pinned.change } : {}), integrationInputs: structuredClone(attempt.integrationInputs ?? []), testFailure: await failureContext(attempt), publicationBinding: attempt.publicationBinding ?? null, integrationInstruction: attempt.publicationBinding ? 'Merge the pinned upstreamSha into the published candidate using --no-commit, resolve and stage conflicts before handoff. Host sealing retains both trusted inputs; never fabricate parents or discard backend changes.' : null, workingDraft: !attempt.candidateSha && workspaceFor(attempt, 'developer') ? { kind: 'unverified-workspace-reference', resourceId: workspaceFor(attempt, 'developer').resourceId, path: workspaceFor(attempt, 'developer').resourceId === workspace.resourceId ? workspaceFor(attempt, 'developer').path : null, note: 'Unsealed source, not candidate or verification evidence. Cross-resource drafts require a sealed artifact before verification.' } : null, handoff: task.collaboration?.history.at(-1) ? { purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, candidateSha: attempt.candidateSha ?? null, tests: attempt.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, status: attempt.evidence.tests.status ?? null, reason: attempt.evidence.tests.reason ?? null, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, review: attempt.evidence.review ? { sha: attempt.evidence.review.sha, verdict: attempt.evidence.review.verdict, summary: attempt.evidence.review.summary } : null }, repository: await repositoryContext(project.id, attempt.baseSha, project) }) + } finally { if (preparation && !state.projectResourceLeases?.[lease.id]?.preparation?.exitUnknown) await rt.projectResources.endPreparation(preparation) } } catch (error) { const registered = lease && ownEntry(state.projectResourceLeases ?? {}, lease.id) const unbound = registered && registered.generation === lease.generation && !registered.worker && !registered.processScope && !Object.values(state.projectHarnessWorkers ?? {}).some(row => row.leaseId === lease.id) && !Object.values(attempt.roleSessions ?? {}).some(row => row.id === sessionId) if (unbound) await rt.projectResources.release(lease.id, lease.generation) - ;(attempt.preparationFailures ??= []).push({ role, sessionId, leaseId: lease?.id ?? null, message: String(error.message), ...(error.code === 'bot/workspace-baseline' ? { code: error.code, details: structuredClone(error.details) } : {}), releasedBeforeExternalStart: Boolean(unbound && registered.status === 'released' && registered.releaseProof?.kind === 'unbound-reservation-release'), at: Date.now() }) + ;(attempt.preparationFailures ??= []).push({ role, sessionId, leaseId: lease?.id ?? null, message: error.environmentReceipt ? 'Execution environment preparation failed; see the bound receipt' : String(error.message), ...(error.environmentReceipt ? { environment: structuredClone(error.environmentReceipt) } : {}), ...(error.code === 'bot/workspace-baseline' ? { code: error.code, details: structuredClone(error.details) } : {}), releasedBeforeExternalStart: Boolean(unbound && registered.status === 'released' && registered.releaseProof?.kind === 'unbound-reservation-release'), at: Date.now() }) await rt.save() throw error } attempt.rolePolicies[role] = policy if ((execution.harness ?? 'flybot') === 'flybot') await archiveForNative(context, attempt, role, sessionId) - const session = { id: sessionId, logicalSessionId: taskSessionId(project.id, task.id, role), projectId: project.id, taskId: task.id, attemptId: attempt.id, role, policyRole, definitionRevision: pinned.revision, definitionRef: pinned.definitionRef, definitionKey: pinned.definitionKey, ...(pinned.change ? { definitionChange: pinned.change } : {}), execution: cloneExecution(execution), policy, context, generation: claim.generation, poolClaimId: claim.id, status: 'prepared', ...(lease ? { resourceLease: lease } : {}) } + const session = { id: sessionId, logicalSessionId: taskSessionId(project.id, task.id, role), projectId: project.id, taskId: task.id, attemptId: attempt.id, role, policyRole, definitionRevision: pinned.revision, definitionRef: pinned.definitionRef, definitionKey: pinned.definitionKey, ...(pinned.change ? { definitionChange: pinned.change } : {}), execution: cloneExecution(execution), environmentPreparation: workspace.environmentPreparation ?? null, policy, context, generation: claim.generation, poolClaimId: claim.id, status: 'prepared', ...(lease ? { resourceLease: lease } : {}) } if (attempt.roleSessions[role]) { (attempt.roleSessionHistory ??= []).push(structuredClone({ ...attempt.roleSessions[role], executionEvidence: attempt.evidence.execution?.[role] ?? null })) if (attempt.evidence.execution) delete attempt.evidence.execution[role] diff --git a/core/src/host/project-development-service.js b/core/src/host/project-development-service.js index 18b244542..f7df81ed1 100644 --- a/core/src/host/project-development-service.js +++ b/core/src/host/project-development-service.js @@ -161,7 +161,7 @@ export function createDevelopmentService(rt) { attemptsPage: { total: rows.length, limit, before: end > limit ? page[0].id : null } } } function context(body) { - if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions'].includes(body.section)) throw failure('Unknown Attempt context section') + if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures'].includes(body.section)) throw failure('Unknown Attempt context section') const attempt = ownEntry(rt.state.projectAttempts ?? {}, body.attemptId) const text = JSON.stringify(attempt[body.section] ?? null), offset = body.offset ?? 0, limit = body.limit ?? 8000 if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length || !Number.isSafeInteger(limit) || limit < 1 || limit > 16000) throw failure('Invalid context page') diff --git a/core/src/host/project-execution-environment.js b/core/src/host/project-execution-environment.js new file mode 100644 index 000000000..3b1e94ec2 --- /dev/null +++ b/core/src/host/project-execution-environment.js @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto' + +const sha = value => createHash('sha256').update(value).digest('hex') +const relative = value => typeof value === 'string' && value.length > 0 && value.length <= 500 && !/^[\\/]|^[A-Za-z]:|[\0\r\n\\]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..') +const argv = value => Array.isArray(value) && value.length > 0 && value.length <= 30 && value.every(part => typeof part === 'string' && part.length <= 4000 && !part.includes('\0')) && value[0].trim() +export function dependencyEnvironment(settings) { + const spec = settings.dependencyEnvironment + if (spec === undefined) return null + const invalid = () => { throw Object.assign(new Error('Repository dependencyEnvironment is invalid; check operator provisioning'), { code: 'bot/repository-config', field: 'dependencyEnvironment' }) } + if (!spec || typeof spec !== 'object' || Array.isArray(spec) || Object.keys(spec).some(key => !['packageManager', 'lockFiles', 'tools', 'requiredPaths', 'npmCachePath', 'network', 'timeoutMs'].includes(key))) invalid() + if (spec.packageManager !== undefined && (typeof spec.packageManager !== 'string' || spec.packageManager.length > 100)) invalid() + for (const key of ['lockFiles', 'requiredPaths']) if (spec[key] !== undefined && (!Array.isArray(spec[key]) || spec[key].length > 32 || !spec[key].every(relative))) invalid() + if (spec.npmCachePath !== undefined && !relative(spec.npmCachePath)) invalid() + if (spec.tools !== undefined && (!Array.isArray(spec.tools) || spec.tools.length > 16 || spec.tools.some(tool => !tool || !argv(tool.argv) || tool.expectedOutput !== undefined && (typeof tool.expectedOutput !== 'string' || tool.expectedOutput.length > 500)))) invalid() + if (spec.network !== undefined && !['offline', 'operator-preparation'].includes(spec.network)) invalid() + if (spec.timeoutMs !== undefined && (!Number.isSafeInteger(spec.timeoutMs) || spec.timeoutMs < 1 || spec.timeoutMs > 120_000)) invalid() + return spec +} + +export function executionOutcome(error, output = '', { stage = 'tests', remote = false } = {}) { + if (!error) return { status: 'passed', reason: 'command-completed', exitConfirmed: true } + const timedOut = error.killed === true || error.code === 'ETIMEDOUT' || error.signal === 'SIGTERM' || error.signal === 'SIGKILL' + if (remote && (timedOut || typeof error.code !== 'number' || [125, 255].includes(error.code))) return { status: 'execution-unknown', reason: 'resource-command-exit-unconfirmed', exitConfirmed: false } + if (timedOut) return { status: 'execution-timeout', reason: 'command-deadline', exitConfirmed: true } + if (stage === 'preparation') return { status: 'environment-blocked', reason: 'preparation-command-failed', exitConfirmed: true } + if (['ENOENT', 'EACCES'].includes(error.code) || /\b(?:ENOTCACHED|only-if-cached|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|EAI_AGAIN|ENOTFOUND|ENETUNREACH|ECONNREFUSED)\b|Cannot find (?:module|package)|command not found|network (?:is unreachable|access.*denied)|registry.*(?:denied|unavailable)/i.test(output)) return { status: 'environment-blocked', reason: 'dependency-tool-cache-or-network-unavailable', exitConfirmed: true } + if (typeof error.code !== 'number') return { status: 'execution-unknown', reason: 'test-exit-unconfirmed', exitConfirmed: false } + return { status: /AssertionError|ERR_ASSERTION|assertion failed/i.test(output) ? 'assertion-failed' : 'test-process-failed', reason: 'nonzero-test-exit', exitConfirmed: true } +} + +// Executed on the workspace's own executor. Paths cannot cross checkout boundaries. +export const environmentSnapshotScript = `const fs=require('node:fs'),p=require('node:path'),c=require('node:crypto'); +const s=JSON.parse(process.argv[1]),root=fs.realpathSync(process.cwd()),out={locks:{},paths:[]}; +function path(name){const a=p.resolve(root,name),b=fs.realpathSync(a);if(b!==a||!b.startsWith(root+p.sep))throw Error('dependency-path-scope');return b} +for(const name of s.lockFiles||[]){const f=path(name);if(!fs.statSync(f).isFile()||fs.statSync(f).size>8*1024*1024)throw Error('dependency-lock-unavailable');out.locks[name]=c.createHash('sha256').update(fs.readFileSync(f)).digest('hex')} +for(const name of s.requiredPaths||[]){fs.accessSync(path(name),fs.constants.R_OK);out.paths.push(name)} +if(s.npmCachePath){const f=path(s.npmCachePath);if(!fs.statSync(f).isDirectory())throw Error('dependency-cache-unavailable');out.cache=s.npmCachePath} +console.log(JSON.stringify(out));` + +export const copyDependencyCacheScript = `const fs=require('node:fs'),p=require('node:path'); +const root=fs.realpathSync(process.cwd()),source=p.resolve(root,process.argv[1]),target=process.argv[2]; +if(fs.realpathSync(source)!==source||!source.startsWith(root+p.sep)||!fs.statSync(source).isDirectory())throw Error('dependency-cache-scope'); +let count=0;function check(path){if(++count>100000)throw Error('dependency-cache-budget');const s=fs.lstatSync(path);if(s.isSymbolicLink()||!s.isFile()&&!s.isDirectory())throw Error('dependency-cache-entry');if(s.isDirectory())for(const entry of fs.readdirSync(path))check(p.join(path,entry))} +check(source);fs.cpSync(source,target,{recursive:true,errorOnExist:true,force:false});` + +export async function prepareDependencyEnvironment({ settings, scope, run, preparationNetwork = 'offline' }) { + const spec = dependencyEnvironment(settings) + const receipt = { version: 'project-environment/v1', ...scope, status: 'not-declared', startedAt: Date.now(), configurationSha256: sha(JSON.stringify({ spec, command: settings.workspacePrepareCommand ?? null })), commands: [], logs: '' } + const command = async (args, timeout) => { + const entry = { commandSha256: sha(JSON.stringify(args)), executable: args[0], startedAt: Date.now() } + receipt.commands.push(entry) + try { + const value = await run(args, timeout) + entry.status = 'passed'; entry.outputSha256 = sha(String(value.stdout ?? '') + String(value.stderr ?? '')); entry.completedAt = Date.now() + receipt.logs += String(value.stdout ?? '') + String(value.stderr ?? '') + return value + } catch (error) { + const output = String(error.stdout ?? '') + String(error.stderr ?? '') + Object.assign(entry, executionOutcome(error, output, { stage: 'preparation', remote: scope.executor === 'docker-resource' }), { completedAt: Date.now(), outputSha256: sha(output) }) + receipt.logs += output + receipt.status = entry.status; receipt.reason = entry.reason; receipt.exitConfirmed = entry.exitConfirmed; receipt.completedAt = Date.now() + error.environmentReceipt = receipt + throw error + } + } + try { + if (spec?.network === 'operator-preparation' && preparationNetwork !== 'operator-preparation') throw Object.assign(new Error('Dependency preparation network capability is not provisioned'), { code: 'bot/execution-environment' }) + const timeout = spec?.timeoutMs ?? 30_000 + const before = spec ? JSON.parse((await command(['node', '-e', environmentSnapshotScript, JSON.stringify({ lockFiles: spec.lockFiles })], timeout)).stdout) : null + if (settings.workspacePrepareCommand) await command(settings.workspacePrepareCommand, timeout) + const tools = [] + for (const tool of spec?.tools ?? []) { + const result = await command(tool.argv, timeout) + const observed = String(result.stdout).trim() + if (observed.length > 500 || tool.expectedOutput !== undefined && tool.expectedOutput !== observed) throw Object.assign(new Error('Required dependency tool version does not match'), { code: 'bot/execution-environment' }) + tools.push({ commandSha256: sha(JSON.stringify(tool.argv)), output: observed }) + } + const after = spec ? JSON.parse((await command(['node', '-e', environmentSnapshotScript, JSON.stringify(spec)], timeout)).stdout) : null + if (spec && JSON.stringify(before.locks) !== JSON.stringify(after.locks)) throw Object.assign(new Error('Dependency preparation changed a pinned lockfile'), { code: 'bot/execution-environment' }) + receipt.status = spec || settings.workspacePrepareCommand ? 'ready' : 'not-declared' + receipt.exitConfirmed = true; receipt.completedAt = Date.now() + if (after) { receipt.locks = after.locks; receipt.tools = tools; receipt.cache = after.cache ?? null; receipt.identitySha256 = sha(JSON.stringify({ projectId: scope.projectId, resourceId: scope.resourceId, executor: scope.executor, principalId: scope.principalId ?? null, configuration: receipt.configurationSha256, locks: after.locks, tools })) } + return receipt + } catch (error) { + if (!error.environmentReceipt) { Object.assign(receipt, { status: 'environment-blocked', reason: 'dependency-contract-unavailable', exitConfirmed: true, completedAt: Date.now() }); error.environmentReceipt = receipt } + throw error + } +} diff --git a/core/src/host/project-harness.js b/core/src/host/project-harness.js index b5de20b6d..beb9c7a9c 100644 --- a/core/src/host/project-harness.js +++ b/core/src/host/project-harness.js @@ -6,6 +6,7 @@ import { isAbsolute, join, resolve, relative, dirname } from 'node:path' import { createInterface } from 'node:readline' import { verifyRolePolicy, roleExecutionPrompt } from './project-role-policy.js' import { fileDigest } from './project-resource-workspaces.js' +import { copyDependencyCacheScript, executionOutcome } from './project-execution-environment.js' import { gitWritableRoots } from './project-git-sandbox.js' import { ownEntry, setEntry } from './store.js' import { checkContextPrompt, resolveContextBudget } from './project-context-budget.js' @@ -118,13 +119,23 @@ export function createProjectHarness(rt) { const scratchDirectory = chief ? null : join(selected.resource.root, `.flybot-scratch-${id}`) if (scratchDirectory) { await rt.projectResources.command(session.projectId, session.resourceLease.resourceId, { cwd: selected.resource.root, argv: ['node', '-e', 'require("node:fs").mkdirSync(process.argv[1], { mode: 0o700 })', scratchDirectory] }) + const prepared = session.environmentPreparation + if (prepared?.cache) { + if (prepared.status !== 'ready' || prepared.principalId !== selected.principalId || prepared.workspaceId !== workspace.id || prepared.resourceId !== session.resourceLease.resourceId || prepared.attemptId !== session.attemptId || prepared.projectId !== session.projectId || !prepared.identitySha256) throw refuse('Dependency cache receipt has another execution scope') + try { await rt.projectResources.command(session.projectId, session.resourceLease.resourceId, { cwd: workspace.path, argv: ['node', '-e', copyDependencyCacheScript, prepared.cache, join(scratchDirectory, 'npm-cache')] }) } + catch (error) { + const outcome = executionOutcome(error, '', { stage: 'preparation', remote: selected.resource.type === 'docker' }) + if (!outcome.exitConfirmed) await rt.projectResources.preparationUncertain(session.resourceLease) + throw error + } + } Object.assign(selected.env, { TMPDIR: scratchDirectory, npm_config_cache: join(scratchDirectory, 'npm-cache'), npm_config_update_notifier: 'false', npm_config_offline: 'true' }) } const conversation = chief ? ownEntry(rt.state.projectAgents ?? {}, chief.agentId) : null const row = { id, ...(conversation?.roomId ? { roomId: conversation.roomId } : {}), model: session.execution.model ?? selected.selected.model ?? null, chiefRunId: chief?.runId ?? null, chiefInvocation: chief?.runId ? rt.state.projectExecutions?.[chief.runId]?.invocation ?? null : null, visibility: conversation?.visibility ?? 'project', ...(conversation?.visibility === 'private' ? { memberId: conversation.memberId } : {}), projectId: session.projectId, taskId: session.taskId, attemptId: session.attemptId, role: session.role, policyRole: session.policyRole ?? session.definition?.policyRole ?? session.role, sessionKey: session.id, resourceId: session.resourceLease.resourceId, leaseId: session.resourceLease.id, generation: session.resourceLease.generation, harness: session.execution.harness, sdkVersion: null, expectedSdkVersion: VERSIONS[session.execution.harness], principalId: selected.principalId, - cwd: workspace.path, scratchDirectory, spawnPhase: 'registered', status: 'prepared', executionStartedAt: null, createdAt: Date.now(), eventCount: 0 } + cwd: workspace.path, scratchDirectory, dependencyPreparationId: session.environmentPreparation?.id ?? null, spawnPhase: 'registered', status: 'prepared', executionStartedAt: null, createdAt: Date.now(), eventCount: 0 } if (logical) Object.assign(row, { logicalSessionId: logical.id, bindingKey, bindingVersion: 'task-role-binding/v2', definitionKey, ...(previous ? { backendSessionId: previous.sessionId, continuedFrom: previous.workerId, ...(previous.key !== bindingKey ? { migratedBindingKey: previous.key } : {}) } : { rotationReason: 'First Task-role execution' }) }) else if (chief) Object.assign(row, { logicalSessionId: chief.agentId, bindingKey, ...(chief.resumeSessionId ? { backendSessionId: chief.resumeSessionId, continuedFrom: chief.previousWorkerId } : {}) }) setEntry(workers(), id, row) @@ -249,6 +260,13 @@ export function createProjectHarness(rt) { } else { row.status = 'waiting-input'; row.pending = { kind: 'message', text: event.text.slice(0, 16000) }; if (!control.chief && session.poolClaimId) await taskInputWake(row.taskId) } } + if (event.type === 'sdk-event' && event.event?.type === 'item.completed' && event.event.item?.type === 'command_execution' && Number.isInteger(event.event.item.exit_code) && event.event.item.exit_code !== 0) { + const item = event.event.item, outcome = executionOutcome({ code: item.exit_code }, item.aggregated_output ?? '') + if (outcome.status === 'environment-blocked') { + const observations = row.environmentObservations ??= [] + if (observations.length < 8) observations.push({ source: 'sdk-command-event; not independent verification', eventId: item.id ?? null, status: outcome.status, reason: outcome.reason, outputSha256: sha(String(item.aggregated_output ?? '')), at: Date.now() }) + } + } if (event.type === 'configuration-checked' && event.harness === 'codex' && event.capability?.kind === 'codex-cli-config/v1' && event.capability.version === VERSIONS.codex && event.capability.modelInvoked === false) row.configurationCheck = { kind: event.capability.kind, version: event.capability.version, profile: event.capability.profile === 'chief-read-only' ? 'chief-read-only' : 'flybot-role', at: Date.now(), modelInvoked: false } if (event.type === 'failure') { row.status = 'failed' diff --git a/core/src/host/project-repository-policy.js b/core/src/host/project-repository-policy.js index f63b2883a..c52b60de8 100644 --- a/core/src/host/project-repository-policy.js +++ b/core/src/host/project-repository-policy.js @@ -5,6 +5,7 @@ import { constants } from 'node:fs' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { ownEntry } from './store.js' +import { dependencyEnvironment } from './project-execution-environment.js' import { executionProfile } from './project-execution-profile.js' const exec = promisify(execFile) const invalid = field => Object.assign(new Error(`Repository ${field} is missing, invalid or unavailable; ask the operator to check provisioning`), { code: 'bot/repository-config', field }) @@ -22,6 +23,7 @@ export function repositorySettings(rt, project) { if (settings.workspacePrepareCommand !== undefined && (!Array.isArray(settings.workspacePrepareCommand) || !settings.workspacePrepareCommand.length || !settings.workspacePrepareCommand.every(argument) || !settings.workspacePrepareCommand[0].trim())) throw invalid('workspacePrepareCommand') if (settings.testTimeoutMs !== undefined && (!Number.isSafeInteger(settings.testTimeoutMs) || settings.testTimeoutMs < 1)) throw invalid('testTimeoutMs') if (settings.testDependencies !== undefined && (!Array.isArray(settings.testDependencies) || settings.testDependencies.some(path => !relativePath(path)))) throw invalid('testDependencies') + dependencyEnvironment(settings) return settings } async function executable(command, cwd) { diff --git a/core/src/host/project-resources.js b/core/src/host/project-resources.js index 52255a12a..196380026 100644 --- a/core/src/host/project-resources.js +++ b/core/src/host/project-resources.js @@ -116,11 +116,18 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} } async function endPreparation(token) { const lease = requireLease(token.leaseId, token.generation) - if (preparing.get(lease.id) !== token.id || lease.preparation?.id !== token.id) throw failure('Preparation completion is stale') + if (lease.preparation?.exitUnknown || preparing.get(lease.id) !== token.id || lease.preparation?.id !== token.id) throw failure('Preparation completion is stale') lease.preparation.status = 'settled'; lease.preparation.finishedAt = Date.now() try { await rt.save() } catch (error) { lease.preparation.status = 'active'; throw error } preparing.delete(lease.id) } + async function preparationUncertain(reference) { + requireBinding(reference.projectId, reference) + const lease = ownEntry(leases(), reference.id) + if (!lease || lease.generation !== reference.generation || preparing.get(lease.id) !== lease.preparation?.id || lease.preparation?.status !== 'active') throw failure('Preparation uncertainty has no active owner') + lease.preparation.exitUnknown = true; lease.preparation.unknownAt = Date.now() + await rt.save() + } async function abortReservation(id, generation, reason) { const lease = requireLease(id, generation) if (lease.status === 'released') return true @@ -305,11 +312,11 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} return item.type === 'local' ? spawn(argv[0], argv.slice(1), { ...options, cwd }) : spawn('docker', ['exec', '-i', '--user', item.user, '--workdir', cwd, item.container, 'env', '-i', 'PATH=/usr/local/bin:/usr/bin:/bin', ...argv], options) } - async function command(projectId, resourceId, { argv, cwd, timeout = 30_000, input } = {}) { + async function command(projectId, resourceId, { argv, cwd, timeout = 30_000, input, dependencyOffline = false } = {}) { const item = setting(projectId, resourceId) if (!Array.isArray(argv) || !argv.length || argv.some(value => typeof value !== 'string' || value.includes('\0')) || !isAbsolute(cwd ?? '') || !contains(item.root, cwd)) throw failure('Invalid resource command or working directory') if (argv[0] === 'node') argv = [item.nodePath ?? (item.type === 'local' ? process.execPath : 'node'), ...argv.slice(1)] - const options = { timeout: Math.min(timeout, 120_000), maxBuffer: 2 * 1024 * 1024, env: safeEnv() } + const options = { timeout: Math.min(timeout, 120_000), maxBuffer: 2 * 1024 * 1024, env: { ...safeEnv(), ...(dependencyOffline ? { npm_config_offline: 'true' } : {}) } } // No shell interpolation. Target paths and credentials never become host shell code. if (input !== undefined) throw failure('Resource command input requires a streaming adapter') if (item.type === 'local') { @@ -317,7 +324,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} if (!contains(actualRoot, actualCwd)) throw failure('Resource working directory escapes its registered root') return exec(argv[0], argv.slice(1), { ...options, cwd: actualCwd }) } - return exec('docker', ['exec', '--user', item.user, '--workdir', cwd, item.container, 'env', '-i', 'PATH=/usr/local/bin:/usr/bin:/bin', 'LANG=C.UTF-8', ...argv], options) + return exec('docker', ['exec', '--user', item.user, '--workdir', cwd, item.container, 'env', '-i', 'PATH=/usr/local/bin:/usr/bin:/bin', 'LANG=C.UTF-8', ...(dependencyOffline ? ['npm_config_offline=true'] : []), ...argv], options) } async function uploadFile(projectId, resourceId, source, target, maxBytes = 128 * 1024 * 1024) { const item = setting(projectId, resourceId) @@ -346,7 +353,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} return structuredClone(row) } return { setting, requireBinding, uploadFile, downloadFile, list, checkPool, plan, command, startProcess, probe, occupied, reconcile, recover, - beginPreparation: (...args) => serial(() => beginPreparation(...args)), endPreparation: (...args) => serial(() => endPreparation(...args)), abortReservation: (...args) => serial(() => abortReservation(...args)), fenceSpawn: (...args) => serial(() => fenceSpawn(...args)), reserveRoles: (...args) => serial(() => reserveRoles(...args)), + preparationUncertain: reference => serial(() => preparationUncertain(reference)), beginPreparation: (...args) => serial(() => beginPreparation(...args)), endPreparation: (...args) => serial(() => endPreparation(...args)), abortReservation: (...args) => serial(() => abortReservation(...args)), fenceSpawn: (...args) => serial(() => fenceSpawn(...args)), reserveRoles: (...args) => serial(() => reserveRoles(...args)), reserve: request => serial(() => reserve(request)), markStarted: (...args) => serial(() => markStarted(...args)), release: (...args) => serial(() => release(...args)) } } export function install(rt) { rt.projectResources = createProjectResources(rt) } diff --git a/core/src/host/project-service-views.js b/core/src/host/project-service-views.js index 50350dcc9..1f87883a1 100644 --- a/core/src/host/project-service-views.js +++ b/core/src/host/project-service-views.js @@ -60,9 +60,10 @@ export function attemptMetadataView(attempt) { if (attempt.pr) result.pr = pick(attempt.pr, ['number', 'sha', 'url', 'repository']) if (attempt.publicationBinding) result.publicationBinding = pick(attempt.publicationBinding, ['number', 'repository', 'branch', 'previousSha', 'upstreamSha', 'upstreamRef']) if (attempt.integrationInputs) result.integrationInputs = attempt.integrationInputs.slice(0, 20).map(row => pick(row, ['version', 'repository', 'ref', 'sha', 'purpose'])) + if (attempt.evidence?.tests?.preparation) result.environment = pick(attempt.evidence.tests.preparation, ['id', 'status', 'executor', 'workspaceId', 'resourceId', 'identitySha256', 'sha256']) if (attempt.workspaces) result.workspaces = Object.fromEntries(Object.entries(attempt.workspaces).slice(0, 32).map(([role, row]) => [role, pick(row, ['id', 'status', 'role', 'resourceId', 'materialization'])])) if (attempt.agentDefinitions) result.agentDefinitions = { revision: attempt.agentDefinitions.revision, roles: (attempt.agentDefinitions.roles ?? []).slice(0, 32).map(row => ({ ...pick(row, ['id', 'role', 'policyRole', 'name', 'model']), skills: (row.skills ?? []).slice(0, 20).map(value => short(value, 300)), execution: pick(row.execution, ['harness', 'model', 'resourceIds']) })) } - function evidence(row) { return { ...pick(row, ['sha', 'sha256', 'passed', 'verdict', 'harness', 'sdkVersion', 'resourceId', 'sessionId', 'model', 'startedAt', 'completedAt']), ...(row.summary ? { summary: short(row.summary) } : {}) } } + function evidence(row) { return { ...pick(row, ['sha', 'sha256', 'passed', 'status', 'reason', 'executor', 'testsStarted', 'exitConfirmed', 'verdict', 'harness', 'sdkVersion', 'resourceId', 'sessionId', 'model', 'startedAt', 'completedAt']), ...(row.summary ? { summary: short(row.summary) } : {}) } } result.evidence = {} for (const [key, row] of Object.entries(attempt.evidence ?? {}).slice(0, 32)) { if (key === 'execution') result.evidence.execution = Object.fromEntries(Object.entries(row).slice(0, 32).map(([role, value]) => [role, evidence(value)])) @@ -83,6 +84,7 @@ export function executionMetadataView(state, row) { const lease = row.leaseId && state.projectResourceLeases?.[row.leaseId] const exit = native ? nativeExitConfirmed(state, row) : confirmedWorkerExit(state, row) return { ...pick(row, ['id', 'role', 'resourceId', 'generation', 'sessionKey', 'logicalSessionId', 'createdAt', 'executionStartedAt', 'releasedAt']), + environmentObservations: row.environmentObservations ?? [], capabilityFailure: row.capabilityFailure ?? null, harness: native ? 'flybot' : row.harness ?? null, status: row.status, startupPhase: native ? null : row.spawnPhase ?? null, policyRole: session?.policyRole ?? row.policyRole ?? row.role ?? null, definitionRevision: session?.definitionRevision ?? null, diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index 130d9fed6..293071a58 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -946,6 +946,62 @@ test('independent host tests prepare dependencies after cleaning their exact can assert.equal((await import('node:fs')).existsSync(join(path, 'node_modules/stale')), false) }) +test('environment preparation failure records not-run tests and repaired dependencies rerun the same sealed candidate', async t => { + const f = await setup(t, [process.execPath, '-e', 'require("node:assert/strict").equal(require("./node_modules/local-fixture"),42)'], { pool: true }) + f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) + await f.start('environment-retry') + const attempt = f.attempt(), task = f.rt.state.tasks[f.task.id] + const definitions = f.rt.projectAgents.deliveryDefinitions('p1').roles + await prepareRole(f.rt, task, { id: 'environment-dev', attemptId: attempt.id, generation: 1 }, definitions.find(row => row.role === 'developer')) + writeFileSync(join(attempt.workspaces.developer.path, 'code.mjs'), 'export const add=(a,b)=>a+b;\n') + await f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: attempt.id }, role: 'developer', outputs: { implementation: 'Correct implementation' } }) + await prepareRole(f.rt, task, { id: 'environment-test', attemptId: attempt.id, generation: 2 }, definitions.find(row => row.role === 'tester')) + const settings = f.rt.config.projectRepositories['example/demo'], sha = attempt.candidateSha + settings.dependencyEnvironment = { requiredPaths: ['node_modules/local-fixture/index.js'], tools: [{ argv: [process.execPath, '--version'], expectedOutput: process.version }], network: 'offline' } + settings.workspacePrepareCommand = [process.execPath, '-e', 'process.exit(37)'] + const finish = () => f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: attempt.id }, role: 'tester', outputs: { tests: 'Request exact host verification' } }) + await assert.rejects(finish(), /environment is blocked/) + const failed = structuredClone(attempt.evidence.tests) + assert.equal(failed.passed, false); assert.equal(failed.status, 'environment-blocked'); assert.equal(failed.testsStarted, false) + assert.equal(failed.executor, 'independent-host-controller'); assert.equal(failed.sha, sha) + assert.equal(failed.preparation.status, 'environment-blocked') + const oldLog = readFileSync(failed.log, 'utf8') + settings.workspacePrepareCommand = [process.execPath, '-e', 'const fs=require("fs");fs.mkdirSync("node_modules/local-fixture",{recursive:true});fs.writeFileSync("node_modules/local-fixture/index.js","module.exports=42")'] + await finish() + assert.equal(attempt.candidateSha, sha); assert.equal(attempt.evidence.tests.sha, sha) + assert.equal(attempt.evidence.tests.passed, true); assert.equal(attempt.evidence.tests.status, 'passed') + assert.equal(attempt.evidence.tests.testsStarted, true); assert.equal(attempt.evidence.tests.preparation.status, 'ready') + assert.deepEqual(attempt.testHistory[0], failed); assert.notEqual(attempt.evidence.tests.log, failed.log) + assert.equal(readFileSync(failed.log, 'utf8'), oldLog) + settings.testCommand = [process.execPath, '-e', 'require("node:assert/strict").equal(1,2)'] + await assert.rejects(finish(), /Configured tests failed/) + assert.equal(attempt.evidence.tests.status, 'assertion-failed'); assert.equal(attempt.evidence.tests.testsStarted, true) + assert.equal(attempt.testHistory.at(-1).passed, true) +}) + +test('independent verification preserves dependency diagnostics without declaring assertion failure', async t => { + const f = await setup(t, [process.execPath, '-e', 'console.error("npm error ENOTCACHED only-if-cached");process.exit(1)']) + await f.start('offline-diagnostic') + await implement(f) + await assert.rejects(f.gate('tester'), /Configured tests failed|environment is blocked/) + const tests = f.attempt().evidence.tests + assert.equal(tests.passed, false) + assert.equal(tests.status, 'environment-blocked') + assert.equal(tests.testsStarted, true) + assert.match(readFileSync(tests.log, 'utf8'), /ENOTCACHED/) +}) + +test('independent test timeout remains incomplete verification with an immutable log', async t => { + const f = await setup(t, [process.execPath, '-e', 'setTimeout(()=>{},10000)']) + await f.start('test-timeout') + await implement(f) + f.rt.config.projectRepositories['example/demo'].testTimeoutMs = 20 + await assert.rejects(f.gate('tester'), /timed out/) + const tests = f.attempt().evidence.tests + assert.equal(tests.passed, false); assert.equal(tests.status, 'execution-timeout'); assert.equal(tests.testsStarted, true) + assert.equal(tests.exitConfirmed, true) +}) + test('bound-resource preparation executes before SDK registration and failure retains unknown occupancy', async t => { const f = await setup(t, undefined, { pool: true }) f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) diff --git a/core/test/project-execution-environment.test.js b/core/test/project-execution-environment.test.js new file mode 100644 index 000000000..6ca52fb75 --- /dev/null +++ b/core/test/project-execution-environment.test.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, mkdir, symlink, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { dependencyEnvironment, prepareDependencyEnvironment, executionOutcome, copyDependencyCacheScript } from '../src/host/project-execution-environment.js' +const exec = promisify(execFile) + +test('environment outcomes retain non-success and separate dependency, assertion, timeout and unknown exit', () => { + assert.equal(executionOutcome(null).status, 'passed') + assert.equal(executionOutcome({ code: 1 }, 'npm error ENOTCACHED only-if-cached').status, 'environment-blocked') + assert.equal(executionOutcome({ code: 1 }, 'AssertionError ERR_ASSERTION').status, 'assertion-failed') + assert.equal(executionOutcome({ code: 3 }, 'compiler failed').status, 'test-process-failed') + assert.equal(executionOutcome({ code: 'ENOENT' }).status, 'environment-blocked') + assert.equal(executionOutcome({ killed: true }).status, 'execution-timeout') + assert.equal(executionOutcome({ killed: true }, '', { remote: true }).exitConfirmed, false) + assert.equal(executionOutcome({ code: 'ECONNRESET' }, '', { remote: true }).status, 'execution-unknown') + assert.equal(executionOutcome({ code: 37 }, '', { stage: 'preparation' }).status, 'environment-blocked') +}) +test('dependency contract rejects escaping paths, arbitrary network modes and malformed commands', () => { + for (const spec of [{ lockFiles: ['../package-lock.json'] }, { npmCachePath: '/owner/cache' }, { tools: [{ argv: 'npm --version' }] }, { network: 'full-access' }, { timeoutMs: 0 }, { arbitraryAuthority: true }]) assert.throws(() => dependencyEnvironmentCheck(spec), { code: 'bot/repository-config' }) + function dependencyEnvironmentCheck(spec) { return dependencyEnvironment({ dependencyEnvironment: spec }) } +}) + +test('actual offline cache miss is not a code assertion failure; prepared offline dependencies execute real tests', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'flybot-dependency-env-')) + try { + const env = { PATH: process.env.PATH, HOME: cwd, npm_config_cache: join(cwd, 'fresh-cache'), npm_config_offline: 'true', npm_config_update_notifier: 'false', LANG: 'C.UTF-8' } + let miss + try { await exec('npm', ['exec', '--offline', '--yes', '--package=flybot-no-cached-package-2334@1.0.0', '--', 'nonexistent-command'], { cwd, env, timeout: 15_000 }) } catch (error) { miss = error } + assert.ok(miss) + assert.match(miss.stderr, /ENOTCACHED|only-if-cached/) + assert.equal(executionOutcome(miss, miss.stdout + miss.stderr).status, 'environment-blocked') + await writeFile(join(cwd, 'package-lock.json'), '{"lockfileVersion":3}') + const settings = { dependencyEnvironment: { packageManager: 'npm', lockFiles: ['package-lock.json'], requiredPaths: ['node_modules/local-fixture/index.js'], tools: [{ argv: [process.execPath, '--version'], expectedOutput: process.version }], network: 'offline' }, workspacePrepareCommand: [process.execPath, '-e', 'const fs=require("fs");fs.mkdirSync("node_modules/local-fixture",{recursive:true});fs.writeFileSync("node_modules/local-fixture/index.js","module.exports=42")'] } + const run = (argv, timeout) => exec(argv[0] === 'node' ? process.execPath : argv[0], argv.slice(1), { cwd, env, timeout }) + const scope = { projectId: 'p', attemptId: 'a', workspaceId: 'w', resourceId: 'isolated-test-resource', candidateSha: 'a'.repeat(40), executor: process.env.FLYBOT_TEST_ENV_EXECUTOR ?? 'local-resource' } + const receipt = await prepareDependencyEnvironment({ settings, scope, run }) + assert.equal(receipt.status, 'ready') + assert.equal(receipt.candidateSha, scope.candidateSha) + assert.match(receipt.locks['package-lock.json'], /^[a-f0-9]{64}$/) + assert.equal(receipt.tools[0].output, process.version) + await run([process.execPath, '-e', 'require("node:assert/strict").equal(require("local-fixture"),42)'], 5000) + const original = receipt.identitySha256 + await writeFile(join(cwd, 'package-lock.json'), '{"lockfileVersion":3,"changed":true}') + assert.notEqual((await prepareDependencyEnvironment({ settings, scope, run })).identitySha256, original) + await assert.rejects(prepareDependencyEnvironment({ settings: { ...settings, dependencyEnvironment: { ...settings.dependencyEnvironment, network: 'operator-preparation' } }, scope, run }), error => error.environmentReceipt.status === 'environment-blocked') + const bad = { ...settings, workspacePrepareCommand: [process.execPath, '-e', 'process.exit(37)'] } + await assert.rejects(prepareDependencyEnvironment({ settings: bad, scope, run }), error => error.code === 37 && error.environmentReceipt.status === 'environment-blocked') + const changed = { ...settings, workspacePrepareCommand: [process.execPath, '-e', 'require("fs").writeFileSync("package-lock.json","changed")'] } + await assert.rejects(prepareDependencyEnvironment({ settings: changed, scope, run }), error => error.environmentReceipt.reason === 'dependency-contract-unavailable') + assert.equal(await readFile(join(cwd, 'package-lock.json'), 'utf8'), 'changed') + } finally { await rm(cwd, { recursive: true, force: true }) } +}) + +test('warmed dependency caches copy into a unique scratch and refuse outside or symbolic sources', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'flybot-cache-copy-')) + try { + const source = join(cwd, 'prepared'), target = join(cwd, 'scratch-cache') + await mkdir(source) + await writeFile(join(source, 'warm'), 'cached') + const run = (relative, destination) => exec(process.execPath, ['-e', copyDependencyCacheScript, relative, destination], { cwd, env: { PATH: process.env.PATH }, timeout: 5000 }) + await run('prepared', target) + assert.equal(await readFile(join(target, 'warm'), 'utf8'), 'cached') + await writeFile(join(target, 'warm'), 'private-write') + assert.equal(await readFile(join(source, 'warm'), 'utf8'), 'cached') + await assert.rejects(run('../outside', join(cwd, 'escape'))) + await symlink(join(target, 'warm'), join(source, 'symbolic')) + await assert.rejects(run('prepared', join(cwd, 'symbolic-copy'))) + } finally { await rm(cwd, { recursive: true, force: true }) } +}) diff --git a/core/test/project-harness.test.js b/core/test/project-harness.test.js index a00277759..2190257d5 100644 --- a/core/test/project-harness.test.js +++ b/core/test/project-harness.test.js @@ -85,6 +85,40 @@ test('resource-side configuration failure remains environment evidence and relea assert.equal(rt.projectResources.occupied('r'), 0) }) +test('a declared warmed npm cache is copied into this worker scratch without sharing writable dependency state', async t => { + const { rt, harness, cwd } = await fixture(t) + const session = rt.projectDelivery.sessionFor() + session.environmentPreparation = { id: 'environment-fixture', principalId: 'owner', status: 'ready', projectId: 'p', attemptId: 'a', workspaceId: 'w', resourceId: 'r', cache: '.prepared-npm-cache', identitySha256: 'a'.repeat(64) } + await mkdir(join(cwd, '.prepared-npm-cache')) + await writeFile(join(cwd, '.prepared-npm-cache', 'warm-entry'), 'sealed-cache') + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Use only prepared dependencies' }) + const row = rt.state.projectHarnessWorkers[id] + assert.equal(row.dependencyPreparationId, 'environment-fixture') + assert.equal(await readFile(join(row.scratchDirectory, 'npm-cache/warm-entry'), 'utf8'), 'sealed-cache') + await writeFile(join(row.scratchDirectory, 'npm-cache/warm-entry'), 'worker-write') + assert.equal(await readFile(join(cwd, '.prepared-npm-cache/warm-entry'), 'utf8'), 'sealed-cache') + await harness.release(id) + assert.equal(rt.projectResources.occupied('r'), 0) +}) + +test('SDK command cache misses are advisory environment facts, never independent test results', async t => { + const { rt, harness, cwd } = await fixture(t) + const file = join(rt.config.projectResources.r.runtimeDir, 'src/host/project-harness-worker.mjs') + const source = await readFile(file, 'utf8') + await writeFile(file, source.replace("write({type:'completed'", "write({type:'sdk-event',event:{type:'item.completed',item:{id:'cmd-1',type:'command_execution',exit_code:1,aggregated_output:'npm error ENOTCACHED only-if-cached'}}});write({type:'completed'")) + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Record actual dependency limitations' }) + const result = new Promise(resolve => harness.onResult(resolve)) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Frozen requirement', outputs: [{ name: 'implementation' }] }) + assert.equal((await result).ok, true) + const observations = rt.state.projectHarnessWorkers[id].environmentObservations + assert.equal(observations.length, 1) + assert.equal(observations[0].status, 'environment-blocked') + assert.match(observations[0].source, /not independent verification/) + assert.match(observations[0].outputSha256, /^[a-f0-9]{64}$/) + assert.equal(rt.state.projectAttempts?.a?.evidence?.tests, undefined) + await harness.release(id) +}) + test('Workflow result callback can await the same SDK worker release without blocking stop acknowledgement', async t => { const { rt, harness, cwd } = await fixture(t) const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Implement Task' }) diff --git a/core/test/project-resources.test.js b/core/test/project-resources.test.js index fc2d0c41b..6934f5af9 100644 --- a/core/test/project-resources.test.js +++ b/core/test/project-resources.test.js @@ -215,6 +215,21 @@ test('asynchronous preparation remains owned until its actual completion, even a await assert.rejects(resources.beginPreparation(lease.id, lease.generation), /released/) }) +test('a timed out remote preparation cannot manufacture settled execution or reclaim capacity', async () => { + const { rt, resources } = fixture() + rt.state.projectAttempts = { a: { id: 'a', projectId: 'p1', status: 'running' } } + const lease = await resources.reserve({ projectId: 'p1', attemptId: 'a', role: 'developer', sessionKey: 'uncertain-preparation', harness: 'flybot', resourceIds: ['local'] }) + const token = await resources.beginPreparation(lease.id, lease.generation) + await resources.preparationUncertain(lease) + await assert.rejects(resources.endPreparation(token), /stale/) + rt.state.projectAttempts.a.status = 'returned' + assert.equal(await resources.abortReservation(lease.id, lease.generation, 'Timeout is not exit proof'), false) + await resources.reconcile() + assert.equal(resources.occupied('local'), 1) + assert.equal(rt.state.projectResourceLeases[lease.id].preparation.exitUnknown, true) + await assert.rejects(resources.preparationUncertain({ ...lease, generation: 2 }), /owner/) +}) + test('failed reservation/batch admission cannot return a capability or consume capacity', async () => { const { rt, resources } = fixture() rt.config.projectResources.local.capacity = 2 diff --git a/docs/backend-operations.md b/docs/backend-operations.md index 51eb056e5..fb47dd4e0 100644 --- a/docs/backend-operations.md +++ b/docs/backend-operations.md @@ -131,3 +131,31 @@ sandbox, expand network access, or replace credentials to hide a configuration error. Fix the resource profile through authorized operations, then retry the original Task. Readiness is not a login/model/test PASS; health keeps configuration failures distinct. Offline regression instructions are in the Harness contract. + +### Dependency preparation and test outcomes (#2334) + +Example operator repository configuration (not client/Task input): + +```yaml +workspacePrepareCommand: [node, /approved/prepare-workspace.mjs] +dependencyEnvironment: + packageManager: npm + lockFiles: [package-lock.json] + tools: [{argv: [node, --version]}] + requiredPaths: [node_modules] + npmCachePath: .prepared-npm-cache + network: offline + timeoutMs: 30000 +``` + +The fixed hook must create ignored dependencies/cache in each checkout. No hook +installs automatically. Prewarm offline data through authorized provisioning. If an +existing preparation command requires network, declare operator-preparation and the +same dependencyPreparationNetwork capability on its resource/repository; this grants +no network or credentials to role SDKs. Host verifier preparation is separate from +Docker preparation and runs after reset/clean. Missing tools/cache/locks block +verification. Inspect status/reason/testsStarted/executor and preparation references, +not just passed=false. The original Task/Chief controls decide retry or consultation; +fixing environment can rerun the same candidate while retaining old logs. Unknown +remote exit holds capacity until trusted recovery evidence. Use attempt.context +(testHistory/preparationFailures) and attempt.log(environmentRole) for bounded reads. diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index bb9852933..7b7bba738 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -1794,7 +1794,25 @@ } }, "evidence": { - "$ref": "#/components/schemas/JsonValue" + "type": "object", + "properties": { + "tests": { + "$ref": "#/components/schemas/TestVerification" + }, + "execution": { + "type": "object", + "additionalProperties": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "review": { + "$ref": "#/components/schemas/JsonValue" + }, + "implementation": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "additionalProperties": true }, "pr": { "anyOf": [ @@ -4402,6 +4420,232 @@ "grant" ] }, + "EnvironmentPreparation": { + "type": "object", + "properties": { + "version": { + "const": "project-environment/v1", + "type": "string" + }, + "id": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "attemptId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "role": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$" + }, + "resourceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "principalId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "candidateSha": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + "executor": { + "type": "string", + "enum": [ + "local-resource", + "docker-resource", + "independent-host-controller" + ] + }, + "status": { + "type": "string", + "enum": [ + "not-declared", + "ready", + "environment-blocked", + "execution-timeout", + "execution-unknown" + ] + }, + "reason": { + "type": "string" + }, + "exitConfirmed": { + "type": "boolean" + }, + "configurationSha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "identitySha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "locks": { + "type": "object", + "additionalProperties": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + } + }, + "tools": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JsonValue" + }, + "maxItems": 16 + }, + "cache": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ] + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "startedAt": { + "type": "integer", + "minimum": 0 + }, + "completedAt": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": true + }, + "TestVerification": { + "type": "object", + "properties": { + "sha": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "passed": { + "type": "boolean" + }, + "status": { + "type": "string", + "enum": [ + "passed", + "environment-blocked", + "assertion-failed", + "test-process-failed", + "execution-timeout", + "execution-unknown" + ] + }, + "reason": { + "type": "string" + }, + "testsStarted": { + "type": "boolean" + }, + "exitConfirmed": { + "type": "boolean" + }, + "executor": { + "type": "string" + }, + "command": { + "type": "array", + "items": { + "type": "string" + } + }, + "preparation": { + "anyOf": [ + { + "$ref": "#/components/schemas/EnvironmentPreparation" + }, + { + "type": "null" + } + ] + }, + "sha256": { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + "workspaceId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "resourceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "startedAt": { + "type": "integer", + "minimum": 0 + }, + "at": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": true + }, "DevelopmentQueryInboxListPayload": { "type": "object", "properties": { @@ -8007,7 +8251,9 @@ "requirement", "failure", "agentDefinitions", - "integrationAdmissions" + "integrationAdmissions", + "testHistory", + "preparationFailures" ] }, "offset": { @@ -8338,6 +8584,10 @@ "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$" }, + "environmentRole": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$" + }, "offset": { "type": "integer", "minimum": 0 @@ -26693,6 +26943,10 @@ "type": "string", "pattern": "^[a-z][a-z0-9-]{0,31}$" }, + "environmentRole": { + "type": "string", + "pattern": "^[a-z][a-z0-9-]{0,31}$" + }, "offset": { "type": "integer", "minimum": 0 @@ -29135,7 +29389,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" } }, { @@ -29213,7 +29467,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" } }, { @@ -29291,7 +29545,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" } }, { @@ -29369,7 +29623,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" } }, { @@ -29925,7 +30179,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" } }, { @@ -30247,7 +30501,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" } }, { @@ -30329,7 +30583,7 @@ "notes": "Although a query, the downstream integration checkpoint gate requires manager authority and confirmed release.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -30404,10 +30658,10 @@ "unifiedCommandReceipt": false }, "invokesModel": false, - "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path.", + "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path. environmentRole selects that role workspace latest preparation log and is mutually exclusive with SDK execution role. Preparation is not tests or inference.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -30568,7 +30822,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "3d8fad61f12ab8021c491a4408ad0fa5f472454667916942bd20334130ae40b1" + "sha256": "6bc492f92f46142a208149ea54cdc11cdcdd3c48c8f825c1e780fed98a2d9aff" } }, { @@ -32456,7 +32710,7 @@ "notes": "New Project Task contracts require actual Chief adjudication; third-party clients cannot forge Chief credentials.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -32564,7 +32818,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -32671,7 +32925,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -32765,7 +33019,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -32859,7 +33113,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -32968,7 +33222,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" } }, { @@ -33543,7 +33797,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "3d8fad61f12ab8021c491a4408ad0fa5f472454667916942bd20334130ae40b1" + "sha256": "6bc492f92f46142a208149ea54cdc11cdcdd3c48c8f825c1e780fed98a2d9aff" } }, { @@ -33643,7 +33897,7 @@ "notes": "30-minute inspection is a configurable schedule default, not a framework deadline. correctionBudget defaults to 2 (0–4); protocol correction reuses the Chief Session. Exhaustion is incomplete judgement, never automatic consultation or default routing. inspection.lifecycle configures queue/execution deadlines, bounded recovery delay/count and health overdue threshold; timeout is not exit proof.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "3d8fad61f12ab8021c491a4408ad0fa5f472454667916942bd20334130ae40b1" + "sha256": "6bc492f92f46142a208149ea54cdc11cdcdd3c48c8f825c1e780fed98a2d9aff" } }, { @@ -34020,7 +34274,7 @@ "notes": "payload.requestId identifies the original SDK permission question; outer requestId is the independent command receipt ID. A role string never grants execution identity.", "implementation": { "path": "core/src/host/project-harness.js", - "sha256": "acf48a6d21ec863e939f96b57bd99e880b6bcba4bed7a6bdfeaeaee6cd34e963" + "sha256": "c01fc23dd78d18d7511fcbbec77af403885d5f73f048539c25e7baf8d2eaa4c6" } }, { @@ -39340,7 +39594,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-resources.js", - "sha256": "c987f3dd0ab59491898e1427e210a1ccb89123088760275f8a449b6fe4f18375" + "sha256": "24582ddb88111dc46cec134b5bb4489c8c530448b827ffba998d97e6f3b5401e" } }, { @@ -43288,7 +43542,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" }, "aliasOf": "development.query.task.detail" }, @@ -43385,7 +43639,7 @@ "notes": "New Project Task contracts require actual Chief adjudication; third-party clients cannot forge Chief credentials.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" }, "aliasOf": "development.command.task.accept" }, @@ -43478,7 +43732,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" }, "aliasOf": "development.command.task.reject" }, @@ -43570,7 +43824,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" }, "aliasOf": "development.command.attempt.stop" }, @@ -43662,7 +43916,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" }, "aliasOf": "development.command.attempt.cleanup" }, @@ -44041,7 +44295,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" }, "aliasOf": "development.command.attempt.publication-retry" }, @@ -44114,10 +44368,10 @@ "unifiedCommandReceipt": false }, "invokesModel": false, - "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path.", + "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path. environmentRole selects that role workspace latest preparation log and is mutually exclusive with SDK execution role. Preparation is not tests or inference.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1" + "sha256": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273" }, "aliasOf": "development.query.attempt.log" }, @@ -44425,7 +44679,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "3d8fad61f12ab8021c491a4408ad0fa5f472454667916942bd20334130ae40b1" + "sha256": "6bc492f92f46142a208149ea54cdc11cdcdd3c48c8f825c1e780fed98a2d9aff" }, "aliasOf": "development.command.chief.inspect" }, @@ -44523,7 +44777,7 @@ "notes": "30-minute inspection is a configurable schedule default, not a framework deadline. correctionBudget defaults to 2 (0–4); protocol correction reuses the Chief Session. Exhaustion is incomplete judgement, never automatic consultation or default routing. inspection.lifecycle configures queue/execution deadlines, bounded recovery delay/count and health overdue threshold; timeout is not exit proof.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "3d8fad61f12ab8021c491a4408ad0fa5f472454667916942bd20334130ae40b1" + "sha256": "6bc492f92f46142a208149ea54cdc11cdcdd3c48c8f825c1e780fed98a2d9aff" }, "aliasOf": "development.command.chief.configure" }, @@ -44599,7 +44853,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "3d8fad61f12ab8021c491a4408ad0fa5f472454667916942bd20334130ae40b1" + "sha256": "6bc492f92f46142a208149ea54cdc11cdcdd3c48c8f825c1e780fed98a2d9aff" }, "aliasOf": "development.query.chief.state" }, @@ -44968,7 +45222,7 @@ "notes": "payload.requestId identifies the original SDK permission question; outer requestId is the independent command receipt ID. A role string never grants execution identity.", "implementation": { "path": "core/src/host/project-harness.js", - "sha256": "acf48a6d21ec863e939f96b57bd99e880b6bcba4bed7a6bdfeaeaee6cd34e963" + "sha256": "c01fc23dd78d18d7511fcbbec77af403885d5f73f048539c25e7baf8d2eaa4c6" }, "aliasOf": "development.command.execution.input" }, @@ -45044,7 +45298,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178" + "sha256": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b" }, "aliasOf": "development.query.pools" }, @@ -45463,7 +45717,7 @@ "core/src/host/project-agents.js": "9edc8a4af53347b2b14d5f26b739a7f6683a4767358b86e520ddcfea1162fa9c", "core/src/host/project-assistant.js": "4fa4309d4aa7abf78b6ba4e55ba754f320384f931e0e4a5c6620c859e1b50676", "core/src/host/project-board.js": "dfb0b4bc05ae42335632e3edbba883a03c5da2fb17899b23e8226320ea08aec4", - "core/src/host/project-chief-control.js": "3d8fad61f12ab8021c491a4408ad0fa5f472454667916942bd20334130ae40b1", + "core/src/host/project-chief-control.js": "6bc492f92f46142a208149ea54cdc11cdcdd3c48c8f825c1e780fed98a2d9aff", "core/src/host/project-chief-lifecycle.js": "e854d2fb22b4ea5041ea63a7f072f5af61863c9703ed063b82d0858a15129c68", "core/src/host/project-chief-protocol.js": "a09d85e04171b22625d3eed131dd6a5227dc1a946ad39d8a59f017bb75f71936", "core/src/host/project-chief.js": "a3f01a053177fd8f10d4b778d79ac98c25aee70aa3c4d967dc7e1da2aea2cf6e", @@ -45477,13 +45731,14 @@ "core/src/host/project-credentials.js": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea", "core/src/host/project-delegations.js": "d4d86192551994a3419e0d2ca0f64bc4d50a75960970d2b79ed9f97b82940903", "core/src/host/project-delivery-template.js": "daa5cc8185abc1ec84421ae3fcd5574a50573cfd1a7c4cb10941158e29c79a13", - "core/src/host/project-delivery.js": "87f6aea45fb257987d74cf7c949967eb9f302b33549989f7d58ddb59a6fadef1", - "core/src/host/project-development-service.js": "cb1e3b1cda8656f2add087f43be839776d9f6d2e93965d360bf3c8c2b1462178", + "core/src/host/project-delivery.js": "12a4f42aaec09dc79580f73f15c2787fc65c1a4f085de65c73cddaf317be0273", + "core/src/host/project-development-service.js": "857847e416da641c8699a2c02e4c02b80ca448fcd908cdc0192cc7cf6aefdf2b", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "5557782333a0b3437dacde2b6924fe12ea54225098571fa8e404d79a432be549", "core/src/host/project-execution-authority.js": "9c5ffb4bc9153d8f9d9e7901f7aef2aee90cacc19f19d8a0726ac0adb93e3e36", "core/src/host/project-execution-boundary.js": "9e7afe568024e19517a3d5430a0483a6ea5e2c910a53de056d83db3896d3eb16", "core/src/host/project-execution-capacity.js": "751b48e26f6ed1ad4c000337174b39ab4c113c494cbb7bd5a2a8369cbae10aa4", + "core/src/host/project-execution-environment.js": "26c68a7a5d456d6287081ef9f3edaf487327ef3b7bb23b35fa159003d8f0458c", "core/src/host/project-execution-exit.js": "50f94881a40c2d0c6288fd081c885c7cabb71f8abec3a8c918e1c0bc852a04f7", "core/src/host/project-execution-profile.js": "7106593979d6e21b9e1dab567c8628a7d0252976ed47d42dace337944725da59", "core/src/host/project-git-sandbox.js": "b579b01dcce0708275e9f735d08932481369803382b8f7d24af69639608d724f", @@ -45492,7 +45747,7 @@ "core/src/host/project-github-read.js": "dbbdbbc0d917bbf20c4fdf7306464cbb682b0191b98c36fddbe70e6837a57943", "core/src/host/project-github.js": "bf6d3891a6fa9c0516cd330d3322a750fc6db8813752f7801d4d9760fca84061", "core/src/host/project-harness-turn.js": "61ac162619bb5856f9786c8f7b80bdd2ff92e875828897cf49d70903cd2453df", - "core/src/host/project-harness.js": "acf48a6d21ec863e939f96b57bd99e880b6bcba4bed7a6bdfeaeaee6cd34e963", + "core/src/host/project-harness.js": "c01fc23dd78d18d7511fcbbec77af403885d5f73f048539c25e7baf8d2eaa4c6", "core/src/host/project-history-pages.js": "38958623f0aa98099e570559c55a207285fd0349689ffdcec47b965c40f4e1bc", "core/src/host/project-inputs.js": "dd52c6ddc7b0f2614ecaa3c3cccbea7a39ea914f24e1602946b93a290eff622e", "core/src/host/project-lifecycle.js": "055784e0a1cbf2a9e465e990c60fbd6df4b8844052db43bbcb7cae5de9282936", @@ -45504,9 +45759,9 @@ "core/src/host/project-process-proof.js": "85f4ed03f5191b842ca899b712c99177c059658d43a66e8ddeedc1c06eafcdea", "core/src/host/project-release-github.js": "47c52d0057dd39f1ee627c619970efad36e0134bc0c7bc218d43187230d51d64", "core/src/host/project-releases.js": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885", - "core/src/host/project-repository-policy.js": "6ccc5d682016335c4e780a420a48e3ecee02c17315a76f89bacc72d49dfc5040", + "core/src/host/project-repository-policy.js": "c16f469f3275f61e817bcad0bc8c753f01b034db6562c4e3d197f0349174823a", "core/src/host/project-resource-workspaces.js": "cc9525aab5ea9ea09d4fb2c557a1048fa034d638c0b80dbebea9cc8eba0ede8b", - "core/src/host/project-resources.js": "c987f3dd0ab59491898e1427e210a1ccb89123088760275f8a449b6fe4f18375", + "core/src/host/project-resources.js": "24582ddb88111dc46cec134b5bb4489c8c530448b827ffba998d97e6f3b5401e", "core/src/host/project-role-admission.js": "ccd5f3ac4a5d1f0010a3e533e6809b20a4f88551fc55e6c775d8289d8f9e100f", "core/src/host/project-role-context.js": "e032b6c4e8b7a902c1dedc114782025d73c630d2419f912e10cbd103c8d7f69c", "core/src/host/project-role-governance.js": "fe9ca70113666b0223f55b9225890609c5a1e7ddcb9b6105a3dc0edc3b2ef005", @@ -45517,7 +45772,7 @@ "core/src/host/project-scheduler.js": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6", "core/src/host/project-service-events.js": "71917d1ab367a9db8bef8c1d1003228255a57528e84a57e76de3a39d08ac28ec", "core/src/host/project-service-sse.js": "df97d6b11156da885a6a47d50a4a77c8c99b6af5eb0066de14312cc512762297", - "core/src/host/project-service-views.js": "176bcbe521625324906e05af3fa7cfe202c703658d5bcfefb7a2af9eeec96568", + "core/src/host/project-service-views.js": "5eb689249adccf55440d196c7e4b17e0e0f0232c8338bc1b3ea3abc0e3d84146", "core/src/host/project-service.js": "8ba30c1d7c066867eae06a6612d5bbba3bc2278f34fcf7f1c2242876565dba8f", "core/src/host/project-sources.js": "cf4c11c8e853f3091c4d159b850d06fe7e8bf291fdc616d251eb23d071d63403", "core/src/host/project-state-format.js": "4838b344439e585287fdc25ef15cea44d7aff4199c7a380ebbfa8cfcdf24f678", diff --git a/docs/subsystems/project-chief-control.md b/docs/subsystems/project-chief-control.md index ffd69e1a3..de6b306c6 100644 --- a/docs/subsystems/project-chief-control.md +++ b/docs/subsystems/project-chief-control.md @@ -257,3 +257,5 @@ reports reminder acceptance/start/completion/success, last applied non-deferral active age/deadline and recovery reason. None of these claims verified project progress; empty inspections can succeed without changing a Task. Persisted state remains the source of truth, not a misleading enabled flag or an in-memory waiter. + +#2334 supplies structured test outcome, testsStarted, actual executor and preparation receipt references plus bounded SDK dependency observations. Chief chooses recovery; an environment blockage is neither an assertion failure nor automatic escalation. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index c9dd4a0a8..bd94d539e 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -1,6 +1,8 @@ --- subsystem: project-delivery owned_paths: + - core/src/host/project-execution-environment.js + - core/test/project-execution-environment.test.js - core/src/host/project-test-diagnostics.js - core/test/project-test-diagnostics.test.js - core/src/host/project-child-inputs.js @@ -580,3 +582,48 @@ binding admission. It scopes cleanup to the original Task/Attempt/claim/generati retains workspaces and Session history, and delegates only proven unstarted release to the existing resource service. Direct resource-preparation regressions now install the exact current claim expected from the pool, preserving its original capacity-hold assertion. + +## Dependency readiness and verification outcomes (#2334) + +The optional operator `dependencyEnvironment` contract declares packageManager +(metadata), relative lockFiles/requiredPaths, tools with fixed argv and optional exact +expectedOutput, a relative warmed npmCachePath, network requirement (`offline` or +`operator-preparation`) and timeoutMs (1–120000). Unknown fields/escaping paths are +refused. The existing workspacePrepareCommand remains the only preparation hook; +metadata preflight does not execute it. Required files, actual tool versions and +lock hashes are observed in each receiving checkout, on its actual local/Docker +executor. A hook cannot silently change the pinned locks. Independent host testing +prepares again after exact candidate reset/clean; resource readiness is not host +verifier readiness. A preparation receipt is never test or model success. + +`network: operator-preparation` requires the operator capability +`dependencyPreparationNetwork: operator-preparation` on that resource (or repository +for the independent host). This acknowledges the existing fixed operator command; +it introduces no network, registry, credential or filesystem grant to an SDK. Offline +SDK commands retain the constrained profile, offline npm and unique scratch. Declare +and prewarm an ignored workspace-relative npmCachePath when needed: it is copied into +this worker's own scratch, rejects symlinks/special entries, and never shares writable +cache state with another worker. Receipt identity covers Project, executor/resource, +configuration, lock hashes and observed tools. Missing caches block admission; no +ambient shared cache, automatic download or Owner credential fallback is added. + +Private immutable preparation logs/JSON receipts bind workspace, role, executor, +candidate and command hashes. Test evidence uses status/reason/testsStarted, +exitConfirmed and preparation: passed, environment-blocked, assertion-failed, +test-process-failed, execution-timeout or execution-unknown. ENOTCACHED and dependency +or tool errors remain non-success and do not assert business code is defective. +A preparation failure has testsStarted=false. SDK command observations are bounded +advisory facts, distinct from independent host verification. Chief receives outcome, +executor and receipt references and chooses the next action; there is no automatic +Dev reroute or human escalation. + +Every verification run gets a distinct immutable log. A pool Attempt can retry an +environment-blocked test against the same sealed candidate, preserving testHistory. +Legacy workflow terminal recovery keeps its original controls; this is no permission +to revive cancelled/superseded work. Docker command timeout/transport uncertainty +retains the active preparation fence and occupied lease; killing the host docker +client does not prove its container command exited. + +Existing hash-bound pages expose attempt.context testHistory/preparationFailures; +attempt.log environmentRole selects the latest preparation for a registered role, +mutually exclusive with execution role. No caller filesystem path is accepted. diff --git a/docs/subsystems/project-harness.md b/docs/subsystems/project-harness.md index cb1c503e5..c0f055de5 100644 --- a/docs/subsystems/project-harness.md +++ b/docs/subsystems/project-harness.md @@ -422,3 +422,5 @@ scratch writes and candidate write protection. Basic tests need no vendor instal is insufficient: inspect resolved semantics and run isolated filesystem probes. See [official permissions](https://learn.chatgpt.com/docs/permissions) and [configuration read API](https://learn.chatgpt.com/docs/app-server). + +#2334 consumes the exact workspace preparation receipt before worker admission. A declared warmed cache is copied to unique scratch; SDK command environment observations are bounded advisory facts, never independent test PASS. Configuration readiness (#2337) and dependency readiness remain separate. See [delivery environment contract](project-delivery.md#dependency-readiness-and-verification-outcomes-2334). diff --git a/docs/subsystems/project-resources.md b/docs/subsystems/project-resources.md index 795684940..4f3dd4eaa 100644 --- a/docs/subsystems/project-resources.md +++ b/docs/subsystems/project-resources.md @@ -164,3 +164,5 @@ capacity after claim retirement until their actual callback settles. A restarted active preparation remains unknown; only the existing exact local supervisor proof can reconcile it automatically (remote preparation is not inferred exited). Initial reservation/batch save failures cannot leak a startup capability before return. + +#2334 retains preparation.exitUnknown after remote command timeout/transport uncertainty. endPreparation cannot settle that fence and reclamation cannot infer exit from a dead docker client. Operator preparation network declarations reuse existing fixed-command authority; they grant no SDK network access. See the delivery environment contract. diff --git a/docs/subsystems/project-service-contracts.md b/docs/subsystems/project-service-contracts.md index 6e7288b5b..d3216fca4 100644 --- a/docs/subsystems/project-service-contracts.md +++ b/docs/subsystems/project-service-contracts.md @@ -138,3 +138,5 @@ Operation names scope requestId, so different operations may reuse a key. When a ## Workspace baseline and explicit inputs — #2325 #2325 adds inspect/integrate purpose to scoped approved input metadata and sanitized workspace materialization facts (base/seed/expected/actual SHA). No private paths, key material or workspace contents are added to Attempt projections. + +#2334 includes verification status/reason/executor/testsStarted/exitConfirmed in Attempt summaries. Hash-bound attempt.context also serves testHistory and preparationFailures; attempt.log.environmentRole selects registered preparation evidence without accepting a filesystem path. API components document EnvironmentPreparation and TestVerification; role SDK observations remain advisory. diff --git a/docs/subsystems/project-workbench.md b/docs/subsystems/project-workbench.md index 65ec2b873..c07d73321 100644 --- a/docs/subsystems/project-workbench.md +++ b/docs/subsystems/project-workbench.md @@ -513,3 +513,5 @@ retry preserves the initiating principal and exact Attempt. `project-resources.capacity` reports activeSlots, waitingInput, waitingResume and exitUnknown using the actual admission predicates. occupied measures resource leases; activeSlots measures project execution. They have independent configured limits. + +#2334 metadata preflight validates dependencyEnvironment shape without running commands. Receiving executor and independent host observations occur through existing preparation, not a cached metadata-ready flag. See [dependency outcomes](project-delivery.md#dependency-readiness-and-verification-outcomes-2334). diff --git a/patches/ds-bot/files/src/host/project-chief-control.js b/patches/ds-bot/files/src/host/project-chief-control.js index 23e773eaf..8c72e1a98 100644 --- a/patches/ds-bot/files/src/host/project-chief-control.js +++ b/patches/ds-bot/files/src/host/project-chief-control.js @@ -135,13 +135,14 @@ export function createProjectChiefControl(rt) { const claim = task.collaboration?.claim, worker = rt.state.projectHarnessWorkers?.[claim?.workerId] return { taskId: task.id, chiefActionIssue: task.chiefActionIssue ?? null, chiefDecisionIssue: task.chiefDecisionIssue ?? null, title: task.title, criteria: task.criteria ?? '', projectTaskKind: task.projectTaskKind ?? 'code', status: task.status, taskContract: task.projectTask ? { version: task.projectTask.version, admission: { status: task.projectTask.admission.status, ...(task.projectTask.admission.failure ? { failure: structuredClone(task.projectTask.admission.failure) } : {}) }, requirements: structuredClone(task.projectTask.requirements), wait: structuredClone(task.projectTask.wait ?? null), dependencies: task.projectTask.dependencies.map(row => ({ ...structuredClone(row), current: dependencyFact(rt.state, task, row.taskId) })) } : null, allocationProof: allocationProof(rt.state, task), - execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, lastEventAt: worker?.lastEventAt ?? null } : null, + execution: claim ? { botId: claim.botId, status: claim.status, startedAt: claim.createdAt, elapsedMs: Date.now() - claim.createdAt, workerStatus: worker?.status ?? null, capabilityFailure: worker?.capabilityFailure ?? null, environmentObservations: worker?.environmentObservations ?? [], lastEventAt: worker?.lastEventAt ?? null } : null, handoff: task.collaboration?.history.at(-1) ? { id: task.collaboration.history.at(-1).id, purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, block: task.collaboration?.block ?? null, ...inspectionResources(rt.state, task), + environment: { preparations: Object.values(attempt?.workspaces ?? {}).filter(row => row.environmentPreparation).slice(-6).map(row => ({ id: row.environmentPreparation.id, role: row.role, executor: row.environmentPreparation.executor, status: row.environmentPreparation.status, sha256: row.environmentPreparation.sha256 })), observations: Object.values(rt.state.projectHarnessWorkers ?? {}).filter(row => row.attemptId === attempt?.id && row.projectId === task.projectId && row.taskId === task.id).flatMap(row => (row.environmentObservations ?? []).map(value => ({ ...value, workerId: row.id, role: row.role, resourceId: row.resourceId }))).slice(-8) }, repositoryRecovery: await rt.projectDelivery.recoveryFacts?.(task) ?? null, integrationInputs: structuredClone(attempt?.integrationInputs ?? []), attemptStatus: attempt?.status ?? null, generation: task.collaboration?.generation ?? 0, destination: task.collaboration?.botId ?? null, attemptId: attempt?.id ?? null, candidateSha: attempt?.candidateSha ?? null, evidence: { implementation: attempt?.evidence.implementation ? { sha: attempt.evidence.implementation.sha, paths: attempt.evidence.implementation.paths } : null, - tests: attempt?.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, + tests: attempt?.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, status: attempt.evidence.tests.status ?? null, reason: attempt.evidence.tests.reason ?? null, testsStarted: attempt.evidence.tests.testsStarted ?? null, preparation: attempt.evidence.tests.preparation ? { id: attempt.evidence.tests.preparation.id, status: attempt.evidence.tests.preparation.status, executor: attempt.evidence.tests.preparation.executor, sha256: attempt.evidence.tests.preparation.sha256 } : null, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, review: attempt?.evidence.review ? { sha: attempt.evidence.review.sha, verdict: attempt.evidence.review.verdict, summary: attempt.evidence.review.summary, repositoryVerdict: attempt.evidence.review.report?.repositoryVerdict ?? 'INC' } : null, outputs: attempt?.evidence.developer?.outputs ?? null, pr: attempt?.pr ? { number: attempt.pr.number, sha: attempt.pr.sha, url: attempt.pr.url } : null }, inspection: facts } })) diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index 172cfa53c..ab497b3ec 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -23,6 +23,7 @@ import { childResultSources, createChildInputAdmission } from './project-child-i import { reconcileExecutionAuthority } from './project-execution-authority.js' import { executionExitConfirmed } from './project-execution-exit.js' import { createProjectNativeSessions, nativeExitConfirmed } from './project-native-sessions.js' +import { prepareDependencyEnvironment, executionOutcome } from './project-execution-environment.js' import { testFailureExcerpt } from './project-test-diagnostics.js' import { EffectCancelled, EffectDeferred } from './project-effects.js' @@ -125,7 +126,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { if (source !== attempt && tests.sha !== attempt.seedSha) return null const text = await readFile(tests.log, 'utf8') if (hash(text) !== tests.sha256) throw reject('Failure diagnostic log changed') - return { sourceAttemptId: source.id, sha: tests.sha, logSha256: tests.sha256, excerpt: testFailureExcerpt(text, process.env), completeLogRetained: true, source: 'project-attempt-log; bounded host-verified diagnostic, not a passing result' } + return { status: tests.status ?? 'test-process-failed', reason: tests.reason ?? null, executor: tests.executor, testsStarted: tests.testsStarted ?? true, preparation: tests.preparation ? { id: tests.preparation.id, status: tests.preparation.status, sha256: tests.preparation.sha256, executor: tests.preparation.executor } : null, sourceAttemptId: source.id, sha: tests.sha, logSha256: tests.sha256, excerpt: testFailureExcerpt(text, process.env), completeLogRetained: true, source: 'project-attempt-log; bounded host-verified diagnostic, not a passing result' } } async function archiveForNative(context, attempt, role, suffix) { if (!context.archive) return @@ -135,12 +136,28 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { await writeFile(context.archivePath, JSON.stringify(context.archive), { mode: 0o600 }) } async function prepareWorkspace(attempt, workspace, settings) { - if (!settings.workspacePrepareCommand) return - const argv = settings.workspacePrepareCommand - // Fixed operator argv, never Task/model text; execution stays on the bound resource. - if (workspace.resourceLease) await rt.projectResources.command(workspace.projectId, workspace.resourceId, { cwd: workspace.path, argv, timeout: 30_000 }) - else await exec(argv[0], argv.slice(1), { cwd: workspace.path, env: cleanEnv(), timeout: 30_000, maxBuffer: 1024 * 1024 }) - ;(attempt.workspacePreparations ??= []).push({ role: workspace.role, workspaceId: workspace.id, commandSha256: hash(JSON.stringify(argv)), resourceId: workspace.resourceId, at: Date.now() }) + if (!settings.workspacePrepareCommand && !settings.dependencyEnvironment) return null + const resource = workspace.resourceLease ? rt.projectResources.setting(workspace.projectId, workspace.resourceId) : null + const scope = { projectId: attempt.projectId, attemptId: attempt.id, workspaceId: workspace.id, role: workspace.role, resourceId: workspace.resourceId ?? null, principalId: workspace.resourceLease?.principalId ?? attempt.executionOwnerId ?? null, candidateSha: attempt.candidateSha ?? null, executor: resource?.type === 'docker' ? 'docker-resource' : resource ? 'local-resource' : 'independent-host-controller' } + const run = (argv, timeout) => resource ? rt.projectResources.command(workspace.projectId, workspace.resourceId, { cwd: workspace.path, argv, timeout, dependencyOffline: settings.dependencyEnvironment && settings.dependencyEnvironment.network !== 'operator-preparation' }) : exec(argv[0] === 'node' ? process.execPath : argv[0], argv.slice(1), { cwd: workspace.path, env: { ...cleanEnv(), ...(settings.dependencyEnvironment && settings.dependencyEnvironment.network !== 'operator-preparation' ? { npm_config_offline: 'true' } : {}) }, timeout, maxBuffer: 1024 * 1024 }) + let receipt, failure + try { receipt = await prepareDependencyEnvironment({ settings, scope, run, preparationNetwork: resource?.dependencyPreparationNetwork ?? settings.dependencyPreparationNetwork ?? 'offline' }) } + catch (error) { failure = error; receipt = error.environmentReceipt; if (!receipt) throw error } + const { logs, ...metadata } = receipt + const directory = join(artifacts, attempt.id) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const id = `environment-${randomUUID()}`, log = join(directory, `${id}.log`) + await writeFile(log, logs, { mode: 0o600 }) + receipt = { ...metadata, id, log, sha256: hash(logs) } + await writeFile(join(directory, `${id}.json`), JSON.stringify(receipt), { mode: 0o600 }) + workspace.environmentPreparation = receipt + if (failure) { + failure.environmentReceipt = receipt + if (!receipt.exitConfirmed && state.projectResourceLeases?.[workspace.resourceLease?.id]?.preparation?.status === 'active') await rt.projectResources.preparationUncertain(workspace.resourceLease) + throw failure + } + ;(attempt.workspacePreparations ??= []).push({ role: workspace.role, workspaceId: workspace.id, commandSha256: hash(JSON.stringify(settings.workspacePrepareCommand ?? [])), resourceId: workspace.resourceId, environmentId: id, at: Date.now() }) + return receipt } async function publicationOf(previous, project) { if (!previous) return undefined @@ -587,20 +604,24 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { const path = workspace.path await workspaceGit(workspace, ['reset', '--hard', attempt.candidateSha]) await workspaceGit(workspace, ['clean', '-ffdx']) - await prepareWorkspace(attempt, workspace, settings) - const [command, ...args] = settings.testCommand - let passed = false - let output = '' + const startedAt = Date.now() + let preparation = null, error = null, output = '', stage = 'preparation' try { - const result = await exec(command, args, { cwd: path, env: cleanEnv(), timeout: Math.min(settings.testTimeoutMs ?? 120_000, 1_800_000), maxBuffer: 1024 * 1024 }) - passed = true + preparation = await prepareWorkspace(attempt, workspace, settings) + stage = 'tests' + const [command, ...args] = settings.testCommand + const result = await exec(command, args, { cwd: path, env: { ...cleanEnv(), ...(settings.dependencyEnvironment ? { npm_config_offline: 'true', ...(settings.dependencyEnvironment.npmCachePath ? { npm_config_cache: join(path, settings.dependencyEnvironment.npmCachePath) } : {}) } : {}) }, timeout: Math.min(settings.testTimeoutMs ?? 120_000, 1_800_000), maxBuffer: 1024 * 1024 }) output = result.stdout + result.stderr - } catch (error) { output = String(error.stdout ?? '') + String(error.stderr ?? '') + '\nTest process failed or exceeded its limit' } + } catch (failure) { error = failure; preparation ??= failure.environmentReceipt ?? null; output = String(failure.stdout ?? '') + String(failure.stderr ?? '') } + const outcome = executionOutcome(error, output, { stage }) + if (stage === 'preparation' && error) Object.assign(outcome, { status: preparation?.status ?? 'environment-blocked', reason: preparation?.reason ?? 'preparation-failed', exitConfirmed: preparation?.exitConfirmed ?? false }) await mkdir(join(artifacts, attempt.id), { recursive: true, mode: 0o700 }) - const log = join(artifacts, attempt.id, 'tests.log') + const log = join(artifacts, attempt.id, `tests-${randomUUID()}.log`) + if (error && !output) output = 'Verification did not complete; inspect the structured outcome and preparation receipt' await writeFile(log, output, { mode: 0o600 }) - attempt.evidence.tests = { logicalRole: deliveryRole(attempt, 'tester'), roleSessionId: attempt.roleSessions?.[deliveryRole(attempt, 'tester')]?.id ?? null, executor: 'independent-host-controller', workspaceId: workspace.id, resourceId: workspace.resourceId, sha: attempt.candidateSha, passed, command: settings.testCommand, log, sha256: hash(output), at: Date.now() } - if (!passed) throw reject('Configured tests failed; see Attempt evidence') + if (attempt.evidence.tests) (attempt.testHistory ??= []).push(structuredClone(attempt.evidence.tests)) + attempt.evidence.tests = { logicalRole: deliveryRole(attempt, 'tester'), roleSessionId: attempt.roleSessions?.[deliveryRole(attempt, 'tester')]?.id ?? null, executor: 'independent-host-controller', workspaceId: workspace.id, resourceId: workspace.resourceId, sha: attempt.candidateSha, passed: !error, status: outcome.status, reason: outcome.reason, exitConfirmed: outcome.exitConfirmed, testsStarted: stage === 'tests', preparation, command: settings.testCommand, log, sha256: hash(output), startedAt, at: Date.now() } + if (error) throw Object.assign(reject(outcome.status === 'environment-blocked' ? 'Test environment is blocked; verification is incomplete' : outcome.status === 'execution-timeout' ? 'Test execution timed out; verification is incomplete' : 'Configured tests failed; see Attempt evidence'), { outcome: outcome.status }) } async function review(attempt, outputs) { await verifyCandidate(attempt) @@ -824,11 +845,13 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { const attempt = ownEntry(attempts(), body.attemptId) if (!attempt) throw reject('Unknown Attempt') rt.requireProjectAccess(actor, attempt.projectId) - const evidence = body.role ? attempt.evidence.execution?.[body.role] : attempt.evidence.tests + if (body.environmentRole && body.role) throw reject('Select one preparation or execution log source') + const source = () => body.environmentRole ? attempt.workspaces[body.environmentRole]?.environmentPreparation : body.role ? attempt.evidence.execution?.[body.role] : attempt.evidence.tests + const evidence = source() if (!evidence?.log) return { text: null, reason: 'No test log is available' } const page = await sealedLogPage(evidence, body) rt.requireProjectAccess(actor, attempt.projectId) - if (ownEntry(attempts(), body.attemptId) !== attempt || (body.role ? attempt.evidence.execution?.[body.role] : attempt.evidence.tests) !== evidence) throw reject('Log source changed during query') + if (ownEntry(attempts(), body.attemptId) !== attempt || source() !== evidence) throw reject('Log source changed during query') return page } if (endpoint === 'project-attempt-integration-state') { @@ -938,19 +961,19 @@ export function createProjectDelivery(rt, { publisher, releaseProvider } = {}) { else await workspaceGit(workspace, ['reset', '--hard', attempt.candidateSha]) } if (project.repository) { await prepareWorkspace(attempt, workspace, settings); await prepareIntegrations(attempt, workspace) } - context = await buildProjectContext(rt, { retrieval: true, policy, projectId: project.id, taskId: task.id, role, revision: pinned.revision, resourceId: lease?.resourceId, definition, memory: definition.memory, knowledge: ownEntry(state.projectKnowledge ?? {}, project.id) ?? [], requirement: attempt.requirement, feedback: attempt.feedback, projectFacts: { workspaceMaterialization: workspace.materialization ?? null, ...(pinned.change ? { roleDefinitionChange: pinned.change } : {}), integrationInputs: structuredClone(attempt.integrationInputs ?? []), testFailure: await failureContext(attempt), publicationBinding: attempt.publicationBinding ?? null, integrationInstruction: attempt.publicationBinding ? 'Merge the pinned upstreamSha into the published candidate using --no-commit, resolve and stage conflicts before handoff. Host sealing retains both trusted inputs; never fabricate parents or discard backend changes.' : null, workingDraft: !attempt.candidateSha && workspaceFor(attempt, 'developer') ? { kind: 'unverified-workspace-reference', resourceId: workspaceFor(attempt, 'developer').resourceId, path: workspaceFor(attempt, 'developer').resourceId === workspace.resourceId ? workspaceFor(attempt, 'developer').path : null, note: 'Unsealed source, not candidate or verification evidence. Cross-resource drafts require a sealed artifact before verification.' } : null, handoff: task.collaboration?.history.at(-1) ? { purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, candidateSha: attempt.candidateSha ?? null, tests: attempt.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, review: attempt.evidence.review ? { sha: attempt.evidence.review.sha, verdict: attempt.evidence.review.verdict, summary: attempt.evidence.review.summary } : null }, repository: await repositoryContext(project.id, attempt.baseSha, project) }) - } finally { if (preparation) await rt.projectResources.endPreparation(preparation) } + context = await buildProjectContext(rt, { retrieval: true, policy, projectId: project.id, taskId: task.id, role, revision: pinned.revision, resourceId: lease?.resourceId, definition, memory: definition.memory, knowledge: ownEntry(state.projectKnowledge ?? {}, project.id) ?? [], requirement: attempt.requirement, feedback: attempt.feedback, projectFacts: { environmentPreparation: workspace.environmentPreparation ? { id: workspace.environmentPreparation.id, status: workspace.environmentPreparation.status, executor: workspace.environmentPreparation.executor, identitySha256: workspace.environmentPreparation.identitySha256 ?? null, sha256: workspace.environmentPreparation.sha256 } : null, workspaceMaterialization: workspace.materialization ?? null, ...(pinned.change ? { roleDefinitionChange: pinned.change } : {}), integrationInputs: structuredClone(attempt.integrationInputs ?? []), testFailure: await failureContext(attempt), publicationBinding: attempt.publicationBinding ?? null, integrationInstruction: attempt.publicationBinding ? 'Merge the pinned upstreamSha into the published candidate using --no-commit, resolve and stage conflicts before handoff. Host sealing retains both trusted inputs; never fabricate parents or discard backend changes.' : null, workingDraft: !attempt.candidateSha && workspaceFor(attempt, 'developer') ? { kind: 'unverified-workspace-reference', resourceId: workspaceFor(attempt, 'developer').resourceId, path: workspaceFor(attempt, 'developer').resourceId === workspace.resourceId ? workspaceFor(attempt, 'developer').path : null, note: 'Unsealed source, not candidate or verification evidence. Cross-resource drafts require a sealed artifact before verification.' } : null, handoff: task.collaboration?.history.at(-1) ? { purpose: task.collaboration.history.at(-1).purpose, reason: task.collaboration.history.at(-1).reason } : null, candidateSha: attempt.candidateSha ?? null, tests: attempt.evidence.tests ? { sha: attempt.evidence.tests.sha, passed: attempt.evidence.tests.passed, status: attempt.evidence.tests.status ?? null, reason: attempt.evidence.tests.reason ?? null, sha256: attempt.evidence.tests.sha256, command: attempt.evidence.tests.command } : null, review: attempt.evidence.review ? { sha: attempt.evidence.review.sha, verdict: attempt.evidence.review.verdict, summary: attempt.evidence.review.summary } : null }, repository: await repositoryContext(project.id, attempt.baseSha, project) }) + } finally { if (preparation && !state.projectResourceLeases?.[lease.id]?.preparation?.exitUnknown) await rt.projectResources.endPreparation(preparation) } } catch (error) { const registered = lease && ownEntry(state.projectResourceLeases ?? {}, lease.id) const unbound = registered && registered.generation === lease.generation && !registered.worker && !registered.processScope && !Object.values(state.projectHarnessWorkers ?? {}).some(row => row.leaseId === lease.id) && !Object.values(attempt.roleSessions ?? {}).some(row => row.id === sessionId) if (unbound) await rt.projectResources.release(lease.id, lease.generation) - ;(attempt.preparationFailures ??= []).push({ role, sessionId, leaseId: lease?.id ?? null, message: String(error.message), ...(error.code === 'bot/workspace-baseline' ? { code: error.code, details: structuredClone(error.details) } : {}), releasedBeforeExternalStart: Boolean(unbound && registered.status === 'released' && registered.releaseProof?.kind === 'unbound-reservation-release'), at: Date.now() }) + ;(attempt.preparationFailures ??= []).push({ role, sessionId, leaseId: lease?.id ?? null, message: error.environmentReceipt ? 'Execution environment preparation failed; see the bound receipt' : String(error.message), ...(error.environmentReceipt ? { environment: structuredClone(error.environmentReceipt) } : {}), ...(error.code === 'bot/workspace-baseline' ? { code: error.code, details: structuredClone(error.details) } : {}), releasedBeforeExternalStart: Boolean(unbound && registered.status === 'released' && registered.releaseProof?.kind === 'unbound-reservation-release'), at: Date.now() }) await rt.save() throw error } attempt.rolePolicies[role] = policy if ((execution.harness ?? 'flybot') === 'flybot') await archiveForNative(context, attempt, role, sessionId) - const session = { id: sessionId, logicalSessionId: taskSessionId(project.id, task.id, role), projectId: project.id, taskId: task.id, attemptId: attempt.id, role, policyRole, definitionRevision: pinned.revision, definitionRef: pinned.definitionRef, definitionKey: pinned.definitionKey, ...(pinned.change ? { definitionChange: pinned.change } : {}), execution: cloneExecution(execution), policy, context, generation: claim.generation, poolClaimId: claim.id, status: 'prepared', ...(lease ? { resourceLease: lease } : {}) } + const session = { id: sessionId, logicalSessionId: taskSessionId(project.id, task.id, role), projectId: project.id, taskId: task.id, attemptId: attempt.id, role, policyRole, definitionRevision: pinned.revision, definitionRef: pinned.definitionRef, definitionKey: pinned.definitionKey, ...(pinned.change ? { definitionChange: pinned.change } : {}), execution: cloneExecution(execution), environmentPreparation: workspace.environmentPreparation ?? null, policy, context, generation: claim.generation, poolClaimId: claim.id, status: 'prepared', ...(lease ? { resourceLease: lease } : {}) } if (attempt.roleSessions[role]) { (attempt.roleSessionHistory ??= []).push(structuredClone({ ...attempt.roleSessions[role], executionEvidence: attempt.evidence.execution?.[role] ?? null })) if (attempt.evidence.execution) delete attempt.evidence.execution[role] diff --git a/patches/ds-bot/files/src/host/project-development-service.js b/patches/ds-bot/files/src/host/project-development-service.js index 18b244542..f7df81ed1 100644 --- a/patches/ds-bot/files/src/host/project-development-service.js +++ b/patches/ds-bot/files/src/host/project-development-service.js @@ -161,7 +161,7 @@ export function createDevelopmentService(rt) { attemptsPage: { total: rows.length, limit, before: end > limit ? page[0].id : null } } } function context(body) { - if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions'].includes(body.section)) throw failure('Unknown Attempt context section') + if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures'].includes(body.section)) throw failure('Unknown Attempt context section') const attempt = ownEntry(rt.state.projectAttempts ?? {}, body.attemptId) const text = JSON.stringify(attempt[body.section] ?? null), offset = body.offset ?? 0, limit = body.limit ?? 8000 if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length || !Number.isSafeInteger(limit) || limit < 1 || limit > 16000) throw failure('Invalid context page') diff --git a/patches/ds-bot/files/src/host/project-execution-environment.js b/patches/ds-bot/files/src/host/project-execution-environment.js new file mode 100644 index 000000000..3b1e94ec2 --- /dev/null +++ b/patches/ds-bot/files/src/host/project-execution-environment.js @@ -0,0 +1,88 @@ +import { createHash } from 'node:crypto' + +const sha = value => createHash('sha256').update(value).digest('hex') +const relative = value => typeof value === 'string' && value.length > 0 && value.length <= 500 && !/^[\\/]|^[A-Za-z]:|[\0\r\n\\]/.test(value) && value.split('/').every(part => part && part !== '.' && part !== '..') +const argv = value => Array.isArray(value) && value.length > 0 && value.length <= 30 && value.every(part => typeof part === 'string' && part.length <= 4000 && !part.includes('\0')) && value[0].trim() +export function dependencyEnvironment(settings) { + const spec = settings.dependencyEnvironment + if (spec === undefined) return null + const invalid = () => { throw Object.assign(new Error('Repository dependencyEnvironment is invalid; check operator provisioning'), { code: 'bot/repository-config', field: 'dependencyEnvironment' }) } + if (!spec || typeof spec !== 'object' || Array.isArray(spec) || Object.keys(spec).some(key => !['packageManager', 'lockFiles', 'tools', 'requiredPaths', 'npmCachePath', 'network', 'timeoutMs'].includes(key))) invalid() + if (spec.packageManager !== undefined && (typeof spec.packageManager !== 'string' || spec.packageManager.length > 100)) invalid() + for (const key of ['lockFiles', 'requiredPaths']) if (spec[key] !== undefined && (!Array.isArray(spec[key]) || spec[key].length > 32 || !spec[key].every(relative))) invalid() + if (spec.npmCachePath !== undefined && !relative(spec.npmCachePath)) invalid() + if (spec.tools !== undefined && (!Array.isArray(spec.tools) || spec.tools.length > 16 || spec.tools.some(tool => !tool || !argv(tool.argv) || tool.expectedOutput !== undefined && (typeof tool.expectedOutput !== 'string' || tool.expectedOutput.length > 500)))) invalid() + if (spec.network !== undefined && !['offline', 'operator-preparation'].includes(spec.network)) invalid() + if (spec.timeoutMs !== undefined && (!Number.isSafeInteger(spec.timeoutMs) || spec.timeoutMs < 1 || spec.timeoutMs > 120_000)) invalid() + return spec +} + +export function executionOutcome(error, output = '', { stage = 'tests', remote = false } = {}) { + if (!error) return { status: 'passed', reason: 'command-completed', exitConfirmed: true } + const timedOut = error.killed === true || error.code === 'ETIMEDOUT' || error.signal === 'SIGTERM' || error.signal === 'SIGKILL' + if (remote && (timedOut || typeof error.code !== 'number' || [125, 255].includes(error.code))) return { status: 'execution-unknown', reason: 'resource-command-exit-unconfirmed', exitConfirmed: false } + if (timedOut) return { status: 'execution-timeout', reason: 'command-deadline', exitConfirmed: true } + if (stage === 'preparation') return { status: 'environment-blocked', reason: 'preparation-command-failed', exitConfirmed: true } + if (['ENOENT', 'EACCES'].includes(error.code) || /\b(?:ENOTCACHED|only-if-cached|ERR_MODULE_NOT_FOUND|MODULE_NOT_FOUND|EAI_AGAIN|ENOTFOUND|ENETUNREACH|ECONNREFUSED)\b|Cannot find (?:module|package)|command not found|network (?:is unreachable|access.*denied)|registry.*(?:denied|unavailable)/i.test(output)) return { status: 'environment-blocked', reason: 'dependency-tool-cache-or-network-unavailable', exitConfirmed: true } + if (typeof error.code !== 'number') return { status: 'execution-unknown', reason: 'test-exit-unconfirmed', exitConfirmed: false } + return { status: /AssertionError|ERR_ASSERTION|assertion failed/i.test(output) ? 'assertion-failed' : 'test-process-failed', reason: 'nonzero-test-exit', exitConfirmed: true } +} + +// Executed on the workspace's own executor. Paths cannot cross checkout boundaries. +export const environmentSnapshotScript = `const fs=require('node:fs'),p=require('node:path'),c=require('node:crypto'); +const s=JSON.parse(process.argv[1]),root=fs.realpathSync(process.cwd()),out={locks:{},paths:[]}; +function path(name){const a=p.resolve(root,name),b=fs.realpathSync(a);if(b!==a||!b.startsWith(root+p.sep))throw Error('dependency-path-scope');return b} +for(const name of s.lockFiles||[]){const f=path(name);if(!fs.statSync(f).isFile()||fs.statSync(f).size>8*1024*1024)throw Error('dependency-lock-unavailable');out.locks[name]=c.createHash('sha256').update(fs.readFileSync(f)).digest('hex')} +for(const name of s.requiredPaths||[]){fs.accessSync(path(name),fs.constants.R_OK);out.paths.push(name)} +if(s.npmCachePath){const f=path(s.npmCachePath);if(!fs.statSync(f).isDirectory())throw Error('dependency-cache-unavailable');out.cache=s.npmCachePath} +console.log(JSON.stringify(out));` + +export const copyDependencyCacheScript = `const fs=require('node:fs'),p=require('node:path'); +const root=fs.realpathSync(process.cwd()),source=p.resolve(root,process.argv[1]),target=process.argv[2]; +if(fs.realpathSync(source)!==source||!source.startsWith(root+p.sep)||!fs.statSync(source).isDirectory())throw Error('dependency-cache-scope'); +let count=0;function check(path){if(++count>100000)throw Error('dependency-cache-budget');const s=fs.lstatSync(path);if(s.isSymbolicLink()||!s.isFile()&&!s.isDirectory())throw Error('dependency-cache-entry');if(s.isDirectory())for(const entry of fs.readdirSync(path))check(p.join(path,entry))} +check(source);fs.cpSync(source,target,{recursive:true,errorOnExist:true,force:false});` + +export async function prepareDependencyEnvironment({ settings, scope, run, preparationNetwork = 'offline' }) { + const spec = dependencyEnvironment(settings) + const receipt = { version: 'project-environment/v1', ...scope, status: 'not-declared', startedAt: Date.now(), configurationSha256: sha(JSON.stringify({ spec, command: settings.workspacePrepareCommand ?? null })), commands: [], logs: '' } + const command = async (args, timeout) => { + const entry = { commandSha256: sha(JSON.stringify(args)), executable: args[0], startedAt: Date.now() } + receipt.commands.push(entry) + try { + const value = await run(args, timeout) + entry.status = 'passed'; entry.outputSha256 = sha(String(value.stdout ?? '') + String(value.stderr ?? '')); entry.completedAt = Date.now() + receipt.logs += String(value.stdout ?? '') + String(value.stderr ?? '') + return value + } catch (error) { + const output = String(error.stdout ?? '') + String(error.stderr ?? '') + Object.assign(entry, executionOutcome(error, output, { stage: 'preparation', remote: scope.executor === 'docker-resource' }), { completedAt: Date.now(), outputSha256: sha(output) }) + receipt.logs += output + receipt.status = entry.status; receipt.reason = entry.reason; receipt.exitConfirmed = entry.exitConfirmed; receipt.completedAt = Date.now() + error.environmentReceipt = receipt + throw error + } + } + try { + if (spec?.network === 'operator-preparation' && preparationNetwork !== 'operator-preparation') throw Object.assign(new Error('Dependency preparation network capability is not provisioned'), { code: 'bot/execution-environment' }) + const timeout = spec?.timeoutMs ?? 30_000 + const before = spec ? JSON.parse((await command(['node', '-e', environmentSnapshotScript, JSON.stringify({ lockFiles: spec.lockFiles })], timeout)).stdout) : null + if (settings.workspacePrepareCommand) await command(settings.workspacePrepareCommand, timeout) + const tools = [] + for (const tool of spec?.tools ?? []) { + const result = await command(tool.argv, timeout) + const observed = String(result.stdout).trim() + if (observed.length > 500 || tool.expectedOutput !== undefined && tool.expectedOutput !== observed) throw Object.assign(new Error('Required dependency tool version does not match'), { code: 'bot/execution-environment' }) + tools.push({ commandSha256: sha(JSON.stringify(tool.argv)), output: observed }) + } + const after = spec ? JSON.parse((await command(['node', '-e', environmentSnapshotScript, JSON.stringify(spec)], timeout)).stdout) : null + if (spec && JSON.stringify(before.locks) !== JSON.stringify(after.locks)) throw Object.assign(new Error('Dependency preparation changed a pinned lockfile'), { code: 'bot/execution-environment' }) + receipt.status = spec || settings.workspacePrepareCommand ? 'ready' : 'not-declared' + receipt.exitConfirmed = true; receipt.completedAt = Date.now() + if (after) { receipt.locks = after.locks; receipt.tools = tools; receipt.cache = after.cache ?? null; receipt.identitySha256 = sha(JSON.stringify({ projectId: scope.projectId, resourceId: scope.resourceId, executor: scope.executor, principalId: scope.principalId ?? null, configuration: receipt.configurationSha256, locks: after.locks, tools })) } + return receipt + } catch (error) { + if (!error.environmentReceipt) { Object.assign(receipt, { status: 'environment-blocked', reason: 'dependency-contract-unavailable', exitConfirmed: true, completedAt: Date.now() }); error.environmentReceipt = receipt } + throw error + } +} diff --git a/patches/ds-bot/files/src/host/project-harness.js b/patches/ds-bot/files/src/host/project-harness.js index b5de20b6d..beb9c7a9c 100644 --- a/patches/ds-bot/files/src/host/project-harness.js +++ b/patches/ds-bot/files/src/host/project-harness.js @@ -6,6 +6,7 @@ import { isAbsolute, join, resolve, relative, dirname } from 'node:path' import { createInterface } from 'node:readline' import { verifyRolePolicy, roleExecutionPrompt } from './project-role-policy.js' import { fileDigest } from './project-resource-workspaces.js' +import { copyDependencyCacheScript, executionOutcome } from './project-execution-environment.js' import { gitWritableRoots } from './project-git-sandbox.js' import { ownEntry, setEntry } from './store.js' import { checkContextPrompt, resolveContextBudget } from './project-context-budget.js' @@ -118,13 +119,23 @@ export function createProjectHarness(rt) { const scratchDirectory = chief ? null : join(selected.resource.root, `.flybot-scratch-${id}`) if (scratchDirectory) { await rt.projectResources.command(session.projectId, session.resourceLease.resourceId, { cwd: selected.resource.root, argv: ['node', '-e', 'require("node:fs").mkdirSync(process.argv[1], { mode: 0o700 })', scratchDirectory] }) + const prepared = session.environmentPreparation + if (prepared?.cache) { + if (prepared.status !== 'ready' || prepared.principalId !== selected.principalId || prepared.workspaceId !== workspace.id || prepared.resourceId !== session.resourceLease.resourceId || prepared.attemptId !== session.attemptId || prepared.projectId !== session.projectId || !prepared.identitySha256) throw refuse('Dependency cache receipt has another execution scope') + try { await rt.projectResources.command(session.projectId, session.resourceLease.resourceId, { cwd: workspace.path, argv: ['node', '-e', copyDependencyCacheScript, prepared.cache, join(scratchDirectory, 'npm-cache')] }) } + catch (error) { + const outcome = executionOutcome(error, '', { stage: 'preparation', remote: selected.resource.type === 'docker' }) + if (!outcome.exitConfirmed) await rt.projectResources.preparationUncertain(session.resourceLease) + throw error + } + } Object.assign(selected.env, { TMPDIR: scratchDirectory, npm_config_cache: join(scratchDirectory, 'npm-cache'), npm_config_update_notifier: 'false', npm_config_offline: 'true' }) } const conversation = chief ? ownEntry(rt.state.projectAgents ?? {}, chief.agentId) : null const row = { id, ...(conversation?.roomId ? { roomId: conversation.roomId } : {}), model: session.execution.model ?? selected.selected.model ?? null, chiefRunId: chief?.runId ?? null, chiefInvocation: chief?.runId ? rt.state.projectExecutions?.[chief.runId]?.invocation ?? null : null, visibility: conversation?.visibility ?? 'project', ...(conversation?.visibility === 'private' ? { memberId: conversation.memberId } : {}), projectId: session.projectId, taskId: session.taskId, attemptId: session.attemptId, role: session.role, policyRole: session.policyRole ?? session.definition?.policyRole ?? session.role, sessionKey: session.id, resourceId: session.resourceLease.resourceId, leaseId: session.resourceLease.id, generation: session.resourceLease.generation, harness: session.execution.harness, sdkVersion: null, expectedSdkVersion: VERSIONS[session.execution.harness], principalId: selected.principalId, - cwd: workspace.path, scratchDirectory, spawnPhase: 'registered', status: 'prepared', executionStartedAt: null, createdAt: Date.now(), eventCount: 0 } + cwd: workspace.path, scratchDirectory, dependencyPreparationId: session.environmentPreparation?.id ?? null, spawnPhase: 'registered', status: 'prepared', executionStartedAt: null, createdAt: Date.now(), eventCount: 0 } if (logical) Object.assign(row, { logicalSessionId: logical.id, bindingKey, bindingVersion: 'task-role-binding/v2', definitionKey, ...(previous ? { backendSessionId: previous.sessionId, continuedFrom: previous.workerId, ...(previous.key !== bindingKey ? { migratedBindingKey: previous.key } : {}) } : { rotationReason: 'First Task-role execution' }) }) else if (chief) Object.assign(row, { logicalSessionId: chief.agentId, bindingKey, ...(chief.resumeSessionId ? { backendSessionId: chief.resumeSessionId, continuedFrom: chief.previousWorkerId } : {}) }) setEntry(workers(), id, row) @@ -249,6 +260,13 @@ export function createProjectHarness(rt) { } else { row.status = 'waiting-input'; row.pending = { kind: 'message', text: event.text.slice(0, 16000) }; if (!control.chief && session.poolClaimId) await taskInputWake(row.taskId) } } + if (event.type === 'sdk-event' && event.event?.type === 'item.completed' && event.event.item?.type === 'command_execution' && Number.isInteger(event.event.item.exit_code) && event.event.item.exit_code !== 0) { + const item = event.event.item, outcome = executionOutcome({ code: item.exit_code }, item.aggregated_output ?? '') + if (outcome.status === 'environment-blocked') { + const observations = row.environmentObservations ??= [] + if (observations.length < 8) observations.push({ source: 'sdk-command-event; not independent verification', eventId: item.id ?? null, status: outcome.status, reason: outcome.reason, outputSha256: sha(String(item.aggregated_output ?? '')), at: Date.now() }) + } + } if (event.type === 'configuration-checked' && event.harness === 'codex' && event.capability?.kind === 'codex-cli-config/v1' && event.capability.version === VERSIONS.codex && event.capability.modelInvoked === false) row.configurationCheck = { kind: event.capability.kind, version: event.capability.version, profile: event.capability.profile === 'chief-read-only' ? 'chief-read-only' : 'flybot-role', at: Date.now(), modelInvoked: false } if (event.type === 'failure') { row.status = 'failed' diff --git a/patches/ds-bot/files/src/host/project-repository-policy.js b/patches/ds-bot/files/src/host/project-repository-policy.js index f63b2883a..c52b60de8 100644 --- a/patches/ds-bot/files/src/host/project-repository-policy.js +++ b/patches/ds-bot/files/src/host/project-repository-policy.js @@ -5,6 +5,7 @@ import { constants } from 'node:fs' import { execFile } from 'node:child_process' import { promisify } from 'node:util' import { ownEntry } from './store.js' +import { dependencyEnvironment } from './project-execution-environment.js' import { executionProfile } from './project-execution-profile.js' const exec = promisify(execFile) const invalid = field => Object.assign(new Error(`Repository ${field} is missing, invalid or unavailable; ask the operator to check provisioning`), { code: 'bot/repository-config', field }) @@ -22,6 +23,7 @@ export function repositorySettings(rt, project) { if (settings.workspacePrepareCommand !== undefined && (!Array.isArray(settings.workspacePrepareCommand) || !settings.workspacePrepareCommand.length || !settings.workspacePrepareCommand.every(argument) || !settings.workspacePrepareCommand[0].trim())) throw invalid('workspacePrepareCommand') if (settings.testTimeoutMs !== undefined && (!Number.isSafeInteger(settings.testTimeoutMs) || settings.testTimeoutMs < 1)) throw invalid('testTimeoutMs') if (settings.testDependencies !== undefined && (!Array.isArray(settings.testDependencies) || settings.testDependencies.some(path => !relativePath(path)))) throw invalid('testDependencies') + dependencyEnvironment(settings) return settings } async function executable(command, cwd) { diff --git a/patches/ds-bot/files/src/host/project-resources.js b/patches/ds-bot/files/src/host/project-resources.js index 52255a12a..196380026 100644 --- a/patches/ds-bot/files/src/host/project-resources.js +++ b/patches/ds-bot/files/src/host/project-resources.js @@ -116,11 +116,18 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} } async function endPreparation(token) { const lease = requireLease(token.leaseId, token.generation) - if (preparing.get(lease.id) !== token.id || lease.preparation?.id !== token.id) throw failure('Preparation completion is stale') + if (lease.preparation?.exitUnknown || preparing.get(lease.id) !== token.id || lease.preparation?.id !== token.id) throw failure('Preparation completion is stale') lease.preparation.status = 'settled'; lease.preparation.finishedAt = Date.now() try { await rt.save() } catch (error) { lease.preparation.status = 'active'; throw error } preparing.delete(lease.id) } + async function preparationUncertain(reference) { + requireBinding(reference.projectId, reference) + const lease = ownEntry(leases(), reference.id) + if (!lease || lease.generation !== reference.generation || preparing.get(lease.id) !== lease.preparation?.id || lease.preparation?.status !== 'active') throw failure('Preparation uncertainty has no active owner') + lease.preparation.exitUnknown = true; lease.preparation.unknownAt = Date.now() + await rt.save() + } async function abortReservation(id, generation, reason) { const lease = requireLease(id, generation) if (lease.status === 'released') return true @@ -305,11 +312,11 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} return item.type === 'local' ? spawn(argv[0], argv.slice(1), { ...options, cwd }) : spawn('docker', ['exec', '-i', '--user', item.user, '--workdir', cwd, item.container, 'env', '-i', 'PATH=/usr/local/bin:/usr/bin:/bin', ...argv], options) } - async function command(projectId, resourceId, { argv, cwd, timeout = 30_000, input } = {}) { + async function command(projectId, resourceId, { argv, cwd, timeout = 30_000, input, dependencyOffline = false } = {}) { const item = setting(projectId, resourceId) if (!Array.isArray(argv) || !argv.length || argv.some(value => typeof value !== 'string' || value.includes('\0')) || !isAbsolute(cwd ?? '') || !contains(item.root, cwd)) throw failure('Invalid resource command or working directory') if (argv[0] === 'node') argv = [item.nodePath ?? (item.type === 'local' ? process.execPath : 'node'), ...argv.slice(1)] - const options = { timeout: Math.min(timeout, 120_000), maxBuffer: 2 * 1024 * 1024, env: safeEnv() } + const options = { timeout: Math.min(timeout, 120_000), maxBuffer: 2 * 1024 * 1024, env: { ...safeEnv(), ...(dependencyOffline ? { npm_config_offline: 'true' } : {}) } } // No shell interpolation. Target paths and credentials never become host shell code. if (input !== undefined) throw failure('Resource command input requires a streaming adapter') if (item.type === 'local') { @@ -317,7 +324,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} if (!contains(actualRoot, actualCwd)) throw failure('Resource working directory escapes its registered root') return exec(argv[0], argv.slice(1), { ...options, cwd: actualCwd }) } - return exec('docker', ['exec', '--user', item.user, '--workdir', cwd, item.container, 'env', '-i', 'PATH=/usr/local/bin:/usr/bin:/bin', 'LANG=C.UTF-8', ...argv], options) + return exec('docker', ['exec', '--user', item.user, '--workdir', cwd, item.container, 'env', '-i', 'PATH=/usr/local/bin:/usr/bin:/bin', 'LANG=C.UTF-8', ...(dependencyOffline ? ['npm_config_offline=true'] : []), ...argv], options) } async function uploadFile(projectId, resourceId, source, target, maxBytes = 128 * 1024 * 1024) { const item = setting(projectId, resourceId) @@ -346,7 +353,7 @@ export function createProjectResources(rt, { priorExit = priorScopeExited } = {} return structuredClone(row) } return { setting, requireBinding, uploadFile, downloadFile, list, checkPool, plan, command, startProcess, probe, occupied, reconcile, recover, - beginPreparation: (...args) => serial(() => beginPreparation(...args)), endPreparation: (...args) => serial(() => endPreparation(...args)), abortReservation: (...args) => serial(() => abortReservation(...args)), fenceSpawn: (...args) => serial(() => fenceSpawn(...args)), reserveRoles: (...args) => serial(() => reserveRoles(...args)), + preparationUncertain: reference => serial(() => preparationUncertain(reference)), beginPreparation: (...args) => serial(() => beginPreparation(...args)), endPreparation: (...args) => serial(() => endPreparation(...args)), abortReservation: (...args) => serial(() => abortReservation(...args)), fenceSpawn: (...args) => serial(() => fenceSpawn(...args)), reserveRoles: (...args) => serial(() => reserveRoles(...args)), reserve: request => serial(() => reserve(request)), markStarted: (...args) => serial(() => markStarted(...args)), release: (...args) => serial(() => release(...args)) } } export function install(rt) { rt.projectResources = createProjectResources(rt) } diff --git a/patches/ds-bot/files/src/host/project-service-views.js b/patches/ds-bot/files/src/host/project-service-views.js index 50350dcc9..1f87883a1 100644 --- a/patches/ds-bot/files/src/host/project-service-views.js +++ b/patches/ds-bot/files/src/host/project-service-views.js @@ -60,9 +60,10 @@ export function attemptMetadataView(attempt) { if (attempt.pr) result.pr = pick(attempt.pr, ['number', 'sha', 'url', 'repository']) if (attempt.publicationBinding) result.publicationBinding = pick(attempt.publicationBinding, ['number', 'repository', 'branch', 'previousSha', 'upstreamSha', 'upstreamRef']) if (attempt.integrationInputs) result.integrationInputs = attempt.integrationInputs.slice(0, 20).map(row => pick(row, ['version', 'repository', 'ref', 'sha', 'purpose'])) + if (attempt.evidence?.tests?.preparation) result.environment = pick(attempt.evidence.tests.preparation, ['id', 'status', 'executor', 'workspaceId', 'resourceId', 'identitySha256', 'sha256']) if (attempt.workspaces) result.workspaces = Object.fromEntries(Object.entries(attempt.workspaces).slice(0, 32).map(([role, row]) => [role, pick(row, ['id', 'status', 'role', 'resourceId', 'materialization'])])) if (attempt.agentDefinitions) result.agentDefinitions = { revision: attempt.agentDefinitions.revision, roles: (attempt.agentDefinitions.roles ?? []).slice(0, 32).map(row => ({ ...pick(row, ['id', 'role', 'policyRole', 'name', 'model']), skills: (row.skills ?? []).slice(0, 20).map(value => short(value, 300)), execution: pick(row.execution, ['harness', 'model', 'resourceIds']) })) } - function evidence(row) { return { ...pick(row, ['sha', 'sha256', 'passed', 'verdict', 'harness', 'sdkVersion', 'resourceId', 'sessionId', 'model', 'startedAt', 'completedAt']), ...(row.summary ? { summary: short(row.summary) } : {}) } } + function evidence(row) { return { ...pick(row, ['sha', 'sha256', 'passed', 'status', 'reason', 'executor', 'testsStarted', 'exitConfirmed', 'verdict', 'harness', 'sdkVersion', 'resourceId', 'sessionId', 'model', 'startedAt', 'completedAt']), ...(row.summary ? { summary: short(row.summary) } : {}) } } result.evidence = {} for (const [key, row] of Object.entries(attempt.evidence ?? {}).slice(0, 32)) { if (key === 'execution') result.evidence.execution = Object.fromEntries(Object.entries(row).slice(0, 32).map(([role, value]) => [role, evidence(value)])) @@ -83,6 +84,7 @@ export function executionMetadataView(state, row) { const lease = row.leaseId && state.projectResourceLeases?.[row.leaseId] const exit = native ? nativeExitConfirmed(state, row) : confirmedWorkerExit(state, row) return { ...pick(row, ['id', 'role', 'resourceId', 'generation', 'sessionKey', 'logicalSessionId', 'createdAt', 'executionStartedAt', 'releasedAt']), + environmentObservations: row.environmentObservations ?? [], capabilityFailure: row.capabilityFailure ?? null, harness: native ? 'flybot' : row.harness ?? null, status: row.status, startupPhase: native ? null : row.spawnPhase ?? null, policyRole: session?.policyRole ?? row.policyRole ?? row.role ?? null, definitionRevision: session?.definitionRevision ?? null, diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index 130d9fed6..293071a58 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -946,6 +946,62 @@ test('independent host tests prepare dependencies after cleaning their exact can assert.equal((await import('node:fs')).existsSync(join(path, 'node_modules/stale')), false) }) +test('environment preparation failure records not-run tests and repaired dependencies rerun the same sealed candidate', async t => { + const f = await setup(t, [process.execPath, '-e', 'require("node:assert/strict").equal(require("./node_modules/local-fixture"),42)'], { pool: true }) + f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) + await f.start('environment-retry') + const attempt = f.attempt(), task = f.rt.state.tasks[f.task.id] + const definitions = f.rt.projectAgents.deliveryDefinitions('p1').roles + await prepareRole(f.rt, task, { id: 'environment-dev', attemptId: attempt.id, generation: 1 }, definitions.find(row => row.role === 'developer')) + writeFileSync(join(attempt.workspaces.developer.path, 'code.mjs'), 'export const add=(a,b)=>a+b;\n') + await f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: attempt.id }, role: 'developer', outputs: { implementation: 'Correct implementation' } }) + await prepareRole(f.rt, task, { id: 'environment-test', attemptId: attempt.id, generation: 2 }, definitions.find(row => row.role === 'tester')) + const settings = f.rt.config.projectRepositories['example/demo'], sha = attempt.candidateSha + settings.dependencyEnvironment = { requiredPaths: ['node_modules/local-fixture/index.js'], tools: [{ argv: [process.execPath, '--version'], expectedOutput: process.version }], network: 'offline' } + settings.workspacePrepareCommand = [process.execPath, '-e', 'process.exit(37)'] + const finish = () => f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: attempt.id }, role: 'tester', outputs: { tests: 'Request exact host verification' } }) + await assert.rejects(finish(), /environment is blocked/) + const failed = structuredClone(attempt.evidence.tests) + assert.equal(failed.passed, false); assert.equal(failed.status, 'environment-blocked'); assert.equal(failed.testsStarted, false) + assert.equal(failed.executor, 'independent-host-controller'); assert.equal(failed.sha, sha) + assert.equal(failed.preparation.status, 'environment-blocked') + const oldLog = readFileSync(failed.log, 'utf8') + settings.workspacePrepareCommand = [process.execPath, '-e', 'const fs=require("fs");fs.mkdirSync("node_modules/local-fixture",{recursive:true});fs.writeFileSync("node_modules/local-fixture/index.js","module.exports=42")'] + await finish() + assert.equal(attempt.candidateSha, sha); assert.equal(attempt.evidence.tests.sha, sha) + assert.equal(attempt.evidence.tests.passed, true); assert.equal(attempt.evidence.tests.status, 'passed') + assert.equal(attempt.evidence.tests.testsStarted, true); assert.equal(attempt.evidence.tests.preparation.status, 'ready') + assert.deepEqual(attempt.testHistory[0], failed); assert.notEqual(attempt.evidence.tests.log, failed.log) + assert.equal(readFileSync(failed.log, 'utf8'), oldLog) + settings.testCommand = [process.execPath, '-e', 'require("node:assert/strict").equal(1,2)'] + await assert.rejects(finish(), /Configured tests failed/) + assert.equal(attempt.evidence.tests.status, 'assertion-failed'); assert.equal(attempt.evidence.tests.testsStarted, true) + assert.equal(attempt.testHistory.at(-1).passed, true) +}) + +test('independent verification preserves dependency diagnostics without declaring assertion failure', async t => { + const f = await setup(t, [process.execPath, '-e', 'console.error("npm error ENOTCACHED only-if-cached");process.exit(1)']) + await f.start('offline-diagnostic') + await implement(f) + await assert.rejects(f.gate('tester'), /Configured tests failed|environment is blocked/) + const tests = f.attempt().evidence.tests + assert.equal(tests.passed, false) + assert.equal(tests.status, 'environment-blocked') + assert.equal(tests.testsStarted, true) + assert.match(readFileSync(tests.log, 'utf8'), /ENOTCACHED/) +}) + +test('independent test timeout remains incomplete verification with an immutable log', async t => { + const f = await setup(t, [process.execPath, '-e', 'setTimeout(()=>{},10000)']) + await f.start('test-timeout') + await implement(f) + f.rt.config.projectRepositories['example/demo'].testTimeoutMs = 20 + await assert.rejects(f.gate('tester'), /timed out/) + const tests = f.attempt().evidence.tests + assert.equal(tests.passed, false); assert.equal(tests.status, 'execution-timeout'); assert.equal(tests.testsStarted, true) + assert.equal(tests.exitConfirmed, true) +}) + test('bound-resource preparation executes before SDK registration and failure retains unknown occupancy', async t => { const f = await setup(t, undefined, { pool: true }) f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) diff --git a/patches/ds-bot/files/test/project-execution-environment.test.js b/patches/ds-bot/files/test/project-execution-environment.test.js new file mode 100644 index 000000000..6ca52fb75 --- /dev/null +++ b/patches/ds-bot/files/test/project-execution-environment.test.js @@ -0,0 +1,73 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { mkdtemp, mkdir, symlink, writeFile, readFile, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { execFile } from 'node:child_process' +import { promisify } from 'node:util' +import { dependencyEnvironment, prepareDependencyEnvironment, executionOutcome, copyDependencyCacheScript } from '../src/host/project-execution-environment.js' +const exec = promisify(execFile) + +test('environment outcomes retain non-success and separate dependency, assertion, timeout and unknown exit', () => { + assert.equal(executionOutcome(null).status, 'passed') + assert.equal(executionOutcome({ code: 1 }, 'npm error ENOTCACHED only-if-cached').status, 'environment-blocked') + assert.equal(executionOutcome({ code: 1 }, 'AssertionError ERR_ASSERTION').status, 'assertion-failed') + assert.equal(executionOutcome({ code: 3 }, 'compiler failed').status, 'test-process-failed') + assert.equal(executionOutcome({ code: 'ENOENT' }).status, 'environment-blocked') + assert.equal(executionOutcome({ killed: true }).status, 'execution-timeout') + assert.equal(executionOutcome({ killed: true }, '', { remote: true }).exitConfirmed, false) + assert.equal(executionOutcome({ code: 'ECONNRESET' }, '', { remote: true }).status, 'execution-unknown') + assert.equal(executionOutcome({ code: 37 }, '', { stage: 'preparation' }).status, 'environment-blocked') +}) +test('dependency contract rejects escaping paths, arbitrary network modes and malformed commands', () => { + for (const spec of [{ lockFiles: ['../package-lock.json'] }, { npmCachePath: '/owner/cache' }, { tools: [{ argv: 'npm --version' }] }, { network: 'full-access' }, { timeoutMs: 0 }, { arbitraryAuthority: true }]) assert.throws(() => dependencyEnvironmentCheck(spec), { code: 'bot/repository-config' }) + function dependencyEnvironmentCheck(spec) { return dependencyEnvironment({ dependencyEnvironment: spec }) } +}) + +test('actual offline cache miss is not a code assertion failure; prepared offline dependencies execute real tests', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'flybot-dependency-env-')) + try { + const env = { PATH: process.env.PATH, HOME: cwd, npm_config_cache: join(cwd, 'fresh-cache'), npm_config_offline: 'true', npm_config_update_notifier: 'false', LANG: 'C.UTF-8' } + let miss + try { await exec('npm', ['exec', '--offline', '--yes', '--package=flybot-no-cached-package-2334@1.0.0', '--', 'nonexistent-command'], { cwd, env, timeout: 15_000 }) } catch (error) { miss = error } + assert.ok(miss) + assert.match(miss.stderr, /ENOTCACHED|only-if-cached/) + assert.equal(executionOutcome(miss, miss.stdout + miss.stderr).status, 'environment-blocked') + await writeFile(join(cwd, 'package-lock.json'), '{"lockfileVersion":3}') + const settings = { dependencyEnvironment: { packageManager: 'npm', lockFiles: ['package-lock.json'], requiredPaths: ['node_modules/local-fixture/index.js'], tools: [{ argv: [process.execPath, '--version'], expectedOutput: process.version }], network: 'offline' }, workspacePrepareCommand: [process.execPath, '-e', 'const fs=require("fs");fs.mkdirSync("node_modules/local-fixture",{recursive:true});fs.writeFileSync("node_modules/local-fixture/index.js","module.exports=42")'] } + const run = (argv, timeout) => exec(argv[0] === 'node' ? process.execPath : argv[0], argv.slice(1), { cwd, env, timeout }) + const scope = { projectId: 'p', attemptId: 'a', workspaceId: 'w', resourceId: 'isolated-test-resource', candidateSha: 'a'.repeat(40), executor: process.env.FLYBOT_TEST_ENV_EXECUTOR ?? 'local-resource' } + const receipt = await prepareDependencyEnvironment({ settings, scope, run }) + assert.equal(receipt.status, 'ready') + assert.equal(receipt.candidateSha, scope.candidateSha) + assert.match(receipt.locks['package-lock.json'], /^[a-f0-9]{64}$/) + assert.equal(receipt.tools[0].output, process.version) + await run([process.execPath, '-e', 'require("node:assert/strict").equal(require("local-fixture"),42)'], 5000) + const original = receipt.identitySha256 + await writeFile(join(cwd, 'package-lock.json'), '{"lockfileVersion":3,"changed":true}') + assert.notEqual((await prepareDependencyEnvironment({ settings, scope, run })).identitySha256, original) + await assert.rejects(prepareDependencyEnvironment({ settings: { ...settings, dependencyEnvironment: { ...settings.dependencyEnvironment, network: 'operator-preparation' } }, scope, run }), error => error.environmentReceipt.status === 'environment-blocked') + const bad = { ...settings, workspacePrepareCommand: [process.execPath, '-e', 'process.exit(37)'] } + await assert.rejects(prepareDependencyEnvironment({ settings: bad, scope, run }), error => error.code === 37 && error.environmentReceipt.status === 'environment-blocked') + const changed = { ...settings, workspacePrepareCommand: [process.execPath, '-e', 'require("fs").writeFileSync("package-lock.json","changed")'] } + await assert.rejects(prepareDependencyEnvironment({ settings: changed, scope, run }), error => error.environmentReceipt.reason === 'dependency-contract-unavailable') + assert.equal(await readFile(join(cwd, 'package-lock.json'), 'utf8'), 'changed') + } finally { await rm(cwd, { recursive: true, force: true }) } +}) + +test('warmed dependency caches copy into a unique scratch and refuse outside or symbolic sources', async () => { + const cwd = await mkdtemp(join(tmpdir(), 'flybot-cache-copy-')) + try { + const source = join(cwd, 'prepared'), target = join(cwd, 'scratch-cache') + await mkdir(source) + await writeFile(join(source, 'warm'), 'cached') + const run = (relative, destination) => exec(process.execPath, ['-e', copyDependencyCacheScript, relative, destination], { cwd, env: { PATH: process.env.PATH }, timeout: 5000 }) + await run('prepared', target) + assert.equal(await readFile(join(target, 'warm'), 'utf8'), 'cached') + await writeFile(join(target, 'warm'), 'private-write') + assert.equal(await readFile(join(source, 'warm'), 'utf8'), 'cached') + await assert.rejects(run('../outside', join(cwd, 'escape'))) + await symlink(join(target, 'warm'), join(source, 'symbolic')) + await assert.rejects(run('prepared', join(cwd, 'symbolic-copy'))) + } finally { await rm(cwd, { recursive: true, force: true }) } +}) diff --git a/patches/ds-bot/files/test/project-harness.test.js b/patches/ds-bot/files/test/project-harness.test.js index a00277759..2190257d5 100644 --- a/patches/ds-bot/files/test/project-harness.test.js +++ b/patches/ds-bot/files/test/project-harness.test.js @@ -85,6 +85,40 @@ test('resource-side configuration failure remains environment evidence and relea assert.equal(rt.projectResources.occupied('r'), 0) }) +test('a declared warmed npm cache is copied into this worker scratch without sharing writable dependency state', async t => { + const { rt, harness, cwd } = await fixture(t) + const session = rt.projectDelivery.sessionFor() + session.environmentPreparation = { id: 'environment-fixture', principalId: 'owner', status: 'ready', projectId: 'p', attemptId: 'a', workspaceId: 'w', resourceId: 'r', cache: '.prepared-npm-cache', identitySha256: 'a'.repeat(64) } + await mkdir(join(cwd, '.prepared-npm-cache')) + await writeFile(join(cwd, '.prepared-npm-cache', 'warm-entry'), 'sealed-cache') + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Use only prepared dependencies' }) + const row = rt.state.projectHarnessWorkers[id] + assert.equal(row.dependencyPreparationId, 'environment-fixture') + assert.equal(await readFile(join(row.scratchDirectory, 'npm-cache/warm-entry'), 'utf8'), 'sealed-cache') + await writeFile(join(row.scratchDirectory, 'npm-cache/warm-entry'), 'worker-write') + assert.equal(await readFile(join(cwd, '.prepared-npm-cache/warm-entry'), 'utf8'), 'sealed-cache') + await harness.release(id) + assert.equal(rt.projectResources.occupied('r'), 0) +}) + +test('SDK command cache misses are advisory environment facts, never independent test results', async t => { + const { rt, harness, cwd } = await fixture(t) + const file = join(rt.config.projectResources.r.runtimeDir, 'src/host/project-harness-worker.mjs') + const source = await readFile(file, 'utf8') + await writeFile(file, source.replace("write({type:'completed'", "write({type:'sdk-event',event:{type:'item.completed',item:{id:'cmd-1',type:'command_execution',exit_code:1,aggregated_output:'npm error ENOTCACHED only-if-cached'}}});write({type:'completed'")) + const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Record actual dependency limitations' }) + const result = new Promise(resolve => harness.onResult(resolve)) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Frozen requirement', outputs: [{ name: 'implementation' }] }) + assert.equal((await result).ok, true) + const observations = rt.state.projectHarnessWorkers[id].environmentObservations + assert.equal(observations.length, 1) + assert.equal(observations[0].status, 'environment-blocked') + assert.match(observations[0].source, /not independent verification/) + assert.match(observations[0].outputSha256, /^[a-f0-9]{64}$/) + assert.equal(rt.state.projectAttempts?.a?.evidence?.tests, undefined) + await harness.release(id) +}) + test('Workflow result callback can await the same SDK worker release without blocking stop acknowledgement', async t => { const { rt, harness, cwd } = await fixture(t) const id = await harness.spawn({ executionProjectId: 'p', executionWorkDir: cwd, prompt: 'Implement Task' }) diff --git a/patches/ds-bot/files/test/project-resources.test.js b/patches/ds-bot/files/test/project-resources.test.js index fc2d0c41b..6934f5af9 100644 --- a/patches/ds-bot/files/test/project-resources.test.js +++ b/patches/ds-bot/files/test/project-resources.test.js @@ -215,6 +215,21 @@ test('asynchronous preparation remains owned until its actual completion, even a await assert.rejects(resources.beginPreparation(lease.id, lease.generation), /released/) }) +test('a timed out remote preparation cannot manufacture settled execution or reclaim capacity', async () => { + const { rt, resources } = fixture() + rt.state.projectAttempts = { a: { id: 'a', projectId: 'p1', status: 'running' } } + const lease = await resources.reserve({ projectId: 'p1', attemptId: 'a', role: 'developer', sessionKey: 'uncertain-preparation', harness: 'flybot', resourceIds: ['local'] }) + const token = await resources.beginPreparation(lease.id, lease.generation) + await resources.preparationUncertain(lease) + await assert.rejects(resources.endPreparation(token), /stale/) + rt.state.projectAttempts.a.status = 'returned' + assert.equal(await resources.abortReservation(lease.id, lease.generation, 'Timeout is not exit proof'), false) + await resources.reconcile() + assert.equal(resources.occupied('local'), 1) + assert.equal(rt.state.projectResourceLeases[lease.id].preparation.exitUnknown, true) + await assert.rejects(resources.preparationUncertain({ ...lease, generation: 2 }), /owner/) +}) + test('failed reservation/batch admission cannot return a capability or consume capacity', async () => { const { rt, resources } = fixture() rt.config.projectResources.local.capacity = 2 diff --git a/scripts/api-contracts.mjs b/scripts/api-contracts.mjs index dff526230..46d96dea3 100644 --- a/scripts/api-contracts.mjs +++ b/scripts/api-contracts.mjs @@ -181,9 +181,9 @@ q('pools', empty, result({ pools: array(record({ botId: id, role, tasks: array(j q('inputs', obj({ ...taskId, ...page, limit: int(1, 200) }, ['taskId']), record({ inputs: array(ref('Input')), next: nullable(id) }), { source: 'core/src/host/project-inputs.js', notes: 'Receipt states distinguish persisted, delivered, model-declared-read and handled; none imply Task completion.' }) q('sources', obj({ threadId: id, eventId: id, offset: int(), ...page }), record({ sources: array(json), threads: array(json), subscriptions: array(json), events: array(json), next: nullable(id), event: json, text: str(), offset: int(), nextOffset: nullable(int()), sha256: hash }), { source: 'core/src/host/project-sources.js' }) q('execution.boundary', obj(taskId), record({ projectId: id, profile: json, authority: json, task: nullable(json), repository: json, resources: array(json), harnesses: json, limits: json }), { source: 'core/src/host/project-execution-boundary.js', notes: 'Reports effective current boundaries; it neither runs tests nor grants tools/credentials.' }) -q('attempt.context', obj({ ...attemptId, section: choice('roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions'), ...sourcePage }, ['attemptId', 'section']), ref('SourcePage')) +q('attempt.context', obj({ ...attemptId, section: choice('roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures'), ...sourcePage }, ['attemptId', 'section']), ref('SourcePage')) q('attempt.integration-state', obj(attemptId, ['attemptId']), result({ attemptId: id, generation: int(), inputs: array(json), availableInputs: array(json), materialization: nullable(json), snapshot: json }), { permission: 'configure', source: 'core/src/host/project-delivery.js', preconditions: ['current-project-manager', 'current-released-integration-checkpoint'], notes: 'Although a query, the downstream integration checkpoint gate requires manager authority and confirmed release.' }) -q('attempt.log', obj({ ...attemptId, role, offset: int(), limit: int(4, 8192), expectedSha256: hash }, ['attemptId']), ref('LogPage'), { source: 'core/src/host/project-delivery.js', notes: 'UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path.' }) +q('attempt.log', obj({ ...attemptId, role, environmentRole: role, offset: int(), limit: int(4, 8192), expectedSha256: hash }, ['attemptId']), ref('LogPage'), { source: 'core/src/host/project-delivery.js', notes: 'UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path. environmentRole selects that role workspace latest preparation log and is mutually exclusive with SDK execution role. Preparation is not tests or inference.' }) q('release.status', obj(attemptId, ['attemptId']), result({ release: nullable(ref('Release')) }), { source: 'core/src/host/project-releases.js', preconditions: ['current-project-membership', 'current-release-task-attempt'] }) q('chief.state', empty, record({ projectId: id, policy: nullable(json), scheduledTask: nullable(json), queued: array(json), active: array(json), runs: array(json), proposals: array(json) }), { source: 'core/src/host/project-chief-control.js' }) q('effects', obj({ ...taskId, limit: int(1, 200) }), result({ effects: array(ref('Effect')) }), { source: 'core/src/host/project-effects.js', notes: 'Private command effects/results remain visible only to the matching independent principal.' }) @@ -338,3 +338,8 @@ for (const operation of ['create', 'graph']) { node.outputs = array({ anyOf: [str(40), obj({ name: str(40), type: str(40) }, ['name'])] }, 8) node.prompt.maxLength = 4000 } + +// Execution environment outcomes are independent of model-role completion. +schemas.EnvironmentPreparation = record({ version: lit('project-environment/v1'), id, projectId: id, attemptId: id, workspaceId: id, role, resourceId: nullable(id), principalId: nullable(id), candidateSha: nullable(sha), executor: choice('local-resource', 'docker-resource', 'independent-host-controller'), status: choice('not-declared', 'ready', 'environment-blocked', 'execution-timeout', 'execution-unknown'), reason: str(), exitConfirmed: bool, configurationSha256: hash, identitySha256: hash, locks: map(hash), tools: array(json, 16), cache: nullable(str()), sha256: hash, startedAt: int(), completedAt: int() }) +schemas.TestVerification = record({ sha, passed: bool, status: choice('passed', 'environment-blocked', 'assertion-failed', 'test-process-failed', 'execution-timeout', 'execution-unknown'), reason: str(), testsStarted: bool, exitConfirmed: bool, executor: str(), command: array(str()), preparation: nullable(ref('EnvironmentPreparation')), sha256: hash, workspaceId: id, resourceId: nullable(id), startedAt: int(), at: int() }) +schemas.Attempt.properties.evidence = record({ tests: ref('TestVerification'), execution: map(json), review: json, implementation: json })