diff --git a/PROJECT.md b/PROJECT.md index 8908ad3a7..19136834d 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -207,7 +207,7 @@ and unknown leases while digesting only proven released history. Dated UI drafts asset measurements and test reports remain in [the archive](docs/history/2026-11-02-collaboration-implementation.md). ### Task-scoped SDK context and resident Chief memory — 2026-11-01 -Owner requires a resident Chief with durable Project memory and one logical Session per Task/role. Codex/Claude own their supported conversation persistence and automatic compaction; FlyBot does not inject a second model-history compactor. SDK role rework reuses a confirmed released Task-role cwd/thread under the same principal/resource/profile/model/persona binding, while preserving separate immutable Attempt/claim/log/test/Review evidence. Changed or incompatible bindings expose an explicit context-resume problem and retain history; they cannot silently start a fresh conversation. Chief memory uses the original own-Bot MEMORY.md/topics/journal, never global team or private-user memory. Context admission keeps current bounded facts and hash-bound full source archives for scoped retrieval. Backend changes are foreground work; frontend Task40/PR48 resumes through FlyBot after validation. +The original implementation checkpoint is [preserved verbatim](docs/history/2026-11-04-context-implementation-checkpoint.md). Current contracts and acceptance follow. Keep Chief durable memory and one logical Session per Task/role. SDKs retain their supported persistence/compaction; FlyBot keeps bounded facts and exact binding/exit @@ -314,3 +314,7 @@ Task execution pins its associated repository independently of Project defaults; #2334 follow-up (updated issue): shell 127 / missing test runners are environment blockers. testsStarted records command invocation; stage distinguishes runner startup from preparation and tests. Nested Node test markers do not enter host verification. + +#2338 preflights input receipts before host side effects and returns correctable +errors to the original role/Chief Session. Frozen proposals, bounded tool-free +correction and durable receipts preserve work; stale/revoked input stays denied. diff --git a/ROADMAP.md b/ROADMAP.md index ea0abce35..620cce81e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -173,3 +173,8 @@ deferred multi-repository automation is not reactivated by this recovery work. Updated #2334 acceptance adds actual npm missing-vitest/127 classification and clean/prepare/same-candidate retest coverage; it remains a source/test follow-up, not live npm ci provisioning or production Vitest acceptance. + +#2338: bounded input receipt correction in the original Task-role/Chief context, +with all-plan preflight, immutable business evidence and explicit incomplete +recovery. Tests distinguish deterministic role transport and actual SDK execution +with a local provider fixture from paid-model/live project acceptance. diff --git a/changelog.d/2338.fixed.md b/changelog.d/2338.fixed.md new file mode 100644 index 000000000..cbdaf027a --- /dev/null +++ b/changelog.d/2338.fixed.md @@ -0,0 +1 @@ +Return correctable input receipt errors to the original role or Chief Session before host completion, with bounded tool-free correction and immutable business evidence. diff --git a/core/src/host/hooks.js b/core/src/host/hooks.js index adca68bbf..4c5580b71 100644 --- a/core/src/host/hooks.js +++ b/core/src/host/hooks.js @@ -1407,6 +1407,17 @@ export function install(rt) { if (result?.ok === false) throw new Error(result.code ?? 'Pool dispatch failed') if (taskInbox?.inputs.length && !rt.state.projectHarnessWorkers?.[workerId]) await rt.projectInputs.transport(task.id, claim, taskInbox.batchId, { kind: 'adapter-written', sha256: createHash('sha256').update(JSON.stringify(taskInbox)).digest('hex') }) }, + async correct(input) { + const sdk = await rt.projectHarness?.correctInput(input) + if (sdk) return sdk + if (typeof rt.ctx.tools?.guard !== 'function') throw new Error('Native receipt correction requires the tool guard') + const sessionId = rt.chatOf(input.workerId), agent = sessionId && await rt.resolveAgent(sessionId) + if (!agent || agent.session?.id !== sessionId || typeof agent.whenIdle !== 'function') throw new Error('Original native receipt Session is unavailable') + await agent.whenIdle() + const c = input.task.collaboration + if (rt.chatOf(input.workerId) !== sessionId || c.claim?.id !== input.claim.id || c.generation !== input.claim.generation || c.claim.inputCorrection?.round !== input.round || c.claim.inputCorrection?.status !== 'pending') throw new Error('Native receipt correction was superseded') + return lifecycle.dispatch({ botId: input.workerId, taskId: input.task.id, brief: input.prompt, inputs: {}, outputs: [{ name: 'handoff', required: true }] }) + }, async release(id) { await lifecycle.release(id) return rt.projectDelivery.exitConfirmed(id) diff --git a/core/src/host/project-chief-control.js b/core/src/host/project-chief-control.js index 97e30baeb..2994f1f5d 100644 --- a/core/src/host/project-chief-control.js +++ b/core/src/host/project-chief-control.js @@ -6,6 +6,7 @@ import { fileDigest } from './project-resource-workspaces.js' import { ownEntry, setEntry } from './store.js' import { dependencyFact } from './project-task-lifecycle.js' import { inspectionLifecycle, inspectionHealth, scheduledInspectionState } from './project-chief-lifecycle.js' +import { protocolCorrection } from './project-receipt-correction.js' import { CHIEF_AUTONOMY, parseChiefResponse, correctionBudget, chiefProtocolError } from './project-chief-protocol.js' import { confirmedWorkerExit } from './project-task-sessions.js' import { stageRoleSummary } from './project-context-memory.js' @@ -232,6 +233,16 @@ export function createProjectChiefControl(rt) { if (run.taskId && !parsed.decisions.some(row => row.taskId === run.taskId)) throw chiefProtocolError('decisions', 'an explicit decision for the requested Task', run.taskId) if (run.taskId) { const task = rt.state.tasks[run.taskId]; if (task.collaboration?.claim?.chiefRunId === run.id) { task.collaboration.claim.workerId = execution.workerId ?? `native-chief-job-${run.id}`; task.collaboration.claim.executionEvidence = execution } } job.executionEvidence = execution + for (const decision of parsed.decisions) { + if (run.appliedDecisions?.some(row => row.taskId === decision.taskId)) continue + const saved = job.snapshot.find(row => row.taskId === decision.taskId) + if (!saved) throw chiefProtocolError('taskId', 'an exact Task in this invocation snapshot', decision.taskId) + try { rt.projectInputs?.validateChiefReceipt?.(decision.taskId, saved, run, decision.inputReceipts) } + catch (error) { + if (!error.receiptFeedback?.correctable) throw error + throw Object.assign(chiefProtocolError('inputReceipts', 'the exact injected snapshot IDs and handled subset of read', decision.taskId), { receiptFeedback: error.receiptFeedback }) + } + } const roleProposals = parsed.roleProposals ?? [] if (!Array.isArray(roleProposals) || roleProposals.length > 4) throw refuse('Chief role proposals must be a bounded array') run.roleProposalIds = [] @@ -264,8 +275,8 @@ export function createProjectChiefControl(rt) { if (active.get(run.id) !== job || run.status !== 'running') return { ignored: true } if (error.code === 'bot/chief-protocol') { const previous = run.correction, round = (previous?.round ?? 0) + 1, limit = run.correctionLimit ?? correctionBudget(policyOf(run.projectId)) - const feedback = { code: error.code, taskId: error.taskId, fields: error.fields, message: error.message.slice(0, 500), at: Date.now(), sideEffects: 'response protocol rejected before applying its decisions', appliedTaskIds: (run.appliedDecisions ?? []).map(row => row.taskId) } - run.correction = { version: 'chief-correction/v1', round, limit, status: round <= limit ? 'pending' : 'exhausted', feedback: [...(previous?.feedback ?? []), feedback].slice(-5), priorExecution: structuredClone(job.executionEvidence ?? null) } + const feedback = { ...(error.receiptFeedback ? { receiptFeedback: error.receiptFeedback } : {}), code: error.code, taskId: error.taskId, fields: error.fields, message: error.message.slice(0, 500), at: Date.now(), sideEffects: 'response protocol rejected before applying its decisions', appliedTaskIds: (run.appliedDecisions ?? []).map(row => row.taskId) } + run.correction = protocolCorrection(previous, { version: 'chief-correction/v1', limit, feedback, priorExecution: job.executionEvidence ?? null }) run.status = round <= limit ? 'correction-pending' : 'incomplete'; run.failure = feedback.message if (rt.projectEffects) { const token = rt.projectEffects.stage({ kind: 'chief.correction', key: `${run.id}:${round}`, projectId: run.projectId, ...(run.taskId ? { taskId: run.taskId } : {}), payload: { runId: run.id, round, feedbackSha256: digest(feedback) } }) diff --git a/core/src/host/project-development-service.js b/core/src/host/project-development-service.js index 43fea4444..90b22ce3b 100644 --- a/core/src/host/project-development-service.js +++ b/core/src/host/project-development-service.js @@ -51,6 +51,8 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm const nativeCandidate = ownEntry(state.projectNativeWorkers ?? {}, claim?.workerId) const nativeWorker = sameTask(nativeCandidate, task) ? nativeCandidate : null const executionView = executionMetadataView(state, sdk ?? nativeWorker) + const receiptCorrection = claim?.inputCorrection + if (executionView && receiptCorrection) executionView.inputCorrection = { version: receiptCorrection.version, round: receiptCorrection.round, limit: receiptCorrection.limit, status: receiptCorrection.status, proposalSha256: receiptCorrection.proposal?.sha256 ?? null, feedback: receiptCorrection.feedback.map(({ kind, field, correctable, sideEffects, businessEffects }) => ({ kind, field, correctable, sideEffects, businessEffects })) } const nativeActivated = nativeWorker && isIntentCommitted(nativeWorker.activationEffect) const native = ownEntry(state.bots ?? {}, claim?.workerId) const nativeActive = native?.projectId === task.projectId && (!attempt || native.attemptId === attempt.id) && activity[claim?.workerId] @@ -63,6 +65,7 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm else if (task.status === 'cancelled' || c?.status === 'cancelled' || attempt?.status === 'cancelled') phase = 'cancelled' else if (attempt?.cancelRequested || c?.status === 'stopping') { phase = 'stopping'; waitingFor = 'worker-exit' } else if (nativeWorker?.status === 'stopping' || nativeWorker?.status === 'stop-unconfirmed') { phase = nativeWorker.status === 'stopping' ? 'stopping' : 'blocked'; waitingFor = 'worker-exit'; reason = c?.block?.reason ?? null } + else if (receiptCorrection?.status === 'pending') { phase = 'awaiting-input'; waitingFor = 'role-correction'; reason = 'Original role is correcting input receipts; business proposal is preserved' } else if (task.chiefDecisionIssue?.status === 'pending') { phase = 'awaiting-chief'; waitingFor = 'chief-correction'; reason = task.chiefDecisionIssue.feedback.message } else if (task.chiefDecisionIssue?.status === 'exhausted') { phase = 'blocked'; waitingFor = 'chief-recovery'; reason = 'Chief judgement incomplete; correction budget exhausted' } else if (task.chiefQuestion) { phase = 'awaiting-owner'; waitingFor = task.chiefQuestion.recipient?.kind === 'personal-agent' ? 'personal-agent' : 'owner'; reason = task.chiefQuestion.reason } diff --git a/core/src/host/project-harness-turn.js b/core/src/host/project-harness-turn.js index e4e8fb025..7a6eaa93c 100644 --- a/core/src/host/project-harness-turn.js +++ b/core/src/host/project-harness-turn.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url' import { validateCodexConfiguration } from './project-codex-config.js' // One real SDK turn. The host owns Workflow completion and acceptance, never this driver. export async function runHarnessTurn(request, { emit, permission, signal, validateCodex = validateCodexConfiguration, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) { + const toolsDisabled = request.noTools || request.protocolCorrection === true let text = '', completed = false, sessionId = request.sessionId ?? null, usage = null if (request.harness === 'codex') { const toolOutputTokens = request.toolOutputTokens ?? 2000 @@ -10,7 +11,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida const denyScript = fileURLToPath(new URL('./project-harness-deny-tool.mjs', import.meta.url)) const quote = value => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"` const connectorPolicy = { remote_plugin: false, skill_mcp_dependency_install: false } - const chiefConfig = request.noTools ? { features: { ...connectorPolicy, shell_tool: false }, tools: { view_image: false }, apps: { _default: { enabled: false } }, + const chiefConfig = toolsDisabled ? { features: { ...connectorPolicy, shell_tool: false }, tools: { view_image: false }, apps: { _default: { enabled: false } }, hooks: { PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command: `node ${quote(denyScript)}` }] }] } } : { features: connectorPolicy } // Raw network denial also denies Unix pipe lifecycle syscalls in CLI 0.166, // breaking Node child_process despite a successful child exit. The limited @@ -22,7 +23,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida const inlineToml = value => value && typeof value === 'object' ? '{' + Object.entries(value).map(([key, child]) => `${JSON.stringify(key)}=${inlineToml(child)}`).join(',') + '}' : JSON.stringify(value) const rawPolicy = request.noTools ? [] : ['permissions.flybot-role=' + inlineToml(processPolicy.permissions['flybot-role'])] const configPolicy = request.noTools ? {} : { ...processPolicy, permissions: { 'flybot-role': { extends: processPolicy.permissions['flybot-role'].extends, network: processPolicy.permissions['flybot-role'].network } } } - const clientOptions = { env: request.env, configOverrides: ['mcp_servers={}', 'plugins={}', 'apps={_default={enabled=false}}', ...rawPolicy], ...(request.baseUrl ? { baseUrl: request.baseUrl } : {}), config: { ...(request.config ?? {}), ...chiefConfig, ...configPolicy, tool_output_token_limit: toolOutputTokens } } + const clientOptions = { env: request.env, configOverrides: ['mcp_servers={}', 'plugins={}', 'apps={_default={enabled=false}}', ...rawPolicy], ...(request.baseUrl ? { baseUrl: request.baseUrl } : {}), config: { ...(request.config ?? {}), ...chiefConfig, ...configPolicy, features: { ...chiefConfig.features, ...configPolicy.features, ...(toolsDisabled ? { shell_tool: false } : {}) }, tool_output_token_limit: toolOutputTokens } } const validated = await validateCodex({ ...request, signal }, clientOptions) if (validated.capability) await emit({ type: 'configuration-checked', harness: 'codex', capability: validated.capability }) const client = new Codex({ ...clientOptions, codexPathOverride: validated.codexPathOverride, env: validated.env }) @@ -48,10 +49,10 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida settingSources: [], mcpServers: {}, persistSession: true, ...(sessionId ? { resume: sessionId } : {}), maxTurns: request.probe ? 1 : 30, maxBudgetUsd: request.maxBudgetUsd ?? 1, permissionMode: 'default', - tools: request.noTools ? [] : request.readOnly ? ['Read', 'Glob', 'Grep', 'Bash'] : ['Read', 'Glob', 'Grep', 'Bash', 'Edit', 'Write'], - hooks: request.noTools ? { PreToolUse: [{ hooks: [async () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Use FlyBot scoped action proposals only' } })] }] } : {}, + tools: toolsDisabled ? [] : request.readOnly ? ['Read', 'Glob', 'Grep', 'Bash'] : ['Read', 'Glob', 'Grep', 'Bash', 'Edit', 'Write'], + hooks: toolsDisabled ? { PreToolUse: [{ hooks: [async () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Use FlyBot scoped action proposals only' } })] }] } : {}, canUseTool: async (name, input, options) => { - if (request.noTools) return { behavior: 'deny', message: 'Generic Chief tools are disabled' } + if (toolsDisabled) return { behavior: 'deny', message: 'Generic Chief tools are disabled' } const answer = await permission({ name, input, requestId: options.requestId ?? options.toolUseID }) return answer.allow ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: 'FlyBot operator refused this action' } }, diff --git a/core/src/host/project-harness-worker.mjs b/core/src/host/project-harness-worker.mjs index 18ed9fcb7..2b8861af2 100644 --- a/core/src/host/project-harness-worker.mjs +++ b/core/src/host/project-harness-worker.mjs @@ -9,20 +9,26 @@ const input = createInterface({ input: process.stdin, crlfDelay: Infinity }) const write = value => process.stdout.write(JSON.stringify(value) + '\n') const answers = new Map() let controller, sessionId, settings, busy = false, shuttingDown = false +const completedTurns = new Map() const permission = request => new Promise(resolve => { const id = randomUUID(); answers.set(id, resolve); write({ type: 'permission', id, request }) }) async function turn(message) { + if (message.turnId && completedTurns.has(message.turnId)) { write(completedTurns.get(message.turnId)); return } if (busy) { write({ type: 'failure', reason: 'SDK Session is already answering' }); return } busy = true; controller = new AbortController() try { settings ??= message.settings sessionId ??= settings.sessionId - const result = await runHarnessTurn({ ...settings, sessionId, prompt: message.prompt }, { emit: write, permission, signal: controller.signal }) + const emit = value => write({ ...value, ...(message.turnId ? { turnId: message.turnId } : {}) }) + const result = await runHarnessTurn({ ...settings, sessionId, protocolCorrection: message.protocolCorrection === true, prompt: message.prompt }, { emit, permission, signal: controller.signal }) sessionId = result.sessionId - write({ type: 'completed', ...result }) + const response = { type: 'completed', ...result, ...(message.turnId ? { turnId: message.turnId } : {}) } + if (message.turnId) { completedTurns.set(message.turnId, response); if (completedTurns.size > 16) completedTurns.delete(completedTurns.keys().next().value) } + busy = false + write(response) if (settings.probe) void shutdown() - } catch (error) { write({ type: 'failure', reason: ['bot/harness-capability', 'bot/context-resume'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } + } catch (error) { write({ type: 'failure', ...(message.turnId ? { turnId: message.turnId } : {}), reason: ['bot/harness-capability', 'bot/context-resume'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } finally { busy = false } } input.on('line', line => { @@ -55,4 +61,4 @@ function sdkVersion(name) { } catch { /* Missing SDK is an explicit failed handshake, not a simulated version. */ } return null } -write({ type: 'ready', pid: process.pid, sdk: { codex: sdkVersion('@openai/codex-sdk'), 'claude-agent': sdkVersion('@anthropic-ai/claude-agent-sdk') } }) +write({ type: 'ready', receiptCorrection: 'input-correction/v1', pid: process.pid, sdk: { codex: sdkVersion('@openai/codex-sdk'), 'claude-agent': sdkVersion('@anthropic-ai/claude-agent-sdk') } }) diff --git a/core/src/host/project-harness.js b/core/src/host/project-harness.js index 4106cadda..2072f266b 100644 --- a/core/src/host/project-harness.js +++ b/core/src/host/project-harness.js @@ -200,6 +200,7 @@ export function createProjectHarness(rt) { try { const signal = JSON.parse(line), lease = ownEntry(rt.state.projectResourceLeases ?? {}, row.leaseId) if (signal.type === 'ready' && active.get(id) === control) { + control.receiptCorrectionSupported = signal.receiptCorrection === 'input-correction/v1' control.readyObserved = true control.observedSdkVersion = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/.test(signal.sdk?.[row.harness] ?? '') ? signal.sdk[row.harness] : null if (signal.sdk?.[row.harness] !== VERSIONS[row.harness]) readyReject(refuse('SDK version on selected resource is missing or incompatible')) @@ -212,6 +213,7 @@ export function createProjectHarness(rt) { if (active.get(id) !== control || row.status === 'released' || currentLease?.generation !== row.generation || currentLease.status === 'released') return if (line.length > 1024 * 1024) throw refuse('SDK event exceeds maximum size') const event = redact(JSON.parse(line), control.env) + if (control.correctionTurnId && ['completed', 'sdk-event', 'failure', 'permission', 'configuration-checked'].includes(event.type) && event.turnId !== control.correctionTurnId) return row.lastEventAt = Date.now() row.eventCount++ row.eventBytes = (row.eventBytes ?? 0) + Buffer.byteLength(JSON.stringify(event)) @@ -220,6 +222,7 @@ export function createProjectHarness(rt) { row.lastEventHash = sha(JSON.stringify(event)) if (event.type === 'stopped') control.stoppedConfirmed = event.released === true if (event.type === 'ready') { control.ready = true; if (row.status === 'prepared') row.status = 'idle' } + if (event.type === 'permission' && control.correctionTurnId) { control.process.stdin.write(JSON.stringify({ type: 'permission', id: event.id, allow: false }) + '\n'); return } if (event.type === 'permission') { row.status = 'waiting-input'; row.pending = { id: event.id, kind: 'permission', request: event.request } } if (event.type === 'sdk-event') { row.lastEventAt = Date.now() @@ -238,6 +241,11 @@ export function createProjectHarness(rt) { rt.projectResources.setting(row.projectId, row.resourceId) if (row.backendSessionId && row.backendSessionId !== event.sessionId) throw refuse('SDK result names another Session') const rawText = event.text + if (control.correctionTurnId) { + if (event.sessionId !== row.backendSessionId) throw refuse('Receipt correction changed its backend Session') + row.inputCorrectionResult = { turnId: control.correctionTurnId, sha256: sha(rawText), at: Date.now() } + row.status = 'completed'; await report(row, true, rawText); return + } if (!control.chief && session.poolClaimId && (control.safeInputTurns ?? 0) < 4) { const task = rt.state.tasks[row.taskId], claim = task?.collaboration?.claim if (claim?.id === session.poolClaimId && claim.status === 'active' && task.collaboration.generation === claim.generation) { @@ -337,6 +345,23 @@ export function createProjectHarness(rt) { } for (const listener of listeners) await listener({ botId: row.id, ok, text }) } + async function correctInput({ workerId, task, claim, prompt, round }) { + const row = ownEntry(workers(), workerId) + if (!row) return null + const control = active.get(workerId) + if (!control || control.closed || !control.receiptCorrectionSupported || row.status !== 'completed' || row.projectId !== task.projectId || row.taskId !== task.id || row.attemptId !== claim.attemptId || control.session.poolClaimId !== claim.id) throw refuse('Original SDK receipt-correction channel is unavailable') + await requireExecutionAuthority(rt, control.session) + const lease = ownEntry(rt.state.projectResourceLeases ?? {}, row.leaseId) + if (!lease || lease.generation !== row.generation || lease.status === 'released' || task.collaboration.claim !== claim && task.collaboration.claim?.id !== claim.id || task.collaboration.generation !== claim.generation || claim.inputCorrection?.round !== round) throw refuse('Receipt correction scope changed') + checkContextPrompt(prompt, control.contextBudget) + control.correctionTurnId = `receipt-${claim.id}-${round}` + control.reported = false; control.inputBatch = null; row.status = 'running' + await rt.save() + await requireExecutionAuthority(rt, control.session) + if (active.get(workerId) !== control || control.closed || task.collaboration.claim?.inputCorrection?.status !== 'pending' || task.collaboration.claim.inputCorrection.round !== round || lease.status === 'released') throw refuse('Receipt correction was superseded before delivery') + control.process.stdin.write(JSON.stringify({ type: 'input', turnId: control.correctionTurnId, protocolCorrection: true, prompt }) + '\n') + return { ok: true } + } async function taskInputWake(taskId) { const task = ownEntry(rt.state.tasks ?? {}, taskId), claim = task?.collaboration?.claim const row = claim && ownEntry(workers(), claim.workerId), control = row && active.get(row.id) @@ -519,7 +544,7 @@ export function createProjectHarness(rt) { } delete row.pending; row.status = 'running'; await rt.save(); return { sent: true } } - return { profile, spawn, dispatch, checkNativeInput, taskInputWake, release, input, chiefTurn, onResult: fn => { listeners.add(fn); return () => listeners.delete(fn) }, + return { profile, spawn, dispatch, correctInput, checkNativeInput, taskInputWake, release, input, chiefTurn, onResult: fn => { listeners.add(fn); return () => listeners.delete(fn) }, capabilities: harnessCapabilities, owns: id => Boolean(ownEntry(workers(), id)), hasController: id => active.has(id) && !active.get(id).closed, versions: VERSIONS } } export function install(rt) { rt.projectHarness = createProjectHarness(rt) } diff --git a/core/src/host/project-inputs.js b/core/src/host/project-inputs.js index f9b0872f9..62f6b966e 100644 --- a/core/src/host/project-inputs.js +++ b/core/src/host/project-inputs.js @@ -19,6 +19,7 @@ export function createProjectInputs(rt) { const taskOf = id => { const task = ownEntry(rt.state.tasks, id); if (!task?.projectId) throw refuse('Unknown Project Task'); return task } const writable = () => { if (rt.readOnly?.() !== undefined) throw refuse('Task inbox is read-only') } const authorized = (row, task) => { + if (row.sourceAuthorityRevoked === true) return false if (!['human', 'github'].includes(row.source.kind)) return true try { rt.requireProjectAccess({ memberId: row.source.memberId }, task.projectId, 'task-write', task); return true } catch { return false } } @@ -112,25 +113,54 @@ export function createProjectInputs(rt) { } await rt.save(); return true } - async function acknowledge(taskId, claim, receipts) { - if (receipts === undefined) return + function receiptError(task, claim, kind, field, message, correctable = true, manifest) { + const batches = manifest ?? [...new Set(Object.values(rt.state.projectTaskInputs ?? {}).filter(row => row.taskId === task.id && row.projectId === task.projectId && row.version === INPUT_PROTOCOL).flatMap(row => row.deliveries.filter(d => d.claimId === claim?.id && d.generation === claim?.generation && d.botId === claim?.botId).map(d => d.batchId)))].slice(0, 8).map(batchId => ({ batchId, inputs: deliveryRows(task, claim, batchId).filter(({ row }) => ['accepted', 'handled'].includes(row.status) && authorized(row, task)).map(({ row }) => ({ id: row.id, sha256: row.sha256 })) })) + return Object.assign(refuse(message), { receiptFeedback: { version: 'input-receipt-feedback/v1', kind, field, correctable, taskId: task.id, claimId: claim?.id ?? null, generation: claim?.generation ?? null, batches: correctable ? batches : [], sideEffects: 'receipt rejected before acknowledgement; no read/handled declaration applied' } }) + } + function acknowledgementPlan(taskId, claim, receipts) { + if (receipts === undefined) return [] writable() - const task = taskOf(taskId); current(task, claim) + const task = taskOf(taskId) + try { current(task, claim) } catch { throw receiptError(task, claim, 'stale-claim', 'claim', 'Task input claim is stale', false) } const batches = Array.isArray(receipts) ? receipts : [receipts] - if (!batches.length || batches.length > 8) throw refuse('Input acknowledgement needs one to eight injected batches') - const plans = batches.map(receipt => { - if (!receipt || typeof receipt.batchId !== 'string' || !Array.isArray(receipt.read) || !Array.isArray(receipt.handled) || receipt.read.length > 2 || receipt.handled.length > 2) throw refuse('Input acknowledgement needs the injected batch and bounded read/handled IDs') - const allowed = new Map(deliveryRows(task, claim, receipt.batchId).map(pair => [pair.row.id, pair])) - if (![...receipt.read, ...receipt.handled].every(id => typeof id === 'string' && allowed.has(id)) || !receipt.handled.every(id => receipt.read.includes(id))) throw refuse('Input receipt is outside the current injected batch') - if (receipt.read.some(id => !['accepted', 'handled'].includes(allowed.get(id).row.status) || !authorized(allowed.get(id).row, task))) throw refuse('Input was cancelled or source authority was revoked') - for (const id of receipt.read) verify(allowed.get(id).row) + if (!batches.length || batches.length > 8) throw receiptError(task, claim, 'schema', 'inputReceipts', 'Input acknowledgement needs one to eight injected batches') + return batches.map(receipt => { + if (!receipt || typeof receipt.batchId !== 'string' || !Array.isArray(receipt.read) || !Array.isArray(receipt.handled) || receipt.read.length > 2 || receipt.handled.length > 2) throw receiptError(task, claim, 'schema', 'inputReceipts', 'Input acknowledgement needs the injected batch and bounded read/handled IDs') + const selected = deliveryRows(task, claim, receipt.batchId), allowed = new Map(selected.map(pair => [pair.row.id, pair])) + if (!selected.length) { + const historical = Object.values(rt.state.projectTaskInputs ?? {}).some(row => row.deliveries?.some(d => d.batchId === receipt.batchId)) + throw receiptError(task, claim, historical ? 'stale-or-foreign-batch' : 'unknown-batch', 'batchId', 'Input receipt is outside the current injected batch', !historical) + } + for (const id of [...receipt.read, ...receipt.handled]) if (typeof id !== 'string' || !allowed.has(id)) { + const source = typeof id === 'string' && ownEntry(rt.state.projectTaskInputs ?? {}, id) + throw receiptError(task, claim, source && (source.taskId !== task.id || source.projectId !== task.projectId) ? 'foreign-input' : 'uninjected-input', 'read/handled', 'Input receipt is outside the current injected batch', !source || source.taskId === task.id && source.projectId === task.projectId) + } + if (!receipt.handled.every(id => receipt.read.includes(id))) throw receiptError(task, claim, 'handled-not-read', 'handled', 'Input receipt is outside the current injected batch: handled must be a subset of read') + if (receipt.read.some(id => !['accepted', 'handled'].includes(allowed.get(id).row.status) || !authorized(allowed.get(id).row, task))) throw receiptError(task, claim, 'source-revoked', 'read', 'Input was cancelled or source authority was revoked', false) + for (const id of receipt.read) { try { verify(allowed.get(id).row) } catch { throw receiptError(task, claim, 'content-changed', 'sha256', 'Task input content changed after acceptance', false) } } return { receipt, allowed } }) + } + async function acknowledge(taskId, claim, receipts) { + const plans = acknowledgementPlan(taskId, claim, receipts) + if (!plans.length) return + const undo = [], task = taskOf(taskId) + const change = (object, key, value) => { undo.push({ object, key, before: object[key], after: value }); object[key] = value } for (const { receipt, allowed } of plans) { - for (const id of receipt.read) { const { delivery } = allowed.get(id); delivery.readAt ??= Date.now(); delivery.readProof = 'current-role-declaration' } - for (const id of receipt.handled) { const { row, delivery } = allowed.get(id); row.status = 'handled'; delivery.handledAt ??= Date.now(); delivery.handledProof = 'validated-current-role-decision' } + for (const id of receipt.read) { const { delivery } = allowed.get(id); if (delivery.readAt === undefined) change(delivery, 'readAt', Date.now()); change(delivery, 'readProof', 'current-role-declaration') } + for (const id of receipt.handled) { const { row, delivery } = allowed.get(id); change(row, 'status', 'handled'); if (delivery.handledAt === undefined) change(delivery, 'handledAt', Date.now()); change(delivery, 'handledProof', 'validated-current-role-decision') } + } + let token + try { + if (rt.projectEffects) { + token = rt.projectEffects.stage({ kind: 'input.receipt', key: digest([claim.id, claim.generation, receipts]), projectId: task.projectId, taskId, payload: { claimId: claim.id, generation: claim.generation, receiptSha256: digest(receipts) } }) + await rt.projectEffects.persist(token) + } else await rt.save() + } catch (error) { + if (!token || rt.projectEffects.discardUncommitted(token)) for (const item of undo.reverse()) if (item.object[item.key] === item.after) { if (item.before === undefined) delete item.object[item.key]; else item.object[item.key] = item.before } + for (const { allowed } of plans) for (const { row } of allowed.values()) if (row.status === 'accepted' && row.sourceAuthorityRevoked) { row.status = 'cancelled'; row.cancellation = 'Source Project membership was revoked' } + throw error } - await rt.save() } async function prepareChief(snapshot, run) { writable() @@ -157,11 +187,18 @@ export function createProjectInputs(rt) { if (receipts === undefined) return null writable() const task = taskOf(taskId) - if (ended(task) || run.projectId !== task.projectId || saved.taskId !== task.id || (task.collaboration?.generation ?? 0) !== saved.generation || !saved.taskInbox || receipts?.batchId !== saved.taskInbox.batchId || !Array.isArray(receipts.read) || !Array.isArray(receipts.handled) || receipts.read.length > 2 || receipts.handled.length > 2) throw refuse('Chief input receipt is outside its current snapshot') + if (ended(task) || run.projectId !== task.projectId || saved.taskId !== task.id || (task.collaboration?.generation ?? 0) !== saved.generation) throw receiptError(task, null, 'stale-snapshot', 'snapshot', 'Chief input receipt is outside its current snapshot', false) + const feedback = (kind, field, message, correctable = true) => receiptError(task, null, kind, field, message, correctable, saved.taskInbox ? [{ batchId: saved.taskInbox.batchId, inputs: saved.taskInbox.inputs.map(({ id, sha256 }) => ({ id, sha256 })) }] : []) + if (!saved.taskInbox || receipts?.batchId !== saved.taskInbox.batchId || !Array.isArray(receipts.read) || !Array.isArray(receipts.handled) || receipts.read.length > 2 || receipts.handled.length > 2) throw feedback('snapshot-schema', 'inputReceipts', 'Chief input receipt is outside its current snapshot') const manifest = new Map(saved.taskInbox.inputs.map(row => [row.id, row.sha256])) const selected = new Map(Object.values(rt.state.projectTaskInputs ?? {}).filter(row => row.projectId === task.projectId && row.taskId === task.id && manifest.get(row.id) === row.sha256 && ['accepted', 'handled'].includes(row.status) && authorized(row, task)).map(row => [row.id, { row, delivery: row.deliveries.find(d => d.batchId === receipts.batchId && d.chiefRunId === run.id && d.generation === saved.generation) }]).filter(([, pair]) => pair.delivery)) - if (![...receipts.read, ...receipts.handled].every(id => typeof id === 'string' && selected.has(id)) || !receipts.handled.every(id => receipts.read.includes(id))) throw refuse('Chief acknowledged an input outside its injected snapshot') - for (const id of receipts.read) verify(selected.get(id).row) + for (const id of [...receipts.read, ...receipts.handled]) if (typeof id !== 'string' || !selected.has(id)) { + const row = typeof id === 'string' && ownEntry(rt.state.projectTaskInputs ?? {}, id) + const foreign = row && (row.taskId !== task.id || row.projectId !== task.projectId), revoked = row && (!authorized(row, task) || row.status === 'cancelled') + throw feedback(foreign ? 'foreign-input' : revoked ? 'source-revoked' : 'uninjected-input', 'read/handled', 'Chief acknowledged an input outside its injected snapshot', !foreign && !revoked) + } + if (!receipts.handled.every(id => receipts.read.includes(id))) throw feedback('handled-not-read', 'handled', 'Chief acknowledged an input outside its injected snapshot: handled must be read') + for (const id of receipts.read) { try { verify(selected.get(id).row) } catch { throw feedback('content-changed', 'sha256', 'Task input content changed after acceptance', false) } } return { taskId, projectId: task.projectId, generation: saved.generation, runId: run.id, receipts: clone(receipts), selected, committed: false } } function commitChiefPlan(plan) { @@ -217,7 +254,7 @@ export function createProjectInputs(rt) { for (const row of pending(task).filter(row => row.kind === 'handoff' && row.source.eventId === handoffId)) { row.status = 'handled'; row.routingReceipt = { chiefDecisionId: decisionId, at: Date.now(), proof: 'validated-chief-routing-decision' } } }, revokeSource(projectId, memberId) { - for (const row of Object.values(rt.state.projectTaskInputs ?? {})) if (row.projectId === projectId && ['human', 'github'].includes(row.source.kind) && row.source.memberId === memberId && row.status === 'accepted') { row.status = 'cancelled'; row.cancelledAt = Date.now(); row.cancellation = 'Source Project membership was revoked' } + for (const row of Object.values(rt.state.projectTaskInputs ?? {})) if (row.projectId === projectId && ['human', 'github'].includes(row.source.kind) && row.source.memberId === memberId) { row.sourceAuthorityRevoked = true; if (row.status === 'accepted') { row.status = 'cancelled'; row.cancelledAt = Date.now(); row.cancellation = 'Source Project membership was revoked' } } }, finishHandoff(taskId, claim) { const task = taskOf(taskId); current(task, claim) @@ -225,12 +262,15 @@ export function createProjectInputs(rt) { }, discardHandoff(id) { const row = ownEntry(rows(), id); if (row?.kind === 'handoff' && !row.deliveries.length) delete rows()[id] }, prepare: (taskId, claim, options) => serial(() => prepare(taskId, claim, options)), transport: (taskId, claim, batch, signal) => serial(() => transport(taskId, claim, batch, signal)), + validateReceipt: (taskId, claim, receipts) => serial(() => { acknowledgementPlan(taskId, claim, receipts); return true }), + validateChiefReceipt: chiefPlan, acknowledge: (taskId, claim, receipts) => serial(() => acknowledge(taskId, claim, receipts)), prepareChief: (snapshot, run) => serial(() => prepareChief(snapshot, run)), acknowledgeChief: (taskId, saved, run, receipts) => serial(() => acknowledgeChief(taskId, saved, run, receipts)), requireHandled, pending(taskId) { return pending(taskOf(taskId)).filter(row => row.kind !== 'handoff').map(row => metadata(row, taskOf(taskId))) } } } export function install(rt) { rt.projectInputs = createProjectInputs(rt) + rt.projectEffects.register('input.receipt', { run: row => ({ ...row.payload, status: 'declared' }) }) rt.projectEffects.register('input.inbox', { run: row => ({ batchId: row.payload.batchId, status: 'durable-inbox' }) }) rt.projectEffects.register('input.notify', { async validate(row) { diff --git a/core/src/host/project-pools.js b/core/src/host/project-pools.js index 172518a1f..9184a974a 100644 --- a/core/src/host/project-pools.js +++ b/core/src/host/project-pools.js @@ -1,4 +1,5 @@ // Per-logical-Bot routing on existing Task records. Execution remains a host adapter. +import { protocolCorrection, freezeReceiptProposal, readReceiptProposal, receiptDigest } from './project-receipt-correction.js' import { randomUUID, createHash } from 'node:crypto' import { ownEntry } from './store.js' import { initialRoleTarget } from './project-role-admission.js' @@ -294,6 +295,7 @@ export function createProjectPools(rt) { const children = action === 'split' ? rt.projectTaskLifecycle.prepareSplit(task, claim, decision) : null const next = action === 'split' ? null : ['complete', 'arbitrate'].includes(action) ? definitions(task.projectId).roles.find(row => row.role === 'chief') : targetOf(task.projectId, decision.to) if (!next && action !== 'split') throw refuse('Chief is not configured') + await rt.projectInputs?.validateReceipt?.(task.id, claim, decision.inputReceipts) // Stop confirmation precedes relinquishing the claim and capacity. if (claim.workerId) { if (!await adapter.release(claim.workerId)) throw refuse('Worker exit is unconfirmed; claim and capacity remain held') @@ -305,6 +307,7 @@ export function createProjectPools(rt) { await rt.projectInputs?.acknowledge(task.id, claim, decision.inputReceipts) await current(task, token) rt.projectInputs?.finishHandoff(task.id, claim) + if (claim.inputCorrection) claim.inputCorrection.status = 'resolved' claim.status = 'completed'; claim.decision = { action, reason, intentHash: intentHash(decision), at: Date.now() } const receipt = c.history.find(row => row.id === c.currentHandoffId) receipt.finishedAt = Date.now(); receipt.decision = clone(claim.decision); receipt.execution = clone(claim) @@ -358,6 +361,51 @@ export function createProjectPools(rt) { return { items: page.map(row => handoffContract(rt.state, task, row)), next: index + 1 + page.length < rows.length ? page.at(-1).id : null } } const owns = botId => Object.values(rt.state.tasks).some(task => task.collaboration?.claim?.workerId === botId || task.collaboration?.history.some(row => row.execution?.workerId === botId)) + async function feedback(task, token, decision, error, responseSha256 = receiptDigest(decision)) { + const claim = await current(task, token), previous = claim.inputCorrection + const proposal = previous?.proposal ?? await freezeReceiptProposal(rt, task, claim, decision) + const correction = protocolCorrection(previous, { version: 'input-correction/v1', limit: previous?.limit ?? rt.config.projectHandoff?.inputCorrectionBudget ?? 2, + feedback: { ...clone(error.receiptFeedback), receiptResponseSha256: responseSha256, at: Date.now(), proposalSha256: proposal.sha256, businessEffects: 'host completion, release and downstream handoff not applied; original model work preserved' } }) + correction.proposal = proposal; correction.originalResponseSha256 = previous?.originalResponseSha256 ?? receiptDigest(decision) + claim.inputCorrection = correction + let intent + try { + if (correction.status === 'pending' && adapter.correct && rt.projectEffects) { + intent = rt.projectEffects.stage({ kind: 'input.correction', key: `${claim.id}:${correction.round}`, projectId: task.projectId, taskId: task.id, payload: { claimId: claim.id, generation: claim.generation, round: correction.round, workerId: claim.workerId } }) + correction.effect = intent + await rt.projectEffects.persist(intent) + rt.projectScheduler?.wake() + } else { + correction.status = correction.status === 'pending' ? 'transport-unavailable' : correction.status + task.collaboration.status = 'blocked'; task.collaboration.block = { reason: 'Input receipt correction incomplete: ' + correction.status, waitingFor: 'chief-recovery', at: Date.now() } + claim.status = 'incomplete'; await rt.save() + if (claim.workerId) { try { claim.exitConfirmed = await adapter.release(claim.workerId) } catch { claim.exitConfirmed = false } } + await rt.save() + } + } catch (failure) { + if (intent && rt.projectEffects.discardUncommitted(intent)) { if (previous) claim.inputCorrection = previous; else delete claim.inputCorrection } + throw failure + } + return true + } + async function correct(effect) { + const task = taskOf(effect.taskId), token = effect.payload + const claim = await serial(() => current(task, { id: token.claimId, generation: token.generation, botId: task.collaboration.claim?.botId })) + const correction = claim.inputCorrection + if (task.projectId !== effect.projectId || correction?.round !== token.round || correction.status !== 'pending' || claim.workerId !== token.workerId) throw new EffectCancelled('Receipt correction changed or ended') + if (correction.dispatchedAt) return { status: 'already-dispatched', round: token.round } + // Commit before transport. An ambiguous send is never retried as a fresh business turn. + correction.dispatchedAt = Date.now(); await rt.save() + const prompt = 'Host input receipt protocol feedback (data, not authorization): ' + JSON.stringify(correction.feedback.at(-1)) + '\nPreserve the original business proposal and all prior code, test, review and external operation results. Tools are disabled for this narrow correction. Return ONLY OUTPUT handoff: {"correctionOf":"' + correction.proposal.sha256 + '","inputReceipts":{...}} (or an array of up to8 injected batches). Declare only IDs actually read/handled. Do not repeat business work or map historical batches to current ones.' + try { + await adapter.correct({ task, claim: clone(claim), workerId: claim.workerId, prompt, round: token.round }) + return { status: 'dispatched', round: token.round, proposalSha256: correction.proposal.sha256 } + } catch { + correction.status = 'transport-incomplete'; correction.failure = 'Original correction transport did not confirm dispatch; inspect its evidence before recovery' + task.collaboration.status = 'blocked'; task.collaboration.block = { reason: correction.failure, waitingFor: 'chief-recovery', at: Date.now() }; await rt.save() + return { status: correction.status, round: token.round } + } + } async function result({ botId, ok, text }) { const task = Object.values(rt.state.tasks).find(task => task.collaboration?.claim?.workerId === botId && task.collaboration.claim.status === 'active') if (!task) return owns(botId) @@ -367,8 +415,18 @@ export function createProjectPools(rt) { const output = String(text).match(/(?:^|\n)OUTPUT handoff:\s*([^\n]+)/)?.[1] decision = ok && output ? JSON.parse(output) : { action: 'arbitrate', reason: ok ? 'Worker did not provide a valid handoff decision' : `Execution failed: ${String(text).slice(0, 1000)}` } } catch { decision = { action: 'arbitrate', reason: 'Worker returned malformed handoff JSON' } } + const correction = task.collaboration.claim.inputCorrection, responseSha256 = receiptDigest(decision) + if (correction?.status === 'pending') { + if (responseSha256 === correction.originalResponseSha256 || correction.feedback.some(row => row.receiptResponseSha256 === responseSha256)) return true + if (!ok || decision?.correctionOf !== correction.proposal.sha256 || Object.keys(decision).some(key => !['correctionOf', 'inputReceipts'].includes(key)) || decision.inputReceipts === undefined) { + return feedback(task, token, decision, { receiptFeedback: { ...correction.feedback.at(-1), version: 'input-receipt-feedback/v1', kind: 'correction-schema', field: 'correctionOf/inputReceipts', correctable: true, message: 'Return only a receipt patch for the exact frozen proposal' } }) + } + decision = { ...await readReceiptProposal(rt, task, token, correction.proposal), inputReceipts: decision.inputReceipts } + } try { await decide(task.id, token, decision) } + catch (error) { + if (error.receiptFeedback?.correctable && task.collaboration.claim?.exitConfirmed !== true) return feedback(task, token, decision, error, responseSha256) const c = task.collaboration; c.status = 'blocked'; c.block = { reason: error.message, at: Date.now() } if (c.claim?.workerId) { c.claim.status = 'blocked'; ++c.generation @@ -544,6 +602,7 @@ export function createProjectPools(rt) { for (const task of Object.values(rt.state.tasks)) { const c = task.collaboration if (!c?.claim || c.claim.status !== 'active') continue + if (c.claim.inputCorrection?.status === 'pending') c.claim.inputCorrection.status = 'interrupted' c.status = 'blocked'; c.claim.status = 'interrupted'; c.block = { reason: 'Host restarted; worker exit and resources must be reconciled before reassigning', at: Date.now() }; ++c.generation; changed = true } if (rt.projectEffects) { @@ -564,11 +623,20 @@ export function createProjectPools(rt) { return changed } return { connect(value) { adapter = value; return () => { if (adapter === value) adapter = null } }, get, list, accept: (taskId, botId, handoffId, requestId) => serial(() => claim(taskId, botId, handoffId, requestId)), admit: (attempt, actor, body) => serial(() => admit(attempt, actor, body)), available, owns, + correct, resumeChildren: (id, waitId) => serial(() => resumeChildren(id, waitId)), claim: (id, botId) => serial(() => claim(id, botId)), remember: (id, token, body) => serial(() => remember(id, token, body)), decide: (id, token, decision) => serial(() => decide(id, token, decision)), result: row => serial(() => result(row)), pump, recover, revokePrincipal: (projectId, memberId) => serial(() => revokePrincipal(projectId, memberId)), revokeExecution: (id, claimId, reason) => serial(() => revokeExecution(id, claimId, reason)), validateClosure, retryChief: (id, actor) => serial(() => retryChief(id, actor)), chiefClaim: (id, actor) => serial(() => chiefClaim(id, actor)), chiefRoute: (body, actor) => serial(() => chiefRoute(body, actor)) } } export function install(rt) { rt.projectPools = createProjectPools(rt) + rt.ctx?.tools?.guard?.(exec => { + const sessionId = exec?.agent?.session?.id ?? exec?.agent?.id + const bot = rt.botOf(rt.selfOf(sessionId) ?? sessionId) + if (!bot) return undefined + const task = Object.values(rt.state.tasks).find(task => task.collaboration?.claim?.workerId === bot.id && task.collaboration?.claim?.inputCorrection && task.collaboration.claim.inputCorrection.status !== 'resolved') + return task ? 'Input receipt correction permits only a declaration patch; business tools are disabled' : undefined + }) + rt.projectEffects?.register('input.correction', { run: effect => rt.projectPools.correct(effect) }) rt.projectEffects?.register('context.memory', { run: effect => ({ ...effect.payload, status: 'memory-version-committed' }) }) rt.projectEffects?.register('pool.dispatch', { async validate(effect, context) { diff --git a/core/src/host/project-receipt-correction.js b/core/src/host/project-receipt-correction.js new file mode 100644 index 000000000..29c04ceee --- /dev/null +++ b/core/src/host/project-receipt-correction.js @@ -0,0 +1,29 @@ +// A bounded protocol correction uses the original claim and frozen business proposal. +// This is feedback transport through project-effects, never another role scheduler. +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +export const receiptDigest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +export function protocolCorrection(previous, { version, limit, feedback, priorExecution = null }) { + if (!Number.isSafeInteger(limit) || limit < 0 || limit > 4) throw new Error('Protocol correction budget must be an integer from0 to4') + const round = (previous?.round ?? 0) + 1 + return { version, round, limit, status: round <= limit ? 'pending' : 'exhausted', feedback: [...(previous?.feedback ?? []), feedback].slice(-5), priorExecution: structuredClone(priorExecution) } +} +export async function freezeReceiptProposal(rt, task, claim, decision) { + const text = JSON.stringify(decision), sha256 = receiptDigest(decision) + if (Buffer.byteLength(text) > 65536 || !rt.home) throw new Error('Receipt proposal exceeds its private recovery budget or archive is unavailable') + const directory = join(rt.home, 'project-artifacts', 'input-corrections', receiptDigest([task.projectId, task.id, claim.id, claim.generation])) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const file = join(directory, sha256 + '.json') + try { await writeFile(file, text, { flag: 'wx', mode: 0o600 }) } + catch (error) { if (error.code !== 'EEXIST' || await readFile(file, 'utf8') !== text) throw error } + return { sha256, bytes: Buffer.byteLength(text) } +} +export async function readReceiptProposal(rt, task, claim, reference) { + const file = join(rt.home, 'project-artifacts', 'input-corrections', receiptDigest([task.projectId, task.id, claim.id, claim.generation]), reference.sha256 + '.json') + const text = await readFile(file, 'utf8') + if (Buffer.byteLength(text) !== reference.bytes || reference.bytes > 65536) throw new Error('Receipt proposal archive changed') + const decision = JSON.parse(text) + if (receiptDigest(decision) !== reference.sha256) throw new Error('Receipt proposal archive hash changed') + return decision +} diff --git a/core/test/install.test.js b/core/test/install.test.js index d248075d9..d2b3fcd0c 100644 --- a/core/test/install.test.js +++ b/core/test/install.test.js @@ -47,6 +47,8 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { // Reject stale Project delegations, then intercept managed schedule deliveries. 'on agent/pre-step', 'tools guard', + // Narrow Task-role receipt corrections deny business tools in the original Session. + 'tools guard', 'on agent/pre-step', 'on llm/adapters-updated', 'on session/event', diff --git a/core/test/project-chief-control.test.js b/core/test/project-chief-control.test.js index 279054444..1e7e25aec 100644 --- a/core/test/project-chief-control.test.js +++ b/core/test/project-chief-control.test.js @@ -44,12 +44,17 @@ test('failed schedule configuration does not silently enable autonomous Chief au assert.equal(control.template.when.seconds, 1800) }) -async function controlFixture({ modern = false, dependency = false } = {}) { +async function controlFixture({ modern = false, dependency = false, withInputs = false } = {}) { const { createProjectPools } = await import('../src/host/project-pools.js') const task = { id: 't', projectId: 'p', title: 'Fix code', criteria: 'Exact candidate passes', status: 'result-ready', deliveryAttemptId: 'a', collaboration: { initiatedBy: 'owner', kind: 'code', generation: 1, status: 'arbitration', botId: 'p:chief', attemptId: 'a', currentHandoffId: 'receipt', history: [{ id: 'receipt', status: 'pending', to: 'p:chief', createdAt: 1, evidence: [] }] } } let request, accepted = 0, permitted = true const roles = ['chief', 'developer', 'tester', 'reviewer'].map(role => ({ id: `p:${role}`, role })) const rt = { state: { tasks: { t: task }, projectExecutions: {}, projectAttempts: { a: { id: 'a', projectId: 'p', taskId: 't', candidateSha: 'candidate', evidence: {} } }, projectChiefPolicies: { p: { enabled: true, authorityMemberId: 'owner', revision: 1, inspection: { thresholds, prompt: 'Inspect evidence' } } }, projectAgents: { 'public-p': { id: 'public-p', projectId: 'p', visibility: 'public', botId: 'chief', sessionId: 'chief', carrierGeneration: 1 } } }, config: {}, save: async () => {}, projects: { requireActive() {} }, requireProjectAccess(actor, id) { if (!permitted || actor.memberId !== 'owner' || id !== 'p') throw new Error('Current role denied') }, projectDelegations: { background: async () => true }, bindProject(body) { rt.state.projectExecutions[body.id] = { ...body }; return { id: body.projectId, status: 'active' } }, projectAgents: { deliveryDefinitions: () => ({ roles }), chiefBusy: () => false, startChiefJob: async args => { request = args; request.capturedInvocation = rt.state.projectExecutions[args.runId].invocation; return 'native-job' } }, projectDelivery: { chiefAccept: async () => { accepted++; task.status = 'accepted' } } } + if (withInputs) { + const { createProjectEffects } = await import('../src/host/project-effects.js'), { install } = await import('../src/host/project-inputs.js') + rt.projectEffects = createProjectEffects(rt); rt.save = async () => rt.projectEffects.saved(rt.projectEffects.capture()); install(rt) + await rt.projectInputs.publish({ projectId: 'p', taskId: 't', requestId: 'receipt-fixture', text: 'Verify the accepted behavior' }, { memberId: 'owner' }) + } let control = createProjectChiefControl(rt); rt.projectChiefControl = control; rt.projectPools = createProjectPools(rt) if (modern) { const { createProjectEffects } = await import('../src/host/project-effects.js') @@ -440,3 +445,22 @@ test('scheduled reminder coalescing and policy changes never re-authorize stale const reference = await f.control.scheduled({ row: { id: 's', revision: 99, projectId: 'p', config: { prompt: 'Changed' } }, delivery: { id: 'old' } }) assert.equal(reference.runId, a.id) }) + + +test('Chief receipt protocol correction precedes all role proposals and Task effects', async () => { + const f = await controlFixture({ withInputs: true }), run = f.rt.state.projectExecutions[f.request.runId] + let proposals = 0; f.rt.projectAgents.proposeRoleForJob = async () => { ++proposals; return { proposal: { id: 'proposal' } } } + const initial = f.request, body = JSON.parse(f.result({ inputReceipts: { batchId: 'typo', read: [], handled: [] } }).text) + body.roleProposals = [{ action: 'add', role: 'designer', reason: 'Review UI' }] + await initial.onResult({ ...f.result(), text: JSON.stringify(body) }) + assert.equal(run.status, 'correction-pending', run.failure); assert.equal(proposals, 0); assert.equal(f.accepted(), 0) + assert.equal(run.correction.feedback[0].receiptFeedback.kind, 'snapshot-schema') + await f.control.pump() + assert.equal(f.request.projectId, initial.projectId) + assert.match(f.request.prompt, /inputReceipts/) + const batch = f.request.projectFacts.tasks[0].taskInbox + await f.request.onResult(f.result({ inputReceipts: { batchId: batch.batchId, read: batch.inputs.map(row => row.id), handled: batch.inputs.map(row => row.id) } })) + assert.equal(run.status, 'done', run.failure); assert.equal(f.accepted(), 1) + assert.equal(run.correction.status, 'resolved'); assert.equal(proposals, 0) + await f.rt.projectEffects.close() +}) diff --git a/core/test/project-codex-config.test.js b/core/test/project-codex-config.test.js index 89c3fe4b7..97b7008e1 100644 --- a/core/test/project-codex-config.test.js +++ b/core/test/project-codex-config.test.js @@ -107,3 +107,29 @@ if (process.env.FLYBOT_TEST_CODEX_SDK_URL && process.env.FLYBOT_TEST_CODEX_EXECU assert.equal(events.filter(row => row.type === 'configuration-checked').length, 2) } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(root, { recursive: true, force: true }) } }) + +if (process.env.FLYBOT_TEST_CODEX_SDK_URL && process.env.FLYBOT_TEST_CODEX_EXECUTABLE) test('actual SDK receipt-only correction resumes its thread with shell disabled and retained business history', async () => { + const root = await mkdtemp(join(tmpdir(), 'flybot-sdk-receipt-')) + let count = 0, historySeen = false, shellDisabled = false + const home = join(root, 'home'), cwd = join(root, 'candidate'), scratch = join(root, 'scratch') + const server = createServer(async (request, response) => { + let raw = ''; for await (const chunk of request) raw += chunk + const body = JSON.parse(raw); ++count + if (count === 2) { historySeen = raw.includes('receipt-original-business-result'); shellDisabled = !(body.tools ?? []).some(tool => tool.name === 'exec_command' || tool.name === 'shell' || tool.name === 'shell_command') } + const item = { id: 'message_' + count, type: 'message', role: 'assistant', content: [{ type: 'output_text', text: count === 1 ? 'receipt-original-business-result' : 'OUTPUT handoff: {"correctionOf":"original","inputReceipts":[]}' }] } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const event of [{ type: 'response.output_item.done', output_index: 0, item }, { type: 'response.completed', response: { id: 'response_' + count, status: 'completed', output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } }]) response.write('data: ' + JSON.stringify(event) + '\n\n') + response.end() + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + try { + await Promise.all([home, cwd, scratch].map(path => mkdir(path))); await exec('git', ['init', '-q', cwd]); await writeFile(join(cwd, 'candidate.txt'), 'original-work') + const executable = process.env.FLYBOT_TEST_CODEX_EXECUTABLE, sdkUrl = process.env.FLYBOT_TEST_CODEX_SDK_URL + const request = { harness: 'codex', cwd, model: 'fixture-model', prompt: 'Return the original business result', readOnly: false, scratchDirectory: scratch, env: { PATH: process.env.PATH, HOME: home, CODEX_HOME: home, TMPDIR: scratch }, config: { model_provider: 'fixture', model_providers: { fixture: { name: 'fixture', base_url: `http://127.0.0.1:${server.address().port}`, wire_api: 'responses', requires_openai_auth: false } } } } + const ports = { signal: AbortSignal.timeout(30_000), emit() {}, loadCodex: () => import(sdkUrl), validateCodex: (request, options) => validateCodexConfiguration(request, options, { executable }) } + const original = await runHarnessTurn(request, ports) + const corrected = await runHarnessTurn({ ...request, sessionId: original.sessionId, protocolCorrection: true, prompt: 'Correct only the input receipt; preserve prior work' }, ports) + assert.equal(corrected.sessionId, original.sessionId); assert.equal(historySeen, true); assert.equal(shellDisabled, true); assert.equal(count, 2) + assert.match(corrected.text, /correctionOf/); assert.equal(await readFile(join(cwd, 'candidate.txt'), 'utf8'), 'original-work') + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(root, { recursive: true, force: true }) } +}) diff --git a/core/test/project-harness-turn.test.js b/core/test/project-harness-turn.test.js index f2740451a..aee311d79 100644 --- a/core/test/project-harness-turn.test.js +++ b/core/test/project-harness-turn.test.js @@ -121,3 +121,18 @@ test('Codex tool history has a host-selected limit without replacing SDK compact assert.equal(observed[1].id, 'kept-thread') for (const limit of [0, -1, 1.5, '1536']) await assert.rejects(runHarnessTurn({ ...request, toolOutputTokens: limit }, { loadCodex: async () => { throw new Error('Must validate before loading SDK') } }), /output budget/) }) + +test('receipt correction resumes the original SDK Session without tools or changing its role filesystem profile', async () => { + const observed = [] + await runHarnessTurn({ ...request, sessionId: 'original', protocolCorrection: true }, { emit() {}, loadCodex: codex([{ type: 'thread.started', thread_id: 'original' }, { type: 'turn.completed' }], value => observed.push(value)) }) + assert.equal(observed[1].id, 'original'); assert.equal(observed[0].config.features.shell_tool, false) + assert.equal(observed[0].config.permissions['flybot-role'].extends, ':workspace') + assert.equal(observed[1].sandboxMode, undefined) + assert.equal(observed[0].config.hooks.PreToolUse[0].hooks[0].type, 'command') + let asked = false + await runHarnessTurn({ ...request, harness: 'claude-agent', sessionId: 'original', protocolCorrection: true }, { emit() {}, permission: () => { asked = true }, loadClaude: async () => ({ query({ options }) { + assert.deepEqual(options.tools, []); assert.equal(options.resume, 'original') + return (async function* () { assert.equal((await options.canUseTool('Bash', {}, {})).behavior, 'deny'); yield { type: 'result', subtype: 'success', session_id: 'original', result: 'receipt patch' } })() + } }) }) + assert.equal(asked, false) +}) diff --git a/core/test/project-harness.test.js b/core/test/project-harness.test.js index 55b792fc0..d53fcdb70 100644 --- a/core/test/project-harness.test.js +++ b/core/test/project-harness.test.js @@ -692,3 +692,32 @@ test('registered SDK failure before spawn rolls back capacity with a verifiable assert.equal(confirmedWorkerExit(rt.state, row), true) assert.equal(rt.state.projectAttempts, undefined, 'No Task history is rewritten by resource cleanup') }) + +test('SDK receipt correction keeps its physical Session and sealed execution, and discards late prior completions', async t => { + const { rt, harness, task, id } = await taskInputFixture(t, ` +import{createInterface}from'node:readline';const write=v=>process.stdout.write(JSON.stringify(v)+'\\n');write({type:'ready',receiptCorrection:'input-correction/v1',sdk:{codex:'0.166.0'}}); +createInterface({input:process.stdin}).on('line',line=>{const m=JSON.parse(line);if(m.type==='stop'){write({type:'stopped',released:true});setTimeout(()=>process.exit(0),20);return;} +if(m.type==='start'){write({type:'sdk-event',event:{type:'thread.started',thread_id:'original-thread'}});write({type:'completed',sessionId:'original-thread',text:'OUTPUT handoff: {"action":"arbitrate","reason":"Original business result"}'});} +if(m.type==='input'){if(!m.protocolCorrection||!m.turnId)throw Error('Expected narrow correction');write({type:'completed',sessionId:'original-thread',text:'OUTPUT handoff: {"action":"complete","reason":"Late original result"}'});write({type:'completed',turnId:m.turnId,sessionId:'original-thread',text:'OUTPUT handoff: {"correctionOf":"proposal","inputReceipts":[]}'});} +});`) + const claim = task.collaboration.claim; claim.attemptId = 'a'; claim.inputCorrection = { status: 'pending', round: 1 } + let executions = 0, results = 0, originalHash, originalLog + rt.projectDelivery.recordExecution = async () => { ++executions } + let finish, fail + const done = new Promise((resolve, reject) => { finish = resolve; fail = reject }) + harness.onResult(async result => { + try { + ++results + if (results === 1) { + originalHash = rt.state.projectHarnessWorkers[id].resultHash; originalLog = await readFile(rt.state.projectHarnessWorkers[id].sealedLog, 'utf8') + await harness.correctInput({ workerId: id, task, claim, prompt: 'Correct only the receipt', round: 1 }) + } else finish(result) + } catch (error) { fail(error) } + }) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Current work', outputs: [{ name: 'handoff' }] }) + const result = await done, worker = rt.state.projectHarnessWorkers[id] + assert.equal(result.ok, true); assert.match(result.text, /correctionOf/) + assert.equal(results, 2); assert.equal(executions, 1); assert.equal(worker.backendSessionId, 'original-thread') + assert.equal(worker.resultHash, originalHash); assert.equal(await readFile(worker.sealedLog, 'utf8'), originalLog) + assert.equal(worker.inputCorrectionResult.turnId, 'receipt-idle-claim-1') +}) diff --git a/core/test/project-inputs.test.js b/core/test/project-inputs.test.js index f77c56a51..cddfd3918 100644 --- a/core/test/project-inputs.test.js +++ b/core/test/project-inputs.test.js @@ -240,3 +240,51 @@ test('a closing Effect engine cannot leave a phantom admitted input', async () = await assert.rejects(f.rt.projectInputs.publish(input(),actor),/not writable/) assert.equal(Object.keys(f.rt.state.projectTaskInputs).length,0) }) + +test('receipt preflight exposes only current manifests and save failure never consumes a declaration', async () => { + const f = fixture(); await f.rt.projectInputs.publish(input(), actor) + const batch = await f.rt.projectInputs.prepare('t', f.claim), row = Object.values(f.rt.state.projectTaskInputs)[0] + const good = { batchId: batch.batchId, read: [row.id], handled: [row.id] } + for (const [receipt, kind] of [[{ ...good, batchId: 'typo' }, 'unknown-batch'], [{ ...good, read: [] }, 'handled-not-read'], [{ ...good, read: ['unseen'], handled: [] }, 'uninjected-input']]) { + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, receipt), error => { + assert.equal(error.receiptFeedback.kind, kind); assert.equal(error.receiptFeedback.correctable, true) + assert.deepEqual(error.receiptFeedback.batches[0], { batchId: batch.batchId, inputs: [{ id: row.id, sha256: row.sha256 }] }) + assert.ok(!JSON.stringify(error.receiptFeedback).includes(row.text)); return true + }) + } + assert.equal(row.status, 'accepted'); assert.equal(row.deliveries[0].readAt, undefined) + await f.rt.projectInputs.validateReceipt('t', f.claim, good) + f.fail(true); await assert.rejects(f.rt.projectInputs.acknowledge('t', f.claim, good), /disk failed/) + assert.equal(row.status, 'accepted'); assert.equal(row.deliveries[0].readAt, undefined) + f.fail(false); await f.rt.projectInputs.acknowledge('t', f.claim, good) + assert.equal(row.status, 'handled') + await f.rt.projectInputs.acknowledge('t', f.claim, good) + assert.equal(row.status, 'handled') +}) + +test('foreign inputs, historical batches, revoked sources and changed bodies never become correctable grants', async () => { + const f = fixture(); await f.rt.projectInputs.publish(input(), actor) + const batch = await f.rt.projectInputs.prepare('t', f.claim), row = Object.values(f.rt.state.projectTaskInputs)[0] + f.rt.state.projectTaskInputs.foreign = { ...structuredClone(row), id: 'foreign', taskId: 'other', text: 'foreign private content' } + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, { batchId: batch.batchId, read: ['foreign'], handled: [] }), error => error.receiptFeedback.kind === 'foreign-input' && !error.receiptFeedback.correctable && error.receiptFeedback.batches.length === 0) + const good = { batchId: batch.batchId, read: [row.id], handled: [] } + row.text += 'tampered' + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, good), error => error.receiptFeedback.kind === 'content-changed' && !error.receiptFeedback.correctable) + row.text = input().text; f.rt.projectInputs.revokeSource('p', 'owner') + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, good), error => error.receiptFeedback.kind === 'source-revoked' && !error.receiptFeedback.correctable) + const old = structuredClone(f.claim); f.rt.state.tasks.t.collaboration.claim = { ...old, id: 'new' } + await assert.rejects(f.rt.projectInputs.validateReceipt('t', old, good), error => error.receiptFeedback.kind === 'stale-claim' && !error.receiptFeedback.correctable) +}) + +test('revocation while a receipt save fails cannot resurrect the old input on rollback or later regrant', async () => { + const f = fixture(); await f.rt.projectInputs.publish(input(), actor) + const batch = await f.rt.projectInputs.prepare('t', f.claim), row = Object.values(f.rt.state.projectTaskInputs)[0] + const receipt = { batchId: batch.batchId, read: [row.id], handled: [row.id] } + const save = f.rt.save + f.rt.save = async () => { f.rt.projectInputs.revokeSource('p', 'owner'); throw new Error('disk failed during revocation') } + await assert.rejects(f.rt.projectInputs.acknowledge('t', f.claim, receipt), /disk failed/) + assert.equal(row.status, 'cancelled'); assert.equal(row.deliveries[0].readAt, undefined) + f.rt.save = save + await assert.rejects(f.rt.projectInputs.acknowledge('t', f.claim, receipt), error => error.receiptFeedback.kind === 'source-revoked' && !error.receiptFeedback.correctable) + assert.equal(f.rt.projectInputs.pending('t').length, 0) +}) diff --git a/core/test/project-pools.test.js b/core/test/project-pools.test.js index 9ca157a82..82499f8f4 100644 --- a/core/test/project-pools.test.js +++ b/core/test/project-pools.test.js @@ -633,3 +633,71 @@ test('resumed pool claims cannot bypass project admission or spend a claim gener assert.equal(claim.status, 'active'); assert.equal(checks, 2) await assert.rejects(f.pool.decide('task', { ...claim, generation: generation - 1 }, { action: 'complete', reason: 'late callback' }), /stale/) }) + +for (const role of ['developer', 'tester', 'reviewer']) test(role + ' invalid receipt returns to the same claim before release or host completion, then applies the frozen proposal once', async t => { + const { mkdtemp, rm } = await import('node:fs/promises'), { tmpdir } = await import('node:os'), { join } = await import('node:path') + const { createProjectEffects } = await import('../src/host/project-effects.js'), { install: inputs } = await import('../src/host/project-inputs.js') + const f = setup(); f.rt.home = await mkdtemp(join(tmpdir(), 'flybot-receipt-')) + t.after(() => rm(f.rt.home, { recursive: true, force: true })) + f.rt.projectEffects = createProjectEffects(f.rt); f.rt.save = async () => f.rt.projectEffects.saved(f.rt.projectEffects.capture()) + for (const kind of ['pool.dispatch', 'context.summary', 'input.correction']) f.rt.projectEffects.register(kind, { run: () => ({}) }) + inputs(f.rt) + await f.admit() + f.task.collaboration.botId = 'p:' + role; f.task.collaboration.history[0].to = 'p:' + role + const claim = await f.pool.claim('task', 'p:' + role); f.task.collaboration.claim.workerId = 'original-worker' + const batch = await f.rt.projectInputs.prepare('task', claim), id = batch.inputs[0].id + let releases = 0, correctionPrompt + f.adapter.release = async () => { releases++; return true } + f.adapter.correct = async args => { correctionPrompt = args } + const original = { action: 'handoff', to: 'p:chief', reason: 'Actual work complete', outputs: { candidate: 'original evidence' }, inputReceipts: { batchId: 'typo', read: [id], handled: [id] } } + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(original) }) + assert.equal(releases, 0, 'Invalid receipt must not release the original worker'); assert.equal(f.completed.length, 0, 'Invalid receipt must not run host completion') + const state = f.task.collaboration.claim.inputCorrection + assert.equal(state.status, 'pending') + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(original) }) + assert.equal(state.round, 1, 'duplicate original completion consumes no correction round') + await f.pool.correct(Object.values(f.rt.state.projectEffects).find(row => row.kind === 'input.correction')) + assert.equal(correctionPrompt.workerId, 'original-worker'); assert.equal(correctionPrompt.claim.id, claim.id) + assert.match(correctionPrompt.prompt, /Tools are disabled/) + if (role === 'reviewer') { + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify({ correctionOf: state.proposal.sha256, inputReceipts: { batchId: 'still-wrong', read: [id], handled: [] } }) }) + assert.equal(f.task.collaboration.claim.inputCorrection.round, 2) + } + const patch = { correctionOf: state.proposal.sha256, inputReceipts: { batchId: batch.batchId, read: [id], handled: [id] } } + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(patch) }) + assert.equal(f.completed.length, 1); assert.deepEqual(f.completed[0].outputs, original.outputs) + assert.equal(releases, 1); assert.equal(f.task.collaboration.history[0].execution.inputCorrection.status, 'resolved') + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(patch) }) + assert.equal(f.completed.length, 1) +}) + +test('receipt correction budget exhaustion preserves original context and leaves Chief judgement without creating a consultation', async t => { + const { mkdtemp, rm } = await import('node:fs/promises'), { tmpdir } = await import('node:os'), { join } = await import('node:path') + const f = setup(); f.rt.home = await mkdtemp(join(tmpdir(), 'flybot-receipt-exhausted-')) + t.after(() => rm(f.rt.home, { recursive: true, force: true })) + f.rt.config.projectHandoff = { inputCorrectionBudget: 0 } + await f.admit(); const claim = await f.claim(); f.task.collaboration.claim.workerId = 'original-worker' + f.rt.projectInputs = { validateReceipt() { throw Object.assign(new Error('Wrong receipt'), { receiptFeedback: { kind: 'unknown-batch', field: 'batchId', correctable: true } }) } } + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify({ action: 'handoff', to: 'p:tester', reason: 'Actual work done', inputReceipts: {} }) }) + assert.equal(f.task.collaboration.claim.id, claim.id); assert.equal(f.task.collaboration.claim.inputCorrection.status, 'exhausted') + assert.equal(f.task.collaboration.status, 'blocked'); assert.equal(f.task.collaboration.block.waitingFor, 'chief-recovery') + assert.equal(f.task.chiefQuestion, undefined); assert.equal(f.completed.length, 0) + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: {}' }) + assert.equal(f.task.collaboration.claim.inputCorrection.round, 1, 'late output cannot revive exhausted execution') + assert.equal(f.task.collaboration.history.length, 1) +}) + +test('native receipt correction guards business tools in the original Session without affecting unrelated personal Bots', async () => { + const { install } = await import('../src/host/project-pools.js') + let guard + const claim = { workerId: 'worker', inputCorrection: { status: 'pending' } } + const rt = { state: { tasks: { legacy: { id: 'legacy' }, t: { collaboration: { claim } } } }, ctx: { tools: { guard(fn) { guard = fn } } }, selfOf: id => id === 'original-session' ? 'worker' : id, botOf: id => ['worker', 'personal'].includes(id) ? { id } : null } + install(rt) + assert.match(guard({ name: 'exec_command', agent: { session: { id: 'original-session' } } }), /declaration patch/) + assert.equal(guard({ name: 'exec_command', agent: { id: 'personal' } }), undefined) + assert.equal(guard({ name: 'exec_command', agent: {} }), undefined) + claim.inputCorrection.status = 'exhausted' + assert.match(guard({ agent: { id: 'worker' } }), /declaration patch/) + claim.inputCorrection.status = 'resolved' + assert.equal(guard({ agent: { id: 'worker' } }), undefined) +}) diff --git a/docs/history/2026-11-04-context-implementation-checkpoint.md b/docs/history/2026-11-04-context-implementation-checkpoint.md new file mode 100644 index 000000000..18ed6f69f --- /dev/null +++ b/docs/history/2026-11-04-context-implementation-checkpoint.md @@ -0,0 +1,6 @@ +# Historical context implementation checkpoint + +Preserved verbatim from PROJECT.md; current authority and acceptance remain in +the active Project state and canonical subsystem contracts. + +Owner requires a resident Chief with durable Project memory and one logical Session per Task/role. Codex/Claude own their supported conversation persistence and automatic compaction; FlyBot does not inject a second model-history compactor. SDK role rework reuses a confirmed released Task-role cwd/thread under the same principal/resource/profile/model/persona binding, while preserving separate immutable Attempt/claim/log/test/Review evidence. Changed or incompatible bindings expose an explicit context-resume problem and retain history; they cannot silently start a fresh conversation. Chief memory uses the original own-Bot MEMORY.md/topics/journal, never global team or private-user memory. Context admission keeps current bounded facts and hash-bound full source archives for scoped retrieval. Backend changes are foreground work; frontend Task40/PR48 resumes through FlyBot after validation. diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index 39fff2dbb..dad21101a 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -4819,10 +4819,86 @@ "$ref": "#/components/schemas/JsonValue" }, "maxItems": 8 + }, + "inputCorrection": { + "$ref": "#/components/schemas/InputCorrection" } }, "additionalProperties": true }, + "InputCorrection": { + "type": "object", + "properties": { + "version": { + "const": "input-correction/v1", + "type": "string" + }, + "round": { + "type": "integer", + "minimum": 1, + "maximum": 5 + }, + "limit": { + "type": "integer", + "minimum": 0, + "maximum": 4 + }, + "status": { + "type": "string", + "enum": [ + "pending", + "resolved", + "exhausted", + "transport-unavailable", + "transport-incomplete", + "interrupted" + ] + }, + "proposalSha256": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{64}$" + }, + { + "type": "null" + } + ] + }, + "feedback": { + "type": "array", + "items": { + "type": "object", + "properties": { + "kind": { + "type": "string" + }, + "field": { + "type": "string" + }, + "correctable": { + "type": "boolean" + }, + "sideEffects": { + "type": "string" + }, + "businessEffects": { + "type": "string" + } + }, + "additionalProperties": true + }, + "maxItems": 5 + } + }, + "additionalProperties": true, + "required": [ + "version", + "round", + "limit", + "status" + ] + }, "DevelopmentQueryInboxListPayload": { "type": "object", "properties": { @@ -29566,7 +29642,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" } }, { @@ -29644,7 +29720,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" } }, { @@ -29722,7 +29798,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" } }, { @@ -29800,7 +29876,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" } }, { @@ -30200,7 +30276,7 @@ "notes": "Read-only transport/initiative history; it is not role claim acceptance.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "b89e5ea9956abab380ea94a313f75ca14cbb198ad6cdf4e294755b2ea2f12441" + "sha256": "e206c7c6bbb63c3c74808ad2496ff0ca70f89ebc3ab08f9055805c1126f3a4f7" } }, { @@ -30278,7 +30354,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "b89e5ea9956abab380ea94a313f75ca14cbb198ad6cdf4e294755b2ea2f12441" + "sha256": "e206c7c6bbb63c3c74808ad2496ff0ca70f89ebc3ab08f9055805c1126f3a4f7" } }, { @@ -30356,7 +30432,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" } }, { @@ -30434,7 +30510,7 @@ "notes": "Receipt states distinguish persisted, delivered, model-declared-read and handled; none imply Task completion.", "implementation": { "path": "core/src/host/project-inputs.js", - "sha256": "dd52c6ddc7b0f2614ecaa3c3cccbea7a39ea914f24e1602946b93a290eff622e" + "sha256": "d1da0f7eb2ea07394bffae2522c86ebbbe041d38323ccda8ea2b8d078383e256" } }, { @@ -30678,7 +30754,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" } }, { @@ -30999,7 +31075,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": "b19aaf8a85783b5ed0d6394d16efe3e06b4c8de815129a950707a82b2eaedc43" + "sha256": "64fc6a71b95888cd289af540bf57bf35e5d6f31ef5cbaffb4dadf8873b881e64" } }, { @@ -33974,7 +34050,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": "b19aaf8a85783b5ed0d6394d16efe3e06b4c8de815129a950707a82b2eaedc43" + "sha256": "64fc6a71b95888cd289af540bf57bf35e5d6f31ef5cbaffb4dadf8873b881e64" } }, { @@ -34074,7 +34150,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": "b19aaf8a85783b5ed0d6394d16efe3e06b4c8de815129a950707a82b2eaedc43" + "sha256": "64fc6a71b95888cd289af540bf57bf35e5d6f31ef5cbaffb4dadf8873b881e64" } }, { @@ -34451,7 +34527,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": "ed9c33cb7fb3c780300f94a05b340c77c9a7a640da14b3015ca8c40d8a95fd9e" + "sha256": "db8aa73099164cb395625d833b13fa443f238d5083eaeab63917847338c66685" } }, { @@ -34546,7 +34622,7 @@ "notes": "Host derives source, recipient and claim. Persisted/delivered does not mean read or handled.", "implementation": { "path": "core/src/host/project-inputs.js", - "sha256": "dd52c6ddc7b0f2614ecaa3c3cccbea7a39ea914f24e1602946b93a290eff622e" + "sha256": "d1da0f7eb2ea07394bffae2522c86ebbbe041d38323ccda8ea2b8d078383e256" } }, { @@ -43363,7 +43439,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "b89e5ea9956abab380ea94a313f75ca14cbb198ad6cdf4e294755b2ea2f12441" + "sha256": "e206c7c6bbb63c3c74808ad2496ff0ca70f89ebc3ab08f9055805c1126f3a4f7" }, "aliasOf": "development.query.handoff.get" }, @@ -43439,7 +43515,7 @@ "notes": "Read-only transport/initiative history; it is not role claim acceptance.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "b89e5ea9956abab380ea94a313f75ca14cbb198ad6cdf4e294755b2ea2f12441" + "sha256": "e206c7c6bbb63c3c74808ad2496ff0ca70f89ebc3ab08f9055805c1126f3a4f7" }, "aliasOf": "development.query.handoff.list" }, @@ -43719,7 +43795,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" }, "aliasOf": "development.query.task.detail" }, @@ -44856,7 +44932,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": "b19aaf8a85783b5ed0d6394d16efe3e06b4c8de815129a950707a82b2eaedc43" + "sha256": "64fc6a71b95888cd289af540bf57bf35e5d6f31ef5cbaffb4dadf8873b881e64" }, "aliasOf": "development.command.chief.inspect" }, @@ -44954,7 +45030,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": "b19aaf8a85783b5ed0d6394d16efe3e06b4c8de815129a950707a82b2eaedc43" + "sha256": "64fc6a71b95888cd289af540bf57bf35e5d6f31ef5cbaffb4dadf8873b881e64" }, "aliasOf": "development.command.chief.configure" }, @@ -45030,7 +45106,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": "b19aaf8a85783b5ed0d6394d16efe3e06b4c8de815129a950707a82b2eaedc43" + "sha256": "64fc6a71b95888cd289af540bf57bf35e5d6f31ef5cbaffb4dadf8873b881e64" }, "aliasOf": "development.query.chief.state" }, @@ -45399,7 +45475,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": "ed9c33cb7fb3c780300f94a05b340c77c9a7a640da14b3015ca8c40d8a95fd9e" + "sha256": "db8aa73099164cb395625d833b13fa443f238d5083eaeab63917847338c66685" }, "aliasOf": "development.command.execution.input" }, @@ -45475,7 +45551,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": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f" + "sha256": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2" }, "aliasOf": "development.query.pools" }, @@ -45871,7 +45947,7 @@ "core/src/host/groups.js": "d7f0175c8ad67522aca392d7e47f9d2773c02f525bd6f33d564cbec784217bbf", "core/src/host/harness.js": "49e4b3ad56827d47cf8c5af99600026437c37a5e3b7e01bad2000983f6541af3", "core/src/host/home-lock.js": "c4ad69a0a3a2726888b69ffc46a624644379960834f385d83b93f88b2f05fa04", - "core/src/host/hooks.js": "3a4b4e24e1ddb760ea499397a03d019ef2efa5dd01324dc286338d8465ddcd26", + "core/src/host/hooks.js": "263e7d7b7c7f7eb8f2e58686cf7e565c76bebe5e36506ba9108367bb0a5da583", "core/src/host/images.js": "18579c262f130ffba10b51cf8f9562730f1ebfa3eff6321f698d72b537fae9d4", "core/src/host/journal.js": "5050048eba15af5520664b7f40dbca1aa457f08c3bbf5ed2798fc99ee9d64906", "core/src/host/meetings.js": "eb68d1cc18ed4370dc8271bd9620966aabb9d8c01001e19fbfc16124852bc063", @@ -45894,7 +45970,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": "b19aaf8a85783b5ed0d6394d16efe3e06b4c8de815129a950707a82b2eaedc43", + "core/src/host/project-chief-control.js": "64fc6a71b95888cd289af540bf57bf35e5d6f31ef5cbaffb4dadf8873b881e64", "core/src/host/project-chief-lifecycle.js": "e854d2fb22b4ea5041ea63a7f072f5af61863c9703ed063b82d0858a15129c68", "core/src/host/project-chief-protocol.js": "a09d85e04171b22625d3eed131dd6a5227dc1a946ad39d8a59f017bb75f71936", "core/src/host/project-chief.js": "a3f01a053177fd8f10d4b778d79ac98c25aee70aa3c4d967dc7e1da2aea2cf6e", @@ -45909,7 +45985,7 @@ "core/src/host/project-delegations.js": "d4d86192551994a3419e0d2ca0f64bc4d50a75960970d2b79ed9f97b82940903", "core/src/host/project-delivery-template.js": "daa5cc8185abc1ec84421ae3fcd5574a50573cfd1a7c4cb10941158e29c79a13", "core/src/host/project-delivery.js": "0e2de57798d9d121ccd1e6b93466fe791cd9baa41b5ae59ac52fd76fa7ad0890", - "core/src/host/project-development-service.js": "3a64f135a0f4bbfcb5945360cff6acced5516319a23a81052ce9802ed7d2b20f", + "core/src/host/project-development-service.js": "ae1afd759df9d888d39d658f4b24f55756ecefcbb625d794d629479a575f75f2", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "5557782333a0b3437dacde2b6924fe12ea54225098571fa8e404d79a432be549", "core/src/host/project-execution-authority.js": "9c5ffb4bc9153d8f9d9e7901f7aef2aee90cacc19f19d8a0726ac0adb93e3e36", @@ -45923,17 +45999,18 @@ "core/src/host/project-github-connections.js": "471e5e07d424fa6a7b22b173545c2232836ebcf45b76f8b9e2f1a1af376bfd2d", "core/src/host/project-github-read.js": "dbbdbbc0d917bbf20c4fdf7306464cbb682b0191b98c36fddbe70e6837a57943", "core/src/host/project-github.js": "bf6d3891a6fa9c0516cd330d3322a750fc6db8813752f7801d4d9760fca84061", - "core/src/host/project-harness-turn.js": "63437ae7b129057b87c70b88417a7d77752bbb625217cd90518c0cd610b965b9", - "core/src/host/project-harness.js": "ed9c33cb7fb3c780300f94a05b340c77c9a7a640da14b3015ca8c40d8a95fd9e", + "core/src/host/project-harness-turn.js": "77b137b906444858326049d62d66b2f3299f06048f2451aa9b2a61d4154e018e", + "core/src/host/project-harness.js": "db8aa73099164cb395625d833b13fa443f238d5083eaeab63917847338c66685", "core/src/host/project-history-pages.js": "38958623f0aa98099e570559c55a207285fd0349689ffdcec47b965c40f4e1bc", - "core/src/host/project-inputs.js": "dd52c6ddc7b0f2614ecaa3c3cccbea7a39ea914f24e1602946b93a290eff622e", + "core/src/host/project-inputs.js": "d1da0f7eb2ea07394bffae2522c86ebbbe041d38323ccda8ea2b8d078383e256", "core/src/host/project-lifecycle.js": "055784e0a1cbf2a9e465e990c60fbd6df4b8844052db43bbcb7cae5de9282936", "core/src/host/project-merge-heads.js": "9e5e39b8aaa191c2175feaafcf6524b8a16dbafdfa573f94222b9f71d8482074", "core/src/host/project-metrics.js": "80f209d76955e5a3058fa66757543059fded1030625e5e155be5254494ce5264", "core/src/host/project-native-sessions.js": "4b825e7e509d0a9a0279ee7a70d1de383378a1892b7822dc7d478bd46de21c00", - "core/src/host/project-pools.js": "b89e5ea9956abab380ea94a313f75ca14cbb198ad6cdf4e294755b2ea2f12441", + "core/src/host/project-pools.js": "e206c7c6bbb63c3c74808ad2496ff0ca70f89ebc3ab08f9055805c1126f3a4f7", "core/src/host/project-private-file.js": "e8b51d8da7bcd72658eb6eb953dbf9d0054ea0ee519b0229785e8ea0aacef6df", "core/src/host/project-process-proof.js": "85f4ed03f5191b842ca899b712c99177c059658d43a66e8ddeedc1c06eafcdea", + "core/src/host/project-receipt-correction.js": "7162d771d2e28fb4f55af6d492d9b9dc18c9e96f7fbacf660b84c551281ec167", "core/src/host/project-release-github.js": "47c52d0057dd39f1ee627c619970efad36e0134bc0c7bc218d43187230d51d64", "core/src/host/project-releases.js": "ac588064b036f2273c7b61cf3389c1da720e6f2166c37d269afd9d9095eb6885", "core/src/host/project-repository-policy.js": "c16f469f3275f61e817bcad0bc8c753f01b034db6562c4e3d197f0349174823a", diff --git a/docs/subsystems/core-host.md b/docs/subsystems/core-host.md index 6936d329b..40f48e31b 100644 --- a/docs/subsystems/core-host.md +++ b/docs/subsystems/core-host.md @@ -990,3 +990,8 @@ The [compatibility contract](project-compatibility.md) inventories original entr #2336 connects Workflow admission abort to existing carrier release and claim-scoped reservation rollback. Failure before a returned workerId no longer skips cleanup; unknown execution remains held. See [resources](project-resources.md). + +#2338 reuses Effects for bounded original-claim input receipt correction. Its +private proposal archive and current-scope feedback preserve immutable business +evidence; Task views expose correction status without proposal text or paths. +See [input contract](project-inputs.md#invalid-receipt-correction-2338). diff --git a/docs/subsystems/plugin-runtime.md b/docs/subsystems/plugin-runtime.md index 5b0bf3ded..acef17126 100644 --- a/docs/subsystems/plugin-runtime.md +++ b/docs/subsystems/plugin-runtime.md @@ -192,3 +192,7 @@ input preflight. Only the existing workflow scope reaches the host check; other plugins receive null. Old hosts may omit it. The check grants no Task/Project rights and creates no scheduler; failed budget/current-generation/save validation prevents send. The published FlyBot type declares the additive optional seam. + +#2338 extends the original Workflow pool adapter with same-worker receipt correction. +SDK uses the existing input channel; native uses the original idle Agent Session +and tool guard. No plugin gains independent Task admission or completion authority. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index cfcd55140..d63304742 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -637,3 +637,8 @@ independent execution environment so nested Node tests execute real assertions. Actual missing-runner npm tests and an explicit local runner fixture delegating to Node assertions verify clean→prepare→same-candidate retry; the latter is not installed Vitest or live npm-ci/provider acceptance. + +#2338 validates receipt protocol before role release or host completion. A receipt +patch never repeats code/testing/review or replaces a frozen business output. +[Input correction](project-inputs.md#invalid-receipt-correction-2338) records the +original evidence and post-failure reconciliation boundaries. diff --git a/docs/subsystems/project-harness.md b/docs/subsystems/project-harness.md index 1ef7f7222..cae4f15c9 100644 --- a/docs/subsystems/project-harness.md +++ b/docs/subsystems/project-harness.md @@ -452,3 +452,9 @@ existing admission/activation fences. Context mismatch is structured feedback fo Chief; original context and unknown-exit capacity stay protected. Proven pre-start failure reuses #2336 rollback. No automatic human escalation or business routing. See [recovery details and validation boundaries](../task-session-recovery.md). + +#2338 adds capability-advertised receipt-only continuation in the same SDK thread. +Correction turns disable tools while preserving the role filesystem profile; +sealed execution logs/result hashes remain original. Stale turn frames are ignored. +Native correction waits for the original Agent to idle and denies business tools. +See [receipt contract](project-inputs.md#invalid-receipt-correction-2338). diff --git a/docs/subsystems/project-inputs.md b/docs/subsystems/project-inputs.md index 9476a8407..d30de6179 100644 --- a/docs/subsystems/project-inputs.md +++ b/docs/subsystems/project-inputs.md @@ -1,6 +1,7 @@ --- subsystem: project-inputs owned_paths: + - core/src/host/project-receipt-correction.js - core/src/host/project-inputs.js - core/test/project-inputs.test.js --- @@ -134,3 +135,41 @@ schema-aware reconciliation, not blind restoration of older code or state. ## Project Task lifecycle convergence (#2179) Chief-confirmed requirement revisions become requirements-change inputs with Chief-decision provenance in the same existing inbox. Notification uses the original postcommit input.notify effect. Uncommitted input staging is discarded on confirmed rollback; a changed requirement version invalidates old delivery acceptance. + + +## Invalid receipt correction (#2338) + +Role receipts are preflighted before worker release, host completion/tests/review +or downstream routing. Schema/unknown-batch/uninjected-ID/handled-not-read errors +carry `input-receipt-feedback/v1`: exact current Task/claim/generation, field, +correctability and at most8 injected manifests (IDs/hashes only, never bodies). +Foreign/historical batch, foreign Task, stale claim/snapshot, revoked source and +changed content remain denials, without foreign manifests or automatic remapping. +A valid multi-batch declaration validates every batch before any acknowledgement. +Acknowledgement and its `input.receipt` Effect commit together; uncommitted failure +rolls back only its own unchanged fields. An acknowledged rename remains committed. + +Correctable role output freezes the original business proposal in a private, +hash-checked archive (maximum64KiB). The original claim stores only the reference, +up to5 feedback records and a bounded correction counter. Existing Effects deliver +`input.correction` to the original worker/Session; no new Task or role is admitted. +The role returns `OUTPUT handoff: {"correctionOf":"", +"inputReceipts":{...}}` (or up to8 receipt batches). Only receipts are replaced; +the original business action/outputs/evidence stay frozen. Tools are disabled for +this narrow turn. All live authority, input and claim gates run again. New inputs +remain pending and cannot be declared handled merely by correcting another batch. + +`projectHandoff.inputCorrectionBudget` defaults to2, range0–4. Original execution +timeout and evidence budgets still apply. Duplicate original completions do not +spend another round; SDK correction frames bind a turn ID and backend Session. +A committed dispatch marker prevents blind resend after ambiguous transport. +Exhaustion/transport failure is an explicit incomplete state for Chief judgement, +never an automatic human consultation or fixed next role. Restart preserves the +proposal and feedback but fences the old claim; recovery must confirm exit and +reconcile context, never replay the business proposal from an expired receipt. + +Chief uses the same bounded feedback constructor and its existing resident-Session +correction mechanism. All receipt plans are checked before proposals or Task effects. +A corrected response declares the current snapshot explicitly; no old batch is +silently translated. Candidate-stage evidence already produced by an SDK stays +immutable and is not re-recorded as a second execution during receipt correction. diff --git a/docs/subsystems/project-pools.md b/docs/subsystems/project-pools.md index 03e84664d..dd44f9b87 100644 --- a/docs/subsystems/project-pools.md +++ b/docs/subsystems/project-pools.md @@ -196,3 +196,8 @@ false and retains occupancy. Claim failure is auditable and may return to Chief confirmed cleanup; neither empty workerId nor Task failure alone proves exit. #2346 keeps typed context-resume admission facts on the original claim/handoff. Proven unstarted binding failure uses the existing abort path; unknown exit is held. Chief receives facts without an implicit destination. See [Task-role recovery](../task-session-recovery.md). + +#2338 preflights receipt plans before release/completion and dispatches bounded +receipt-only corrections through existing Effects, preserving the claim and +frozen business intent. Exhaustion remains visible to Chief; no fixed escalation. +See [receipt correction](project-inputs.md#invalid-receipt-correction-2338). diff --git a/patches/ds-bot/0001-core-fixes.patch b/patches/ds-bot/0001-core-fixes.patch index 72bc69739..67a523661 100644 --- a/patches/ds-bot/0001-core-fixes.patch +++ b/patches/ds-bot/0001-core-fixes.patch @@ -9718,7 +9718,7 @@ diff --git a/test/images.test.js b/test/images.test.js diff --git a/test/install.test.js b/test/install.test.js --- a/test/install.test.js +++ b/test/install.test.js -@@ -33,10 +33,26 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { +@@ -33,10 +33,28 @@ test('apply registers listeners, tools, and effects in a fixed order', (t) => { const { ctx, stop, applyResult } = start() t.after(stop) assert.equal(applyResult, undefined) @@ -9736,6 +9736,8 @@ diff --git a/test/install.test.js b/test/install.test.js + // Reject stale Project delegations, then intercept managed schedule deliveries. + 'on agent/pre-step', + 'tools guard', ++ // Narrow Task-role receipt corrections deny business tools in the original Session. ++ 'tools guard', + 'on agent/pre-step', 'on llm/adapters-updated', 'on session/event', diff --git a/patches/ds-bot/files/src/host/hooks.js b/patches/ds-bot/files/src/host/hooks.js index adca68bbf..4c5580b71 100644 --- a/patches/ds-bot/files/src/host/hooks.js +++ b/patches/ds-bot/files/src/host/hooks.js @@ -1407,6 +1407,17 @@ export function install(rt) { if (result?.ok === false) throw new Error(result.code ?? 'Pool dispatch failed') if (taskInbox?.inputs.length && !rt.state.projectHarnessWorkers?.[workerId]) await rt.projectInputs.transport(task.id, claim, taskInbox.batchId, { kind: 'adapter-written', sha256: createHash('sha256').update(JSON.stringify(taskInbox)).digest('hex') }) }, + async correct(input) { + const sdk = await rt.projectHarness?.correctInput(input) + if (sdk) return sdk + if (typeof rt.ctx.tools?.guard !== 'function') throw new Error('Native receipt correction requires the tool guard') + const sessionId = rt.chatOf(input.workerId), agent = sessionId && await rt.resolveAgent(sessionId) + if (!agent || agent.session?.id !== sessionId || typeof agent.whenIdle !== 'function') throw new Error('Original native receipt Session is unavailable') + await agent.whenIdle() + const c = input.task.collaboration + if (rt.chatOf(input.workerId) !== sessionId || c.claim?.id !== input.claim.id || c.generation !== input.claim.generation || c.claim.inputCorrection?.round !== input.round || c.claim.inputCorrection?.status !== 'pending') throw new Error('Native receipt correction was superseded') + return lifecycle.dispatch({ botId: input.workerId, taskId: input.task.id, brief: input.prompt, inputs: {}, outputs: [{ name: 'handoff', required: true }] }) + }, async release(id) { await lifecycle.release(id) return rt.projectDelivery.exitConfirmed(id) 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 97e30baeb..2994f1f5d 100644 --- a/patches/ds-bot/files/src/host/project-chief-control.js +++ b/patches/ds-bot/files/src/host/project-chief-control.js @@ -6,6 +6,7 @@ import { fileDigest } from './project-resource-workspaces.js' import { ownEntry, setEntry } from './store.js' import { dependencyFact } from './project-task-lifecycle.js' import { inspectionLifecycle, inspectionHealth, scheduledInspectionState } from './project-chief-lifecycle.js' +import { protocolCorrection } from './project-receipt-correction.js' import { CHIEF_AUTONOMY, parseChiefResponse, correctionBudget, chiefProtocolError } from './project-chief-protocol.js' import { confirmedWorkerExit } from './project-task-sessions.js' import { stageRoleSummary } from './project-context-memory.js' @@ -232,6 +233,16 @@ export function createProjectChiefControl(rt) { if (run.taskId && !parsed.decisions.some(row => row.taskId === run.taskId)) throw chiefProtocolError('decisions', 'an explicit decision for the requested Task', run.taskId) if (run.taskId) { const task = rt.state.tasks[run.taskId]; if (task.collaboration?.claim?.chiefRunId === run.id) { task.collaboration.claim.workerId = execution.workerId ?? `native-chief-job-${run.id}`; task.collaboration.claim.executionEvidence = execution } } job.executionEvidence = execution + for (const decision of parsed.decisions) { + if (run.appliedDecisions?.some(row => row.taskId === decision.taskId)) continue + const saved = job.snapshot.find(row => row.taskId === decision.taskId) + if (!saved) throw chiefProtocolError('taskId', 'an exact Task in this invocation snapshot', decision.taskId) + try { rt.projectInputs?.validateChiefReceipt?.(decision.taskId, saved, run, decision.inputReceipts) } + catch (error) { + if (!error.receiptFeedback?.correctable) throw error + throw Object.assign(chiefProtocolError('inputReceipts', 'the exact injected snapshot IDs and handled subset of read', decision.taskId), { receiptFeedback: error.receiptFeedback }) + } + } const roleProposals = parsed.roleProposals ?? [] if (!Array.isArray(roleProposals) || roleProposals.length > 4) throw refuse('Chief role proposals must be a bounded array') run.roleProposalIds = [] @@ -264,8 +275,8 @@ export function createProjectChiefControl(rt) { if (active.get(run.id) !== job || run.status !== 'running') return { ignored: true } if (error.code === 'bot/chief-protocol') { const previous = run.correction, round = (previous?.round ?? 0) + 1, limit = run.correctionLimit ?? correctionBudget(policyOf(run.projectId)) - const feedback = { code: error.code, taskId: error.taskId, fields: error.fields, message: error.message.slice(0, 500), at: Date.now(), sideEffects: 'response protocol rejected before applying its decisions', appliedTaskIds: (run.appliedDecisions ?? []).map(row => row.taskId) } - run.correction = { version: 'chief-correction/v1', round, limit, status: round <= limit ? 'pending' : 'exhausted', feedback: [...(previous?.feedback ?? []), feedback].slice(-5), priorExecution: structuredClone(job.executionEvidence ?? null) } + const feedback = { ...(error.receiptFeedback ? { receiptFeedback: error.receiptFeedback } : {}), code: error.code, taskId: error.taskId, fields: error.fields, message: error.message.slice(0, 500), at: Date.now(), sideEffects: 'response protocol rejected before applying its decisions', appliedTaskIds: (run.appliedDecisions ?? []).map(row => row.taskId) } + run.correction = protocolCorrection(previous, { version: 'chief-correction/v1', limit, feedback, priorExecution: job.executionEvidence ?? null }) run.status = round <= limit ? 'correction-pending' : 'incomplete'; run.failure = feedback.message if (rt.projectEffects) { const token = rt.projectEffects.stage({ kind: 'chief.correction', key: `${run.id}:${round}`, projectId: run.projectId, ...(run.taskId ? { taskId: run.taskId } : {}), payload: { runId: run.id, round, feedbackSha256: digest(feedback) } }) 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 43fea4444..90b22ce3b 100644 --- a/patches/ds-bot/files/src/host/project-development-service.js +++ b/patches/ds-bot/files/src/host/project-development-service.js @@ -51,6 +51,8 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm const nativeCandidate = ownEntry(state.projectNativeWorkers ?? {}, claim?.workerId) const nativeWorker = sameTask(nativeCandidate, task) ? nativeCandidate : null const executionView = executionMetadataView(state, sdk ?? nativeWorker) + const receiptCorrection = claim?.inputCorrection + if (executionView && receiptCorrection) executionView.inputCorrection = { version: receiptCorrection.version, round: receiptCorrection.round, limit: receiptCorrection.limit, status: receiptCorrection.status, proposalSha256: receiptCorrection.proposal?.sha256 ?? null, feedback: receiptCorrection.feedback.map(({ kind, field, correctable, sideEffects, businessEffects }) => ({ kind, field, correctable, sideEffects, businessEffects })) } const nativeActivated = nativeWorker && isIntentCommitted(nativeWorker.activationEffect) const native = ownEntry(state.bots ?? {}, claim?.workerId) const nativeActive = native?.projectId === task.projectId && (!attempt || native.attemptId === attempt.id) && activity[claim?.workerId] @@ -63,6 +65,7 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm else if (task.status === 'cancelled' || c?.status === 'cancelled' || attempt?.status === 'cancelled') phase = 'cancelled' else if (attempt?.cancelRequested || c?.status === 'stopping') { phase = 'stopping'; waitingFor = 'worker-exit' } else if (nativeWorker?.status === 'stopping' || nativeWorker?.status === 'stop-unconfirmed') { phase = nativeWorker.status === 'stopping' ? 'stopping' : 'blocked'; waitingFor = 'worker-exit'; reason = c?.block?.reason ?? null } + else if (receiptCorrection?.status === 'pending') { phase = 'awaiting-input'; waitingFor = 'role-correction'; reason = 'Original role is correcting input receipts; business proposal is preserved' } else if (task.chiefDecisionIssue?.status === 'pending') { phase = 'awaiting-chief'; waitingFor = 'chief-correction'; reason = task.chiefDecisionIssue.feedback.message } else if (task.chiefDecisionIssue?.status === 'exhausted') { phase = 'blocked'; waitingFor = 'chief-recovery'; reason = 'Chief judgement incomplete; correction budget exhausted' } else if (task.chiefQuestion) { phase = 'awaiting-owner'; waitingFor = task.chiefQuestion.recipient?.kind === 'personal-agent' ? 'personal-agent' : 'owner'; reason = task.chiefQuestion.reason } diff --git a/patches/ds-bot/files/src/host/project-harness-turn.js b/patches/ds-bot/files/src/host/project-harness-turn.js index e4e8fb025..7a6eaa93c 100644 --- a/patches/ds-bot/files/src/host/project-harness-turn.js +++ b/patches/ds-bot/files/src/host/project-harness-turn.js @@ -2,6 +2,7 @@ import { fileURLToPath } from 'node:url' import { validateCodexConfiguration } from './project-codex-config.js' // One real SDK turn. The host owns Workflow completion and acceptance, never this driver. export async function runHarnessTurn(request, { emit, permission, signal, validateCodex = validateCodexConfiguration, loadCodex = () => import('@openai/codex-sdk'), loadClaude = () => import('@anthropic-ai/claude-agent-sdk') }) { + const toolsDisabled = request.noTools || request.protocolCorrection === true let text = '', completed = false, sessionId = request.sessionId ?? null, usage = null if (request.harness === 'codex') { const toolOutputTokens = request.toolOutputTokens ?? 2000 @@ -10,7 +11,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida const denyScript = fileURLToPath(new URL('./project-harness-deny-tool.mjs', import.meta.url)) const quote = value => `"${value.replaceAll('\\', '\\\\').replaceAll('"', '\\"').replaceAll('$', '\\$').replaceAll('`', '\\`')}"` const connectorPolicy = { remote_plugin: false, skill_mcp_dependency_install: false } - const chiefConfig = request.noTools ? { features: { ...connectorPolicy, shell_tool: false }, tools: { view_image: false }, apps: { _default: { enabled: false } }, + const chiefConfig = toolsDisabled ? { features: { ...connectorPolicy, shell_tool: false }, tools: { view_image: false }, apps: { _default: { enabled: false } }, hooks: { PreToolUse: [{ matcher: '.*', hooks: [{ type: 'command', command: `node ${quote(denyScript)}` }] }] } } : { features: connectorPolicy } // Raw network denial also denies Unix pipe lifecycle syscalls in CLI 0.166, // breaking Node child_process despite a successful child exit. The limited @@ -22,7 +23,7 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida const inlineToml = value => value && typeof value === 'object' ? '{' + Object.entries(value).map(([key, child]) => `${JSON.stringify(key)}=${inlineToml(child)}`).join(',') + '}' : JSON.stringify(value) const rawPolicy = request.noTools ? [] : ['permissions.flybot-role=' + inlineToml(processPolicy.permissions['flybot-role'])] const configPolicy = request.noTools ? {} : { ...processPolicy, permissions: { 'flybot-role': { extends: processPolicy.permissions['flybot-role'].extends, network: processPolicy.permissions['flybot-role'].network } } } - const clientOptions = { env: request.env, configOverrides: ['mcp_servers={}', 'plugins={}', 'apps={_default={enabled=false}}', ...rawPolicy], ...(request.baseUrl ? { baseUrl: request.baseUrl } : {}), config: { ...(request.config ?? {}), ...chiefConfig, ...configPolicy, tool_output_token_limit: toolOutputTokens } } + const clientOptions = { env: request.env, configOverrides: ['mcp_servers={}', 'plugins={}', 'apps={_default={enabled=false}}', ...rawPolicy], ...(request.baseUrl ? { baseUrl: request.baseUrl } : {}), config: { ...(request.config ?? {}), ...chiefConfig, ...configPolicy, features: { ...chiefConfig.features, ...configPolicy.features, ...(toolsDisabled ? { shell_tool: false } : {}) }, tool_output_token_limit: toolOutputTokens } } const validated = await validateCodex({ ...request, signal }, clientOptions) if (validated.capability) await emit({ type: 'configuration-checked', harness: 'codex', capability: validated.capability }) const client = new Codex({ ...clientOptions, codexPathOverride: validated.codexPathOverride, env: validated.env }) @@ -48,10 +49,10 @@ export async function runHarnessTurn(request, { emit, permission, signal, valida settingSources: [], mcpServers: {}, persistSession: true, ...(sessionId ? { resume: sessionId } : {}), maxTurns: request.probe ? 1 : 30, maxBudgetUsd: request.maxBudgetUsd ?? 1, permissionMode: 'default', - tools: request.noTools ? [] : request.readOnly ? ['Read', 'Glob', 'Grep', 'Bash'] : ['Read', 'Glob', 'Grep', 'Bash', 'Edit', 'Write'], - hooks: request.noTools ? { PreToolUse: [{ hooks: [async () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Use FlyBot scoped action proposals only' } })] }] } : {}, + tools: toolsDisabled ? [] : request.readOnly ? ['Read', 'Glob', 'Grep', 'Bash'] : ['Read', 'Glob', 'Grep', 'Bash', 'Edit', 'Write'], + hooks: toolsDisabled ? { PreToolUse: [{ hooks: [async () => ({ hookSpecificOutput: { hookEventName: 'PreToolUse', permissionDecision: 'deny', permissionDecisionReason: 'Use FlyBot scoped action proposals only' } })] }] } : {}, canUseTool: async (name, input, options) => { - if (request.noTools) return { behavior: 'deny', message: 'Generic Chief tools are disabled' } + if (toolsDisabled) return { behavior: 'deny', message: 'Generic Chief tools are disabled' } const answer = await permission({ name, input, requestId: options.requestId ?? options.toolUseID }) return answer.allow ? { behavior: 'allow', updatedInput: input } : { behavior: 'deny', message: 'FlyBot operator refused this action' } }, diff --git a/patches/ds-bot/files/src/host/project-harness-worker.mjs b/patches/ds-bot/files/src/host/project-harness-worker.mjs index 18ed9fcb7..2b8861af2 100644 --- a/patches/ds-bot/files/src/host/project-harness-worker.mjs +++ b/patches/ds-bot/files/src/host/project-harness-worker.mjs @@ -9,20 +9,26 @@ const input = createInterface({ input: process.stdin, crlfDelay: Infinity }) const write = value => process.stdout.write(JSON.stringify(value) + '\n') const answers = new Map() let controller, sessionId, settings, busy = false, shuttingDown = false +const completedTurns = new Map() const permission = request => new Promise(resolve => { const id = randomUUID(); answers.set(id, resolve); write({ type: 'permission', id, request }) }) async function turn(message) { + if (message.turnId && completedTurns.has(message.turnId)) { write(completedTurns.get(message.turnId)); return } if (busy) { write({ type: 'failure', reason: 'SDK Session is already answering' }); return } busy = true; controller = new AbortController() try { settings ??= message.settings sessionId ??= settings.sessionId - const result = await runHarnessTurn({ ...settings, sessionId, prompt: message.prompt }, { emit: write, permission, signal: controller.signal }) + const emit = value => write({ ...value, ...(message.turnId ? { turnId: message.turnId } : {}) }) + const result = await runHarnessTurn({ ...settings, sessionId, protocolCorrection: message.protocolCorrection === true, prompt: message.prompt }, { emit, permission, signal: controller.signal }) sessionId = result.sessionId - write({ type: 'completed', ...result }) + const response = { type: 'completed', ...result, ...(message.turnId ? { turnId: message.turnId } : {}) } + if (message.turnId) { completedTurns.set(message.turnId, response); if (completedTurns.size > 16) completedTurns.delete(completedTurns.keys().next().value) } + busy = false + write(response) if (settings.probe) void shutdown() - } catch (error) { write({ type: 'failure', reason: ['bot/harness-capability', 'bot/context-resume'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } + } catch (error) { write({ type: 'failure', ...(message.turnId ? { turnId: message.turnId } : {}), reason: ['bot/harness-capability', 'bot/context-resume'].includes(error?.code) ? error.message : 'SDK execution failed; inspect the redacted event evidence and resource environment', ...(error?.code === 'bot/context-resume' ? { contextFailure: { code: error.code, kind: 'backend-session-mismatch', contextPreserved: true } } : {}), ...(error?.code === 'bot/harness-capability' ? { capabilityFailure: { code: error.code, kind: error.capability, stage: 'configuration', modelInvoked: false } } : {}) }) } finally { busy = false } } input.on('line', line => { @@ -55,4 +61,4 @@ function sdkVersion(name) { } catch { /* Missing SDK is an explicit failed handshake, not a simulated version. */ } return null } -write({ type: 'ready', pid: process.pid, sdk: { codex: sdkVersion('@openai/codex-sdk'), 'claude-agent': sdkVersion('@anthropic-ai/claude-agent-sdk') } }) +write({ type: 'ready', receiptCorrection: 'input-correction/v1', pid: process.pid, sdk: { codex: sdkVersion('@openai/codex-sdk'), 'claude-agent': sdkVersion('@anthropic-ai/claude-agent-sdk') } }) diff --git a/patches/ds-bot/files/src/host/project-harness.js b/patches/ds-bot/files/src/host/project-harness.js index 4106cadda..2072f266b 100644 --- a/patches/ds-bot/files/src/host/project-harness.js +++ b/patches/ds-bot/files/src/host/project-harness.js @@ -200,6 +200,7 @@ export function createProjectHarness(rt) { try { const signal = JSON.parse(line), lease = ownEntry(rt.state.projectResourceLeases ?? {}, row.leaseId) if (signal.type === 'ready' && active.get(id) === control) { + control.receiptCorrectionSupported = signal.receiptCorrection === 'input-correction/v1' control.readyObserved = true control.observedSdkVersion = /^\d+\.\d+\.\d+(?:-[A-Za-z0-9.]+)?$/.test(signal.sdk?.[row.harness] ?? '') ? signal.sdk[row.harness] : null if (signal.sdk?.[row.harness] !== VERSIONS[row.harness]) readyReject(refuse('SDK version on selected resource is missing or incompatible')) @@ -212,6 +213,7 @@ export function createProjectHarness(rt) { if (active.get(id) !== control || row.status === 'released' || currentLease?.generation !== row.generation || currentLease.status === 'released') return if (line.length > 1024 * 1024) throw refuse('SDK event exceeds maximum size') const event = redact(JSON.parse(line), control.env) + if (control.correctionTurnId && ['completed', 'sdk-event', 'failure', 'permission', 'configuration-checked'].includes(event.type) && event.turnId !== control.correctionTurnId) return row.lastEventAt = Date.now() row.eventCount++ row.eventBytes = (row.eventBytes ?? 0) + Buffer.byteLength(JSON.stringify(event)) @@ -220,6 +222,7 @@ export function createProjectHarness(rt) { row.lastEventHash = sha(JSON.stringify(event)) if (event.type === 'stopped') control.stoppedConfirmed = event.released === true if (event.type === 'ready') { control.ready = true; if (row.status === 'prepared') row.status = 'idle' } + if (event.type === 'permission' && control.correctionTurnId) { control.process.stdin.write(JSON.stringify({ type: 'permission', id: event.id, allow: false }) + '\n'); return } if (event.type === 'permission') { row.status = 'waiting-input'; row.pending = { id: event.id, kind: 'permission', request: event.request } } if (event.type === 'sdk-event') { row.lastEventAt = Date.now() @@ -238,6 +241,11 @@ export function createProjectHarness(rt) { rt.projectResources.setting(row.projectId, row.resourceId) if (row.backendSessionId && row.backendSessionId !== event.sessionId) throw refuse('SDK result names another Session') const rawText = event.text + if (control.correctionTurnId) { + if (event.sessionId !== row.backendSessionId) throw refuse('Receipt correction changed its backend Session') + row.inputCorrectionResult = { turnId: control.correctionTurnId, sha256: sha(rawText), at: Date.now() } + row.status = 'completed'; await report(row, true, rawText); return + } if (!control.chief && session.poolClaimId && (control.safeInputTurns ?? 0) < 4) { const task = rt.state.tasks[row.taskId], claim = task?.collaboration?.claim if (claim?.id === session.poolClaimId && claim.status === 'active' && task.collaboration.generation === claim.generation) { @@ -337,6 +345,23 @@ export function createProjectHarness(rt) { } for (const listener of listeners) await listener({ botId: row.id, ok, text }) } + async function correctInput({ workerId, task, claim, prompt, round }) { + const row = ownEntry(workers(), workerId) + if (!row) return null + const control = active.get(workerId) + if (!control || control.closed || !control.receiptCorrectionSupported || row.status !== 'completed' || row.projectId !== task.projectId || row.taskId !== task.id || row.attemptId !== claim.attemptId || control.session.poolClaimId !== claim.id) throw refuse('Original SDK receipt-correction channel is unavailable') + await requireExecutionAuthority(rt, control.session) + const lease = ownEntry(rt.state.projectResourceLeases ?? {}, row.leaseId) + if (!lease || lease.generation !== row.generation || lease.status === 'released' || task.collaboration.claim !== claim && task.collaboration.claim?.id !== claim.id || task.collaboration.generation !== claim.generation || claim.inputCorrection?.round !== round) throw refuse('Receipt correction scope changed') + checkContextPrompt(prompt, control.contextBudget) + control.correctionTurnId = `receipt-${claim.id}-${round}` + control.reported = false; control.inputBatch = null; row.status = 'running' + await rt.save() + await requireExecutionAuthority(rt, control.session) + if (active.get(workerId) !== control || control.closed || task.collaboration.claim?.inputCorrection?.status !== 'pending' || task.collaboration.claim.inputCorrection.round !== round || lease.status === 'released') throw refuse('Receipt correction was superseded before delivery') + control.process.stdin.write(JSON.stringify({ type: 'input', turnId: control.correctionTurnId, protocolCorrection: true, prompt }) + '\n') + return { ok: true } + } async function taskInputWake(taskId) { const task = ownEntry(rt.state.tasks ?? {}, taskId), claim = task?.collaboration?.claim const row = claim && ownEntry(workers(), claim.workerId), control = row && active.get(row.id) @@ -519,7 +544,7 @@ export function createProjectHarness(rt) { } delete row.pending; row.status = 'running'; await rt.save(); return { sent: true } } - return { profile, spawn, dispatch, checkNativeInput, taskInputWake, release, input, chiefTurn, onResult: fn => { listeners.add(fn); return () => listeners.delete(fn) }, + return { profile, spawn, dispatch, correctInput, checkNativeInput, taskInputWake, release, input, chiefTurn, onResult: fn => { listeners.add(fn); return () => listeners.delete(fn) }, capabilities: harnessCapabilities, owns: id => Boolean(ownEntry(workers(), id)), hasController: id => active.has(id) && !active.get(id).closed, versions: VERSIONS } } export function install(rt) { rt.projectHarness = createProjectHarness(rt) } diff --git a/patches/ds-bot/files/src/host/project-inputs.js b/patches/ds-bot/files/src/host/project-inputs.js index f9b0872f9..62f6b966e 100644 --- a/patches/ds-bot/files/src/host/project-inputs.js +++ b/patches/ds-bot/files/src/host/project-inputs.js @@ -19,6 +19,7 @@ export function createProjectInputs(rt) { const taskOf = id => { const task = ownEntry(rt.state.tasks, id); if (!task?.projectId) throw refuse('Unknown Project Task'); return task } const writable = () => { if (rt.readOnly?.() !== undefined) throw refuse('Task inbox is read-only') } const authorized = (row, task) => { + if (row.sourceAuthorityRevoked === true) return false if (!['human', 'github'].includes(row.source.kind)) return true try { rt.requireProjectAccess({ memberId: row.source.memberId }, task.projectId, 'task-write', task); return true } catch { return false } } @@ -112,25 +113,54 @@ export function createProjectInputs(rt) { } await rt.save(); return true } - async function acknowledge(taskId, claim, receipts) { - if (receipts === undefined) return + function receiptError(task, claim, kind, field, message, correctable = true, manifest) { + const batches = manifest ?? [...new Set(Object.values(rt.state.projectTaskInputs ?? {}).filter(row => row.taskId === task.id && row.projectId === task.projectId && row.version === INPUT_PROTOCOL).flatMap(row => row.deliveries.filter(d => d.claimId === claim?.id && d.generation === claim?.generation && d.botId === claim?.botId).map(d => d.batchId)))].slice(0, 8).map(batchId => ({ batchId, inputs: deliveryRows(task, claim, batchId).filter(({ row }) => ['accepted', 'handled'].includes(row.status) && authorized(row, task)).map(({ row }) => ({ id: row.id, sha256: row.sha256 })) })) + return Object.assign(refuse(message), { receiptFeedback: { version: 'input-receipt-feedback/v1', kind, field, correctable, taskId: task.id, claimId: claim?.id ?? null, generation: claim?.generation ?? null, batches: correctable ? batches : [], sideEffects: 'receipt rejected before acknowledgement; no read/handled declaration applied' } }) + } + function acknowledgementPlan(taskId, claim, receipts) { + if (receipts === undefined) return [] writable() - const task = taskOf(taskId); current(task, claim) + const task = taskOf(taskId) + try { current(task, claim) } catch { throw receiptError(task, claim, 'stale-claim', 'claim', 'Task input claim is stale', false) } const batches = Array.isArray(receipts) ? receipts : [receipts] - if (!batches.length || batches.length > 8) throw refuse('Input acknowledgement needs one to eight injected batches') - const plans = batches.map(receipt => { - if (!receipt || typeof receipt.batchId !== 'string' || !Array.isArray(receipt.read) || !Array.isArray(receipt.handled) || receipt.read.length > 2 || receipt.handled.length > 2) throw refuse('Input acknowledgement needs the injected batch and bounded read/handled IDs') - const allowed = new Map(deliveryRows(task, claim, receipt.batchId).map(pair => [pair.row.id, pair])) - if (![...receipt.read, ...receipt.handled].every(id => typeof id === 'string' && allowed.has(id)) || !receipt.handled.every(id => receipt.read.includes(id))) throw refuse('Input receipt is outside the current injected batch') - if (receipt.read.some(id => !['accepted', 'handled'].includes(allowed.get(id).row.status) || !authorized(allowed.get(id).row, task))) throw refuse('Input was cancelled or source authority was revoked') - for (const id of receipt.read) verify(allowed.get(id).row) + if (!batches.length || batches.length > 8) throw receiptError(task, claim, 'schema', 'inputReceipts', 'Input acknowledgement needs one to eight injected batches') + return batches.map(receipt => { + if (!receipt || typeof receipt.batchId !== 'string' || !Array.isArray(receipt.read) || !Array.isArray(receipt.handled) || receipt.read.length > 2 || receipt.handled.length > 2) throw receiptError(task, claim, 'schema', 'inputReceipts', 'Input acknowledgement needs the injected batch and bounded read/handled IDs') + const selected = deliveryRows(task, claim, receipt.batchId), allowed = new Map(selected.map(pair => [pair.row.id, pair])) + if (!selected.length) { + const historical = Object.values(rt.state.projectTaskInputs ?? {}).some(row => row.deliveries?.some(d => d.batchId === receipt.batchId)) + throw receiptError(task, claim, historical ? 'stale-or-foreign-batch' : 'unknown-batch', 'batchId', 'Input receipt is outside the current injected batch', !historical) + } + for (const id of [...receipt.read, ...receipt.handled]) if (typeof id !== 'string' || !allowed.has(id)) { + const source = typeof id === 'string' && ownEntry(rt.state.projectTaskInputs ?? {}, id) + throw receiptError(task, claim, source && (source.taskId !== task.id || source.projectId !== task.projectId) ? 'foreign-input' : 'uninjected-input', 'read/handled', 'Input receipt is outside the current injected batch', !source || source.taskId === task.id && source.projectId === task.projectId) + } + if (!receipt.handled.every(id => receipt.read.includes(id))) throw receiptError(task, claim, 'handled-not-read', 'handled', 'Input receipt is outside the current injected batch: handled must be a subset of read') + if (receipt.read.some(id => !['accepted', 'handled'].includes(allowed.get(id).row.status) || !authorized(allowed.get(id).row, task))) throw receiptError(task, claim, 'source-revoked', 'read', 'Input was cancelled or source authority was revoked', false) + for (const id of receipt.read) { try { verify(allowed.get(id).row) } catch { throw receiptError(task, claim, 'content-changed', 'sha256', 'Task input content changed after acceptance', false) } } return { receipt, allowed } }) + } + async function acknowledge(taskId, claim, receipts) { + const plans = acknowledgementPlan(taskId, claim, receipts) + if (!plans.length) return + const undo = [], task = taskOf(taskId) + const change = (object, key, value) => { undo.push({ object, key, before: object[key], after: value }); object[key] = value } for (const { receipt, allowed } of plans) { - for (const id of receipt.read) { const { delivery } = allowed.get(id); delivery.readAt ??= Date.now(); delivery.readProof = 'current-role-declaration' } - for (const id of receipt.handled) { const { row, delivery } = allowed.get(id); row.status = 'handled'; delivery.handledAt ??= Date.now(); delivery.handledProof = 'validated-current-role-decision' } + for (const id of receipt.read) { const { delivery } = allowed.get(id); if (delivery.readAt === undefined) change(delivery, 'readAt', Date.now()); change(delivery, 'readProof', 'current-role-declaration') } + for (const id of receipt.handled) { const { row, delivery } = allowed.get(id); change(row, 'status', 'handled'); if (delivery.handledAt === undefined) change(delivery, 'handledAt', Date.now()); change(delivery, 'handledProof', 'validated-current-role-decision') } + } + let token + try { + if (rt.projectEffects) { + token = rt.projectEffects.stage({ kind: 'input.receipt', key: digest([claim.id, claim.generation, receipts]), projectId: task.projectId, taskId, payload: { claimId: claim.id, generation: claim.generation, receiptSha256: digest(receipts) } }) + await rt.projectEffects.persist(token) + } else await rt.save() + } catch (error) { + if (!token || rt.projectEffects.discardUncommitted(token)) for (const item of undo.reverse()) if (item.object[item.key] === item.after) { if (item.before === undefined) delete item.object[item.key]; else item.object[item.key] = item.before } + for (const { allowed } of plans) for (const { row } of allowed.values()) if (row.status === 'accepted' && row.sourceAuthorityRevoked) { row.status = 'cancelled'; row.cancellation = 'Source Project membership was revoked' } + throw error } - await rt.save() } async function prepareChief(snapshot, run) { writable() @@ -157,11 +187,18 @@ export function createProjectInputs(rt) { if (receipts === undefined) return null writable() const task = taskOf(taskId) - if (ended(task) || run.projectId !== task.projectId || saved.taskId !== task.id || (task.collaboration?.generation ?? 0) !== saved.generation || !saved.taskInbox || receipts?.batchId !== saved.taskInbox.batchId || !Array.isArray(receipts.read) || !Array.isArray(receipts.handled) || receipts.read.length > 2 || receipts.handled.length > 2) throw refuse('Chief input receipt is outside its current snapshot') + if (ended(task) || run.projectId !== task.projectId || saved.taskId !== task.id || (task.collaboration?.generation ?? 0) !== saved.generation) throw receiptError(task, null, 'stale-snapshot', 'snapshot', 'Chief input receipt is outside its current snapshot', false) + const feedback = (kind, field, message, correctable = true) => receiptError(task, null, kind, field, message, correctable, saved.taskInbox ? [{ batchId: saved.taskInbox.batchId, inputs: saved.taskInbox.inputs.map(({ id, sha256 }) => ({ id, sha256 })) }] : []) + if (!saved.taskInbox || receipts?.batchId !== saved.taskInbox.batchId || !Array.isArray(receipts.read) || !Array.isArray(receipts.handled) || receipts.read.length > 2 || receipts.handled.length > 2) throw feedback('snapshot-schema', 'inputReceipts', 'Chief input receipt is outside its current snapshot') const manifest = new Map(saved.taskInbox.inputs.map(row => [row.id, row.sha256])) const selected = new Map(Object.values(rt.state.projectTaskInputs ?? {}).filter(row => row.projectId === task.projectId && row.taskId === task.id && manifest.get(row.id) === row.sha256 && ['accepted', 'handled'].includes(row.status) && authorized(row, task)).map(row => [row.id, { row, delivery: row.deliveries.find(d => d.batchId === receipts.batchId && d.chiefRunId === run.id && d.generation === saved.generation) }]).filter(([, pair]) => pair.delivery)) - if (![...receipts.read, ...receipts.handled].every(id => typeof id === 'string' && selected.has(id)) || !receipts.handled.every(id => receipts.read.includes(id))) throw refuse('Chief acknowledged an input outside its injected snapshot') - for (const id of receipts.read) verify(selected.get(id).row) + for (const id of [...receipts.read, ...receipts.handled]) if (typeof id !== 'string' || !selected.has(id)) { + const row = typeof id === 'string' && ownEntry(rt.state.projectTaskInputs ?? {}, id) + const foreign = row && (row.taskId !== task.id || row.projectId !== task.projectId), revoked = row && (!authorized(row, task) || row.status === 'cancelled') + throw feedback(foreign ? 'foreign-input' : revoked ? 'source-revoked' : 'uninjected-input', 'read/handled', 'Chief acknowledged an input outside its injected snapshot', !foreign && !revoked) + } + if (!receipts.handled.every(id => receipts.read.includes(id))) throw feedback('handled-not-read', 'handled', 'Chief acknowledged an input outside its injected snapshot: handled must be read') + for (const id of receipts.read) { try { verify(selected.get(id).row) } catch { throw feedback('content-changed', 'sha256', 'Task input content changed after acceptance', false) } } return { taskId, projectId: task.projectId, generation: saved.generation, runId: run.id, receipts: clone(receipts), selected, committed: false } } function commitChiefPlan(plan) { @@ -217,7 +254,7 @@ export function createProjectInputs(rt) { for (const row of pending(task).filter(row => row.kind === 'handoff' && row.source.eventId === handoffId)) { row.status = 'handled'; row.routingReceipt = { chiefDecisionId: decisionId, at: Date.now(), proof: 'validated-chief-routing-decision' } } }, revokeSource(projectId, memberId) { - for (const row of Object.values(rt.state.projectTaskInputs ?? {})) if (row.projectId === projectId && ['human', 'github'].includes(row.source.kind) && row.source.memberId === memberId && row.status === 'accepted') { row.status = 'cancelled'; row.cancelledAt = Date.now(); row.cancellation = 'Source Project membership was revoked' } + for (const row of Object.values(rt.state.projectTaskInputs ?? {})) if (row.projectId === projectId && ['human', 'github'].includes(row.source.kind) && row.source.memberId === memberId) { row.sourceAuthorityRevoked = true; if (row.status === 'accepted') { row.status = 'cancelled'; row.cancelledAt = Date.now(); row.cancellation = 'Source Project membership was revoked' } } }, finishHandoff(taskId, claim) { const task = taskOf(taskId); current(task, claim) @@ -225,12 +262,15 @@ export function createProjectInputs(rt) { }, discardHandoff(id) { const row = ownEntry(rows(), id); if (row?.kind === 'handoff' && !row.deliveries.length) delete rows()[id] }, prepare: (taskId, claim, options) => serial(() => prepare(taskId, claim, options)), transport: (taskId, claim, batch, signal) => serial(() => transport(taskId, claim, batch, signal)), + validateReceipt: (taskId, claim, receipts) => serial(() => { acknowledgementPlan(taskId, claim, receipts); return true }), + validateChiefReceipt: chiefPlan, acknowledge: (taskId, claim, receipts) => serial(() => acknowledge(taskId, claim, receipts)), prepareChief: (snapshot, run) => serial(() => prepareChief(snapshot, run)), acknowledgeChief: (taskId, saved, run, receipts) => serial(() => acknowledgeChief(taskId, saved, run, receipts)), requireHandled, pending(taskId) { return pending(taskOf(taskId)).filter(row => row.kind !== 'handoff').map(row => metadata(row, taskOf(taskId))) } } } export function install(rt) { rt.projectInputs = createProjectInputs(rt) + rt.projectEffects.register('input.receipt', { run: row => ({ ...row.payload, status: 'declared' }) }) rt.projectEffects.register('input.inbox', { run: row => ({ batchId: row.payload.batchId, status: 'durable-inbox' }) }) rt.projectEffects.register('input.notify', { async validate(row) { diff --git a/patches/ds-bot/files/src/host/project-pools.js b/patches/ds-bot/files/src/host/project-pools.js index 172518a1f..9184a974a 100644 --- a/patches/ds-bot/files/src/host/project-pools.js +++ b/patches/ds-bot/files/src/host/project-pools.js @@ -1,4 +1,5 @@ // Per-logical-Bot routing on existing Task records. Execution remains a host adapter. +import { protocolCorrection, freezeReceiptProposal, readReceiptProposal, receiptDigest } from './project-receipt-correction.js' import { randomUUID, createHash } from 'node:crypto' import { ownEntry } from './store.js' import { initialRoleTarget } from './project-role-admission.js' @@ -294,6 +295,7 @@ export function createProjectPools(rt) { const children = action === 'split' ? rt.projectTaskLifecycle.prepareSplit(task, claim, decision) : null const next = action === 'split' ? null : ['complete', 'arbitrate'].includes(action) ? definitions(task.projectId).roles.find(row => row.role === 'chief') : targetOf(task.projectId, decision.to) if (!next && action !== 'split') throw refuse('Chief is not configured') + await rt.projectInputs?.validateReceipt?.(task.id, claim, decision.inputReceipts) // Stop confirmation precedes relinquishing the claim and capacity. if (claim.workerId) { if (!await adapter.release(claim.workerId)) throw refuse('Worker exit is unconfirmed; claim and capacity remain held') @@ -305,6 +307,7 @@ export function createProjectPools(rt) { await rt.projectInputs?.acknowledge(task.id, claim, decision.inputReceipts) await current(task, token) rt.projectInputs?.finishHandoff(task.id, claim) + if (claim.inputCorrection) claim.inputCorrection.status = 'resolved' claim.status = 'completed'; claim.decision = { action, reason, intentHash: intentHash(decision), at: Date.now() } const receipt = c.history.find(row => row.id === c.currentHandoffId) receipt.finishedAt = Date.now(); receipt.decision = clone(claim.decision); receipt.execution = clone(claim) @@ -358,6 +361,51 @@ export function createProjectPools(rt) { return { items: page.map(row => handoffContract(rt.state, task, row)), next: index + 1 + page.length < rows.length ? page.at(-1).id : null } } const owns = botId => Object.values(rt.state.tasks).some(task => task.collaboration?.claim?.workerId === botId || task.collaboration?.history.some(row => row.execution?.workerId === botId)) + async function feedback(task, token, decision, error, responseSha256 = receiptDigest(decision)) { + const claim = await current(task, token), previous = claim.inputCorrection + const proposal = previous?.proposal ?? await freezeReceiptProposal(rt, task, claim, decision) + const correction = protocolCorrection(previous, { version: 'input-correction/v1', limit: previous?.limit ?? rt.config.projectHandoff?.inputCorrectionBudget ?? 2, + feedback: { ...clone(error.receiptFeedback), receiptResponseSha256: responseSha256, at: Date.now(), proposalSha256: proposal.sha256, businessEffects: 'host completion, release and downstream handoff not applied; original model work preserved' } }) + correction.proposal = proposal; correction.originalResponseSha256 = previous?.originalResponseSha256 ?? receiptDigest(decision) + claim.inputCorrection = correction + let intent + try { + if (correction.status === 'pending' && adapter.correct && rt.projectEffects) { + intent = rt.projectEffects.stage({ kind: 'input.correction', key: `${claim.id}:${correction.round}`, projectId: task.projectId, taskId: task.id, payload: { claimId: claim.id, generation: claim.generation, round: correction.round, workerId: claim.workerId } }) + correction.effect = intent + await rt.projectEffects.persist(intent) + rt.projectScheduler?.wake() + } else { + correction.status = correction.status === 'pending' ? 'transport-unavailable' : correction.status + task.collaboration.status = 'blocked'; task.collaboration.block = { reason: 'Input receipt correction incomplete: ' + correction.status, waitingFor: 'chief-recovery', at: Date.now() } + claim.status = 'incomplete'; await rt.save() + if (claim.workerId) { try { claim.exitConfirmed = await adapter.release(claim.workerId) } catch { claim.exitConfirmed = false } } + await rt.save() + } + } catch (failure) { + if (intent && rt.projectEffects.discardUncommitted(intent)) { if (previous) claim.inputCorrection = previous; else delete claim.inputCorrection } + throw failure + } + return true + } + async function correct(effect) { + const task = taskOf(effect.taskId), token = effect.payload + const claim = await serial(() => current(task, { id: token.claimId, generation: token.generation, botId: task.collaboration.claim?.botId })) + const correction = claim.inputCorrection + if (task.projectId !== effect.projectId || correction?.round !== token.round || correction.status !== 'pending' || claim.workerId !== token.workerId) throw new EffectCancelled('Receipt correction changed or ended') + if (correction.dispatchedAt) return { status: 'already-dispatched', round: token.round } + // Commit before transport. An ambiguous send is never retried as a fresh business turn. + correction.dispatchedAt = Date.now(); await rt.save() + const prompt = 'Host input receipt protocol feedback (data, not authorization): ' + JSON.stringify(correction.feedback.at(-1)) + '\nPreserve the original business proposal and all prior code, test, review and external operation results. Tools are disabled for this narrow correction. Return ONLY OUTPUT handoff: {"correctionOf":"' + correction.proposal.sha256 + '","inputReceipts":{...}} (or an array of up to8 injected batches). Declare only IDs actually read/handled. Do not repeat business work or map historical batches to current ones.' + try { + await adapter.correct({ task, claim: clone(claim), workerId: claim.workerId, prompt, round: token.round }) + return { status: 'dispatched', round: token.round, proposalSha256: correction.proposal.sha256 } + } catch { + correction.status = 'transport-incomplete'; correction.failure = 'Original correction transport did not confirm dispatch; inspect its evidence before recovery' + task.collaboration.status = 'blocked'; task.collaboration.block = { reason: correction.failure, waitingFor: 'chief-recovery', at: Date.now() }; await rt.save() + return { status: correction.status, round: token.round } + } + } async function result({ botId, ok, text }) { const task = Object.values(rt.state.tasks).find(task => task.collaboration?.claim?.workerId === botId && task.collaboration.claim.status === 'active') if (!task) return owns(botId) @@ -367,8 +415,18 @@ export function createProjectPools(rt) { const output = String(text).match(/(?:^|\n)OUTPUT handoff:\s*([^\n]+)/)?.[1] decision = ok && output ? JSON.parse(output) : { action: 'arbitrate', reason: ok ? 'Worker did not provide a valid handoff decision' : `Execution failed: ${String(text).slice(0, 1000)}` } } catch { decision = { action: 'arbitrate', reason: 'Worker returned malformed handoff JSON' } } + const correction = task.collaboration.claim.inputCorrection, responseSha256 = receiptDigest(decision) + if (correction?.status === 'pending') { + if (responseSha256 === correction.originalResponseSha256 || correction.feedback.some(row => row.receiptResponseSha256 === responseSha256)) return true + if (!ok || decision?.correctionOf !== correction.proposal.sha256 || Object.keys(decision).some(key => !['correctionOf', 'inputReceipts'].includes(key)) || decision.inputReceipts === undefined) { + return feedback(task, token, decision, { receiptFeedback: { ...correction.feedback.at(-1), version: 'input-receipt-feedback/v1', kind: 'correction-schema', field: 'correctionOf/inputReceipts', correctable: true, message: 'Return only a receipt patch for the exact frozen proposal' } }) + } + decision = { ...await readReceiptProposal(rt, task, token, correction.proposal), inputReceipts: decision.inputReceipts } + } try { await decide(task.id, token, decision) } + catch (error) { + if (error.receiptFeedback?.correctable && task.collaboration.claim?.exitConfirmed !== true) return feedback(task, token, decision, error, responseSha256) const c = task.collaboration; c.status = 'blocked'; c.block = { reason: error.message, at: Date.now() } if (c.claim?.workerId) { c.claim.status = 'blocked'; ++c.generation @@ -544,6 +602,7 @@ export function createProjectPools(rt) { for (const task of Object.values(rt.state.tasks)) { const c = task.collaboration if (!c?.claim || c.claim.status !== 'active') continue + if (c.claim.inputCorrection?.status === 'pending') c.claim.inputCorrection.status = 'interrupted' c.status = 'blocked'; c.claim.status = 'interrupted'; c.block = { reason: 'Host restarted; worker exit and resources must be reconciled before reassigning', at: Date.now() }; ++c.generation; changed = true } if (rt.projectEffects) { @@ -564,11 +623,20 @@ export function createProjectPools(rt) { return changed } return { connect(value) { adapter = value; return () => { if (adapter === value) adapter = null } }, get, list, accept: (taskId, botId, handoffId, requestId) => serial(() => claim(taskId, botId, handoffId, requestId)), admit: (attempt, actor, body) => serial(() => admit(attempt, actor, body)), available, owns, + correct, resumeChildren: (id, waitId) => serial(() => resumeChildren(id, waitId)), claim: (id, botId) => serial(() => claim(id, botId)), remember: (id, token, body) => serial(() => remember(id, token, body)), decide: (id, token, decision) => serial(() => decide(id, token, decision)), result: row => serial(() => result(row)), pump, recover, revokePrincipal: (projectId, memberId) => serial(() => revokePrincipal(projectId, memberId)), revokeExecution: (id, claimId, reason) => serial(() => revokeExecution(id, claimId, reason)), validateClosure, retryChief: (id, actor) => serial(() => retryChief(id, actor)), chiefClaim: (id, actor) => serial(() => chiefClaim(id, actor)), chiefRoute: (body, actor) => serial(() => chiefRoute(body, actor)) } } export function install(rt) { rt.projectPools = createProjectPools(rt) + rt.ctx?.tools?.guard?.(exec => { + const sessionId = exec?.agent?.session?.id ?? exec?.agent?.id + const bot = rt.botOf(rt.selfOf(sessionId) ?? sessionId) + if (!bot) return undefined + const task = Object.values(rt.state.tasks).find(task => task.collaboration?.claim?.workerId === bot.id && task.collaboration?.claim?.inputCorrection && task.collaboration.claim.inputCorrection.status !== 'resolved') + return task ? 'Input receipt correction permits only a declaration patch; business tools are disabled' : undefined + }) + rt.projectEffects?.register('input.correction', { run: effect => rt.projectPools.correct(effect) }) rt.projectEffects?.register('context.memory', { run: effect => ({ ...effect.payload, status: 'memory-version-committed' }) }) rt.projectEffects?.register('pool.dispatch', { async validate(effect, context) { diff --git a/patches/ds-bot/files/src/host/project-receipt-correction.js b/patches/ds-bot/files/src/host/project-receipt-correction.js new file mode 100644 index 000000000..29c04ceee --- /dev/null +++ b/patches/ds-bot/files/src/host/project-receipt-correction.js @@ -0,0 +1,29 @@ +// A bounded protocol correction uses the original claim and frozen business proposal. +// This is feedback transport through project-effects, never another role scheduler. +import { createHash } from 'node:crypto' +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +export const receiptDigest = value => createHash('sha256').update(JSON.stringify(value)).digest('hex') +export function protocolCorrection(previous, { version, limit, feedback, priorExecution = null }) { + if (!Number.isSafeInteger(limit) || limit < 0 || limit > 4) throw new Error('Protocol correction budget must be an integer from0 to4') + const round = (previous?.round ?? 0) + 1 + return { version, round, limit, status: round <= limit ? 'pending' : 'exhausted', feedback: [...(previous?.feedback ?? []), feedback].slice(-5), priorExecution: structuredClone(priorExecution) } +} +export async function freezeReceiptProposal(rt, task, claim, decision) { + const text = JSON.stringify(decision), sha256 = receiptDigest(decision) + if (Buffer.byteLength(text) > 65536 || !rt.home) throw new Error('Receipt proposal exceeds its private recovery budget or archive is unavailable') + const directory = join(rt.home, 'project-artifacts', 'input-corrections', receiptDigest([task.projectId, task.id, claim.id, claim.generation])) + await mkdir(directory, { recursive: true, mode: 0o700 }) + const file = join(directory, sha256 + '.json') + try { await writeFile(file, text, { flag: 'wx', mode: 0o600 }) } + catch (error) { if (error.code !== 'EEXIST' || await readFile(file, 'utf8') !== text) throw error } + return { sha256, bytes: Buffer.byteLength(text) } +} +export async function readReceiptProposal(rt, task, claim, reference) { + const file = join(rt.home, 'project-artifacts', 'input-corrections', receiptDigest([task.projectId, task.id, claim.id, claim.generation]), reference.sha256 + '.json') + const text = await readFile(file, 'utf8') + if (Buffer.byteLength(text) !== reference.bytes || reference.bytes > 65536) throw new Error('Receipt proposal archive changed') + const decision = JSON.parse(text) + if (receiptDigest(decision) !== reference.sha256) throw new Error('Receipt proposal archive hash changed') + return decision +} diff --git a/patches/ds-bot/files/test/project-chief-control.test.js b/patches/ds-bot/files/test/project-chief-control.test.js index 279054444..1e7e25aec 100644 --- a/patches/ds-bot/files/test/project-chief-control.test.js +++ b/patches/ds-bot/files/test/project-chief-control.test.js @@ -44,12 +44,17 @@ test('failed schedule configuration does not silently enable autonomous Chief au assert.equal(control.template.when.seconds, 1800) }) -async function controlFixture({ modern = false, dependency = false } = {}) { +async function controlFixture({ modern = false, dependency = false, withInputs = false } = {}) { const { createProjectPools } = await import('../src/host/project-pools.js') const task = { id: 't', projectId: 'p', title: 'Fix code', criteria: 'Exact candidate passes', status: 'result-ready', deliveryAttemptId: 'a', collaboration: { initiatedBy: 'owner', kind: 'code', generation: 1, status: 'arbitration', botId: 'p:chief', attemptId: 'a', currentHandoffId: 'receipt', history: [{ id: 'receipt', status: 'pending', to: 'p:chief', createdAt: 1, evidence: [] }] } } let request, accepted = 0, permitted = true const roles = ['chief', 'developer', 'tester', 'reviewer'].map(role => ({ id: `p:${role}`, role })) const rt = { state: { tasks: { t: task }, projectExecutions: {}, projectAttempts: { a: { id: 'a', projectId: 'p', taskId: 't', candidateSha: 'candidate', evidence: {} } }, projectChiefPolicies: { p: { enabled: true, authorityMemberId: 'owner', revision: 1, inspection: { thresholds, prompt: 'Inspect evidence' } } }, projectAgents: { 'public-p': { id: 'public-p', projectId: 'p', visibility: 'public', botId: 'chief', sessionId: 'chief', carrierGeneration: 1 } } }, config: {}, save: async () => {}, projects: { requireActive() {} }, requireProjectAccess(actor, id) { if (!permitted || actor.memberId !== 'owner' || id !== 'p') throw new Error('Current role denied') }, projectDelegations: { background: async () => true }, bindProject(body) { rt.state.projectExecutions[body.id] = { ...body }; return { id: body.projectId, status: 'active' } }, projectAgents: { deliveryDefinitions: () => ({ roles }), chiefBusy: () => false, startChiefJob: async args => { request = args; request.capturedInvocation = rt.state.projectExecutions[args.runId].invocation; return 'native-job' } }, projectDelivery: { chiefAccept: async () => { accepted++; task.status = 'accepted' } } } + if (withInputs) { + const { createProjectEffects } = await import('../src/host/project-effects.js'), { install } = await import('../src/host/project-inputs.js') + rt.projectEffects = createProjectEffects(rt); rt.save = async () => rt.projectEffects.saved(rt.projectEffects.capture()); install(rt) + await rt.projectInputs.publish({ projectId: 'p', taskId: 't', requestId: 'receipt-fixture', text: 'Verify the accepted behavior' }, { memberId: 'owner' }) + } let control = createProjectChiefControl(rt); rt.projectChiefControl = control; rt.projectPools = createProjectPools(rt) if (modern) { const { createProjectEffects } = await import('../src/host/project-effects.js') @@ -440,3 +445,22 @@ test('scheduled reminder coalescing and policy changes never re-authorize stale const reference = await f.control.scheduled({ row: { id: 's', revision: 99, projectId: 'p', config: { prompt: 'Changed' } }, delivery: { id: 'old' } }) assert.equal(reference.runId, a.id) }) + + +test('Chief receipt protocol correction precedes all role proposals and Task effects', async () => { + const f = await controlFixture({ withInputs: true }), run = f.rt.state.projectExecutions[f.request.runId] + let proposals = 0; f.rt.projectAgents.proposeRoleForJob = async () => { ++proposals; return { proposal: { id: 'proposal' } } } + const initial = f.request, body = JSON.parse(f.result({ inputReceipts: { batchId: 'typo', read: [], handled: [] } }).text) + body.roleProposals = [{ action: 'add', role: 'designer', reason: 'Review UI' }] + await initial.onResult({ ...f.result(), text: JSON.stringify(body) }) + assert.equal(run.status, 'correction-pending', run.failure); assert.equal(proposals, 0); assert.equal(f.accepted(), 0) + assert.equal(run.correction.feedback[0].receiptFeedback.kind, 'snapshot-schema') + await f.control.pump() + assert.equal(f.request.projectId, initial.projectId) + assert.match(f.request.prompt, /inputReceipts/) + const batch = f.request.projectFacts.tasks[0].taskInbox + await f.request.onResult(f.result({ inputReceipts: { batchId: batch.batchId, read: batch.inputs.map(row => row.id), handled: batch.inputs.map(row => row.id) } })) + assert.equal(run.status, 'done', run.failure); assert.equal(f.accepted(), 1) + assert.equal(run.correction.status, 'resolved'); assert.equal(proposals, 0) + await f.rt.projectEffects.close() +}) diff --git a/patches/ds-bot/files/test/project-codex-config.test.js b/patches/ds-bot/files/test/project-codex-config.test.js index 89c3fe4b7..97b7008e1 100644 --- a/patches/ds-bot/files/test/project-codex-config.test.js +++ b/patches/ds-bot/files/test/project-codex-config.test.js @@ -107,3 +107,29 @@ if (process.env.FLYBOT_TEST_CODEX_SDK_URL && process.env.FLYBOT_TEST_CODEX_EXECU assert.equal(events.filter(row => row.type === 'configuration-checked').length, 2) } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(root, { recursive: true, force: true }) } }) + +if (process.env.FLYBOT_TEST_CODEX_SDK_URL && process.env.FLYBOT_TEST_CODEX_EXECUTABLE) test('actual SDK receipt-only correction resumes its thread with shell disabled and retained business history', async () => { + const root = await mkdtemp(join(tmpdir(), 'flybot-sdk-receipt-')) + let count = 0, historySeen = false, shellDisabled = false + const home = join(root, 'home'), cwd = join(root, 'candidate'), scratch = join(root, 'scratch') + const server = createServer(async (request, response) => { + let raw = ''; for await (const chunk of request) raw += chunk + const body = JSON.parse(raw); ++count + if (count === 2) { historySeen = raw.includes('receipt-original-business-result'); shellDisabled = !(body.tools ?? []).some(tool => tool.name === 'exec_command' || tool.name === 'shell' || tool.name === 'shell_command') } + const item = { id: 'message_' + count, type: 'message', role: 'assistant', content: [{ type: 'output_text', text: count === 1 ? 'receipt-original-business-result' : 'OUTPUT handoff: {"correctionOf":"original","inputReceipts":[]}' }] } + response.writeHead(200, { 'content-type': 'text/event-stream' }) + for (const event of [{ type: 'response.output_item.done', output_index: 0, item }, { type: 'response.completed', response: { id: 'response_' + count, status: 'completed', output: [item], usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 } } }]) response.write('data: ' + JSON.stringify(event) + '\n\n') + response.end() + }) + await new Promise(resolve => server.listen(0, '127.0.0.1', resolve)) + try { + await Promise.all([home, cwd, scratch].map(path => mkdir(path))); await exec('git', ['init', '-q', cwd]); await writeFile(join(cwd, 'candidate.txt'), 'original-work') + const executable = process.env.FLYBOT_TEST_CODEX_EXECUTABLE, sdkUrl = process.env.FLYBOT_TEST_CODEX_SDK_URL + const request = { harness: 'codex', cwd, model: 'fixture-model', prompt: 'Return the original business result', readOnly: false, scratchDirectory: scratch, env: { PATH: process.env.PATH, HOME: home, CODEX_HOME: home, TMPDIR: scratch }, config: { model_provider: 'fixture', model_providers: { fixture: { name: 'fixture', base_url: `http://127.0.0.1:${server.address().port}`, wire_api: 'responses', requires_openai_auth: false } } } } + const ports = { signal: AbortSignal.timeout(30_000), emit() {}, loadCodex: () => import(sdkUrl), validateCodex: (request, options) => validateCodexConfiguration(request, options, { executable }) } + const original = await runHarnessTurn(request, ports) + const corrected = await runHarnessTurn({ ...request, sessionId: original.sessionId, protocolCorrection: true, prompt: 'Correct only the input receipt; preserve prior work' }, ports) + assert.equal(corrected.sessionId, original.sessionId); assert.equal(historySeen, true); assert.equal(shellDisabled, true); assert.equal(count, 2) + assert.match(corrected.text, /correctionOf/); assert.equal(await readFile(join(cwd, 'candidate.txt'), 'utf8'), 'original-work') + } finally { server.closeAllConnections(); await new Promise(resolve => server.close(resolve)); await rm(root, { recursive: true, force: true }) } +}) diff --git a/patches/ds-bot/files/test/project-harness-turn.test.js b/patches/ds-bot/files/test/project-harness-turn.test.js index f2740451a..aee311d79 100644 --- a/patches/ds-bot/files/test/project-harness-turn.test.js +++ b/patches/ds-bot/files/test/project-harness-turn.test.js @@ -121,3 +121,18 @@ test('Codex tool history has a host-selected limit without replacing SDK compact assert.equal(observed[1].id, 'kept-thread') for (const limit of [0, -1, 1.5, '1536']) await assert.rejects(runHarnessTurn({ ...request, toolOutputTokens: limit }, { loadCodex: async () => { throw new Error('Must validate before loading SDK') } }), /output budget/) }) + +test('receipt correction resumes the original SDK Session without tools or changing its role filesystem profile', async () => { + const observed = [] + await runHarnessTurn({ ...request, sessionId: 'original', protocolCorrection: true }, { emit() {}, loadCodex: codex([{ type: 'thread.started', thread_id: 'original' }, { type: 'turn.completed' }], value => observed.push(value)) }) + assert.equal(observed[1].id, 'original'); assert.equal(observed[0].config.features.shell_tool, false) + assert.equal(observed[0].config.permissions['flybot-role'].extends, ':workspace') + assert.equal(observed[1].sandboxMode, undefined) + assert.equal(observed[0].config.hooks.PreToolUse[0].hooks[0].type, 'command') + let asked = false + await runHarnessTurn({ ...request, harness: 'claude-agent', sessionId: 'original', protocolCorrection: true }, { emit() {}, permission: () => { asked = true }, loadClaude: async () => ({ query({ options }) { + assert.deepEqual(options.tools, []); assert.equal(options.resume, 'original') + return (async function* () { assert.equal((await options.canUseTool('Bash', {}, {})).behavior, 'deny'); yield { type: 'result', subtype: 'success', session_id: 'original', result: 'receipt patch' } })() + } }) }) + assert.equal(asked, false) +}) diff --git a/patches/ds-bot/files/test/project-harness.test.js b/patches/ds-bot/files/test/project-harness.test.js index 55b792fc0..d53fcdb70 100644 --- a/patches/ds-bot/files/test/project-harness.test.js +++ b/patches/ds-bot/files/test/project-harness.test.js @@ -692,3 +692,32 @@ test('registered SDK failure before spawn rolls back capacity with a verifiable assert.equal(confirmedWorkerExit(rt.state, row), true) assert.equal(rt.state.projectAttempts, undefined, 'No Task history is rewritten by resource cleanup') }) + +test('SDK receipt correction keeps its physical Session and sealed execution, and discards late prior completions', async t => { + const { rt, harness, task, id } = await taskInputFixture(t, ` +import{createInterface}from'node:readline';const write=v=>process.stdout.write(JSON.stringify(v)+'\\n');write({type:'ready',receiptCorrection:'input-correction/v1',sdk:{codex:'0.166.0'}}); +createInterface({input:process.stdin}).on('line',line=>{const m=JSON.parse(line);if(m.type==='stop'){write({type:'stopped',released:true});setTimeout(()=>process.exit(0),20);return;} +if(m.type==='start'){write({type:'sdk-event',event:{type:'thread.started',thread_id:'original-thread'}});write({type:'completed',sessionId:'original-thread',text:'OUTPUT handoff: {"action":"arbitrate","reason":"Original business result"}'});} +if(m.type==='input'){if(!m.protocolCorrection||!m.turnId)throw Error('Expected narrow correction');write({type:'completed',sessionId:'original-thread',text:'OUTPUT handoff: {"action":"complete","reason":"Late original result"}'});write({type:'completed',turnId:m.turnId,sessionId:'original-thread',text:'OUTPUT handoff: {"correctionOf":"proposal","inputReceipts":[]}'});} +});`) + const claim = task.collaboration.claim; claim.attemptId = 'a'; claim.inputCorrection = { status: 'pending', round: 1 } + let executions = 0, results = 0, originalHash, originalLog + rt.projectDelivery.recordExecution = async () => { ++executions } + let finish, fail + const done = new Promise((resolve, reject) => { finish = resolve; fail = reject }) + harness.onResult(async result => { + try { + ++results + if (results === 1) { + originalHash = rt.state.projectHarnessWorkers[id].resultHash; originalLog = await readFile(rt.state.projectHarnessWorkers[id].sealedLog, 'utf8') + await harness.correctInput({ workerId: id, task, claim, prompt: 'Correct only the receipt', round: 1 }) + } else finish(result) + } catch (error) { fail(error) } + }) + await harness.dispatch({ botId: id, taskId: 't', brief: 'Current work', outputs: [{ name: 'handoff' }] }) + const result = await done, worker = rt.state.projectHarnessWorkers[id] + assert.equal(result.ok, true); assert.match(result.text, /correctionOf/) + assert.equal(results, 2); assert.equal(executions, 1); assert.equal(worker.backendSessionId, 'original-thread') + assert.equal(worker.resultHash, originalHash); assert.equal(await readFile(worker.sealedLog, 'utf8'), originalLog) + assert.equal(worker.inputCorrectionResult.turnId, 'receipt-idle-claim-1') +}) diff --git a/patches/ds-bot/files/test/project-inputs.test.js b/patches/ds-bot/files/test/project-inputs.test.js index f77c56a51..cddfd3918 100644 --- a/patches/ds-bot/files/test/project-inputs.test.js +++ b/patches/ds-bot/files/test/project-inputs.test.js @@ -240,3 +240,51 @@ test('a closing Effect engine cannot leave a phantom admitted input', async () = await assert.rejects(f.rt.projectInputs.publish(input(),actor),/not writable/) assert.equal(Object.keys(f.rt.state.projectTaskInputs).length,0) }) + +test('receipt preflight exposes only current manifests and save failure never consumes a declaration', async () => { + const f = fixture(); await f.rt.projectInputs.publish(input(), actor) + const batch = await f.rt.projectInputs.prepare('t', f.claim), row = Object.values(f.rt.state.projectTaskInputs)[0] + const good = { batchId: batch.batchId, read: [row.id], handled: [row.id] } + for (const [receipt, kind] of [[{ ...good, batchId: 'typo' }, 'unknown-batch'], [{ ...good, read: [] }, 'handled-not-read'], [{ ...good, read: ['unseen'], handled: [] }, 'uninjected-input']]) { + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, receipt), error => { + assert.equal(error.receiptFeedback.kind, kind); assert.equal(error.receiptFeedback.correctable, true) + assert.deepEqual(error.receiptFeedback.batches[0], { batchId: batch.batchId, inputs: [{ id: row.id, sha256: row.sha256 }] }) + assert.ok(!JSON.stringify(error.receiptFeedback).includes(row.text)); return true + }) + } + assert.equal(row.status, 'accepted'); assert.equal(row.deliveries[0].readAt, undefined) + await f.rt.projectInputs.validateReceipt('t', f.claim, good) + f.fail(true); await assert.rejects(f.rt.projectInputs.acknowledge('t', f.claim, good), /disk failed/) + assert.equal(row.status, 'accepted'); assert.equal(row.deliveries[0].readAt, undefined) + f.fail(false); await f.rt.projectInputs.acknowledge('t', f.claim, good) + assert.equal(row.status, 'handled') + await f.rt.projectInputs.acknowledge('t', f.claim, good) + assert.equal(row.status, 'handled') +}) + +test('foreign inputs, historical batches, revoked sources and changed bodies never become correctable grants', async () => { + const f = fixture(); await f.rt.projectInputs.publish(input(), actor) + const batch = await f.rt.projectInputs.prepare('t', f.claim), row = Object.values(f.rt.state.projectTaskInputs)[0] + f.rt.state.projectTaskInputs.foreign = { ...structuredClone(row), id: 'foreign', taskId: 'other', text: 'foreign private content' } + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, { batchId: batch.batchId, read: ['foreign'], handled: [] }), error => error.receiptFeedback.kind === 'foreign-input' && !error.receiptFeedback.correctable && error.receiptFeedback.batches.length === 0) + const good = { batchId: batch.batchId, read: [row.id], handled: [] } + row.text += 'tampered' + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, good), error => error.receiptFeedback.kind === 'content-changed' && !error.receiptFeedback.correctable) + row.text = input().text; f.rt.projectInputs.revokeSource('p', 'owner') + await assert.rejects(f.rt.projectInputs.validateReceipt('t', f.claim, good), error => error.receiptFeedback.kind === 'source-revoked' && !error.receiptFeedback.correctable) + const old = structuredClone(f.claim); f.rt.state.tasks.t.collaboration.claim = { ...old, id: 'new' } + await assert.rejects(f.rt.projectInputs.validateReceipt('t', old, good), error => error.receiptFeedback.kind === 'stale-claim' && !error.receiptFeedback.correctable) +}) + +test('revocation while a receipt save fails cannot resurrect the old input on rollback or later regrant', async () => { + const f = fixture(); await f.rt.projectInputs.publish(input(), actor) + const batch = await f.rt.projectInputs.prepare('t', f.claim), row = Object.values(f.rt.state.projectTaskInputs)[0] + const receipt = { batchId: batch.batchId, read: [row.id], handled: [row.id] } + const save = f.rt.save + f.rt.save = async () => { f.rt.projectInputs.revokeSource('p', 'owner'); throw new Error('disk failed during revocation') } + await assert.rejects(f.rt.projectInputs.acknowledge('t', f.claim, receipt), /disk failed/) + assert.equal(row.status, 'cancelled'); assert.equal(row.deliveries[0].readAt, undefined) + f.rt.save = save + await assert.rejects(f.rt.projectInputs.acknowledge('t', f.claim, receipt), error => error.receiptFeedback.kind === 'source-revoked' && !error.receiptFeedback.correctable) + assert.equal(f.rt.projectInputs.pending('t').length, 0) +}) diff --git a/patches/ds-bot/files/test/project-pools.test.js b/patches/ds-bot/files/test/project-pools.test.js index 9ca157a82..82499f8f4 100644 --- a/patches/ds-bot/files/test/project-pools.test.js +++ b/patches/ds-bot/files/test/project-pools.test.js @@ -633,3 +633,71 @@ test('resumed pool claims cannot bypass project admission or spend a claim gener assert.equal(claim.status, 'active'); assert.equal(checks, 2) await assert.rejects(f.pool.decide('task', { ...claim, generation: generation - 1 }, { action: 'complete', reason: 'late callback' }), /stale/) }) + +for (const role of ['developer', 'tester', 'reviewer']) test(role + ' invalid receipt returns to the same claim before release or host completion, then applies the frozen proposal once', async t => { + const { mkdtemp, rm } = await import('node:fs/promises'), { tmpdir } = await import('node:os'), { join } = await import('node:path') + const { createProjectEffects } = await import('../src/host/project-effects.js'), { install: inputs } = await import('../src/host/project-inputs.js') + const f = setup(); f.rt.home = await mkdtemp(join(tmpdir(), 'flybot-receipt-')) + t.after(() => rm(f.rt.home, { recursive: true, force: true })) + f.rt.projectEffects = createProjectEffects(f.rt); f.rt.save = async () => f.rt.projectEffects.saved(f.rt.projectEffects.capture()) + for (const kind of ['pool.dispatch', 'context.summary', 'input.correction']) f.rt.projectEffects.register(kind, { run: () => ({}) }) + inputs(f.rt) + await f.admit() + f.task.collaboration.botId = 'p:' + role; f.task.collaboration.history[0].to = 'p:' + role + const claim = await f.pool.claim('task', 'p:' + role); f.task.collaboration.claim.workerId = 'original-worker' + const batch = await f.rt.projectInputs.prepare('task', claim), id = batch.inputs[0].id + let releases = 0, correctionPrompt + f.adapter.release = async () => { releases++; return true } + f.adapter.correct = async args => { correctionPrompt = args } + const original = { action: 'handoff', to: 'p:chief', reason: 'Actual work complete', outputs: { candidate: 'original evidence' }, inputReceipts: { batchId: 'typo', read: [id], handled: [id] } } + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(original) }) + assert.equal(releases, 0, 'Invalid receipt must not release the original worker'); assert.equal(f.completed.length, 0, 'Invalid receipt must not run host completion') + const state = f.task.collaboration.claim.inputCorrection + assert.equal(state.status, 'pending') + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(original) }) + assert.equal(state.round, 1, 'duplicate original completion consumes no correction round') + await f.pool.correct(Object.values(f.rt.state.projectEffects).find(row => row.kind === 'input.correction')) + assert.equal(correctionPrompt.workerId, 'original-worker'); assert.equal(correctionPrompt.claim.id, claim.id) + assert.match(correctionPrompt.prompt, /Tools are disabled/) + if (role === 'reviewer') { + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify({ correctionOf: state.proposal.sha256, inputReceipts: { batchId: 'still-wrong', read: [id], handled: [] } }) }) + assert.equal(f.task.collaboration.claim.inputCorrection.round, 2) + } + const patch = { correctionOf: state.proposal.sha256, inputReceipts: { batchId: batch.batchId, read: [id], handled: [id] } } + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(patch) }) + assert.equal(f.completed.length, 1); assert.deepEqual(f.completed[0].outputs, original.outputs) + assert.equal(releases, 1); assert.equal(f.task.collaboration.history[0].execution.inputCorrection.status, 'resolved') + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify(patch) }) + assert.equal(f.completed.length, 1) +}) + +test('receipt correction budget exhaustion preserves original context and leaves Chief judgement without creating a consultation', async t => { + const { mkdtemp, rm } = await import('node:fs/promises'), { tmpdir } = await import('node:os'), { join } = await import('node:path') + const f = setup(); f.rt.home = await mkdtemp(join(tmpdir(), 'flybot-receipt-exhausted-')) + t.after(() => rm(f.rt.home, { recursive: true, force: true })) + f.rt.config.projectHandoff = { inputCorrectionBudget: 0 } + await f.admit(); const claim = await f.claim(); f.task.collaboration.claim.workerId = 'original-worker' + f.rt.projectInputs = { validateReceipt() { throw Object.assign(new Error('Wrong receipt'), { receiptFeedback: { kind: 'unknown-batch', field: 'batchId', correctable: true } }) } } + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: ' + JSON.stringify({ action: 'handoff', to: 'p:tester', reason: 'Actual work done', inputReceipts: {} }) }) + assert.equal(f.task.collaboration.claim.id, claim.id); assert.equal(f.task.collaboration.claim.inputCorrection.status, 'exhausted') + assert.equal(f.task.collaboration.status, 'blocked'); assert.equal(f.task.collaboration.block.waitingFor, 'chief-recovery') + assert.equal(f.task.chiefQuestion, undefined); assert.equal(f.completed.length, 0) + await f.pool.result({ botId: 'original-worker', ok: true, text: 'OUTPUT handoff: {}' }) + assert.equal(f.task.collaboration.claim.inputCorrection.round, 1, 'late output cannot revive exhausted execution') + assert.equal(f.task.collaboration.history.length, 1) +}) + +test('native receipt correction guards business tools in the original Session without affecting unrelated personal Bots', async () => { + const { install } = await import('../src/host/project-pools.js') + let guard + const claim = { workerId: 'worker', inputCorrection: { status: 'pending' } } + const rt = { state: { tasks: { legacy: { id: 'legacy' }, t: { collaboration: { claim } } } }, ctx: { tools: { guard(fn) { guard = fn } } }, selfOf: id => id === 'original-session' ? 'worker' : id, botOf: id => ['worker', 'personal'].includes(id) ? { id } : null } + install(rt) + assert.match(guard({ name: 'exec_command', agent: { session: { id: 'original-session' } } }), /declaration patch/) + assert.equal(guard({ name: 'exec_command', agent: { id: 'personal' } }), undefined) + assert.equal(guard({ name: 'exec_command', agent: {} }), undefined) + claim.inputCorrection.status = 'exhausted' + assert.match(guard({ agent: { id: 'worker' } }), /declaration patch/) + claim.inputCorrection.status = 'resolved' + assert.equal(guard({ agent: { id: 'worker' } }), undefined) +}) diff --git a/scripts/api-contracts.mjs b/scripts/api-contracts.mjs index 62d64f853..9570382f7 100644 --- a/scripts/api-contracts.mjs +++ b/scripts/api-contracts.mjs @@ -348,3 +348,7 @@ schemas.Attempt.properties.evidence = record({ tests: ref('TestVerification'), e schemas.TaskRoleRestoration = record({ version: lit('task-role-cwd-restoration/v1'), mode: choice('resumed', 'reconstructed'), logicalSessionId: id, contextIdentityKey: hash, sourceWorkerId: id, sourceSessionId: id, physicalSessionId: id, sourceAttemptId: id, attemptId: id, workspaceId: id, checkout: sha, observedCheckout: sha, archiveSha256: hash, at: int() }) schemas.ExecutionMetadata = record({ id, logicalSessionId: id, role, harness: nullable(harness), status: str(), startupPhase: nullable(str()), contextRestoration: nullable(ref('TaskRoleRestoration')), contextFailure: nullable(json), exitConfirmed: bool, capabilityFailure: nullable(json), environmentObservations: array(json, 8) }) schemas.TaskView.properties.execution = nullable(ref('ExecutionMetadata')) + +// Receipt correction is an internal role protocol, observable through existing Task views. +schemas.InputCorrection = record({ version: lit('input-correction/v1'), round: int(1, 5), limit: int(0, 4), status: choice('pending', 'resolved', 'exhausted', 'transport-unavailable', 'transport-incomplete', 'interrupted'), proposalSha256: nullable(hash), feedback: array(record({ kind: str(), field: str(), correctable: bool, sideEffects: str(), businessEffects: str() }), 5) }, ['version', 'round', 'limit', 'status']) +schemas.ExecutionMetadata.properties.inputCorrection = ref('InputCorrection')