diff --git a/.github/scripts/backend-docs.test.mjs b/.github/scripts/backend-docs.test.mjs index ebc3adf19..208fede67 100644 --- a/.github/scripts/backend-docs.test.mjs +++ b/.github/scripts/backend-docs.test.mjs @@ -19,8 +19,8 @@ test('public API tables enumerate the actual complete command/query contracts', const commandText = api.split('## v1命令')[1].split('## Execution')[0] assert.deepEqual(rows(queryText), [...queries].sort()) assert.deepEqual(rows(commandText), operations.filter(name => !queries.includes(name)).sort()) - assert.equal(queries.length, 29); assert.equal(rows(commandText).length, 34) - for (const name of ['README.md', 'README.zh.md']) assert.match(read(name), /29查询\/34命令/) + assert.equal(queries.length, 29); assert.equal(rows(commandText).length, 35) + for (const name of ['README.md', 'README.zh.md']) assert.match(read(name), /29查询\/35命令/) }) test('quickstart JSON, shell syntax and local documentation links stay usable', () => { diff --git a/PROJECT.md b/PROJECT.md index 566a7f810..479100752 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,5 +1,8 @@ # FlyBot — Project State + +#2375 adds explicit exit-proven claim retirement through existing pools/Effects; preserve drafts, pending results and unknown writes. No cancellation or automatic routing. + The single page that says what this project is, who decides what, and what automation must never do. Operational rules live in [AGENTS.md](AGENTS.md) and [CONTRIBUTING.md](CONTRIBUTING.md); architecture lives in [docs/architecture.md](docs/architecture.md) and diff --git a/README.md b/README.md index d22e828df..3289c65f6 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, | 需要 | 文档 | | --- | --- | -| 对象、权限、全部29查询/34命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | +| 对象、权限、全部29查询/35命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | | Chief/角色/Task/Handoff、咨询、个人代理、安全与值守、状态与上下文 | [协作指南](docs/development-service-guide.md) | | 数据目录、账号平台、模型/SDK/仓库、停止重启、备份升级、排障 | [部署运维](docs/backend-operations.md) | | 个人Chief、多Bot、群聊、公共Bot、原版安装与桌面功能 | [原版FlyBot](README.native.zh.md) · [English](README.native.md) | diff --git a/README.zh.md b/README.zh.md index 09c31f593..78577ad84 100644 --- a/README.zh.md +++ b/README.zh.md @@ -81,7 +81,7 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, | 需要 | 文档 | | --- | --- | -| 对象、权限、全部29查询/34命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | +| 对象、权限、全部29查询/35命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | | Chief/角色/Task/Handoff、咨询、个人代理、安全与值守、状态与上下文 | [协作指南](docs/development-service-guide.md) | | 数据目录、账号平台、模型/SDK/仓库、停止重启、备份升级、排障 | [部署运维](docs/backend-operations.md) | | 个人Chief、多Bot、群聊、公共Bot、原版安装与桌面功能 | [原版FlyBot](README.native.zh.md) · [English](README.native.md) | diff --git a/ROADMAP.md b/ROADMAP.md index 24ca1c21e..2781545d3 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,8 @@ # FlyBot — Roadmap + +#2375: retire proven exited responsibility before fresh integration admission; source/isolated tests remain separate from live recovery. + ## Resource authentication reliability — 2026-11-04 #2398: existing profile binding, correct SDK auth feedback and same-profile setup; diff --git a/changelog.d/2375.fixed.md b/changelog.d/2375.fixed.md new file mode 100644 index 000000000..5ddf2f5b0 --- /dev/null +++ b/changelog.d/2375.fixed.md @@ -0,0 +1 @@ +Add explicit, durable retirement of exit-proven execution claims before fresh integration admission, preserving drafts and rejecting stale or unresolved execution. diff --git a/core/src/host/project-chief-control.js b/core/src/host/project-chief-control.js index 012acaf5d..72577182a 100644 --- a/core/src/host/project-chief-control.js +++ b/core/src/host/project-chief-control.js @@ -466,7 +466,7 @@ export function createProjectChiefControl(rt) { for (const run of Object.values(rt.state.projectExecutions ?? {})) if (run.kind === 'chief-control' && run.status === 'running') { run.status = 'interrupted'; run.waitingFor = 'recovery'; run.recoveryIssue = { version: 'chief-recovery/v1', kind: 'host-recovering', retryable: true, conditions: ['prior execution exit reconciliation'], actions: ['wait for the existing recovery pass'] }; run.failure = 'Host restarted before Chief result was validated'; changed = true } return changed } - return { configure, view, request, scheduled, scheduledState, pump, recover, requireJobActor, validateActor, applyDecision, template: structuredClone(template) } + return { exitConfirmed: inspectionExited, configure, view, request, scheduled, scheduledState, pump, recover, requireJobActor, validateActor, applyDecision, template: structuredClone(template) } } export function install(rt) { rt.projectChiefControl = createProjectChiefControl(rt) diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index 59f8513dd..b2db78610 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -456,15 +456,52 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (attempt.protocol !== 'project-handoff/v1') throw reject('Integration input admission requires Project Bot pools') return { attempt, task } } - function integrationReleased(attempt, permit) { - if (permit?.[childPermit]) { childInputs.assertProof(attempt.taskId, permit.proof.claim, permit.proof); return } + function integrationBlockers(attempt, permit, retiringClaim) { const chief = row => row?.kind === 'chief-control' && row.projectId === attempt.projectId - const claim = taskOf(attempt.taskId)?.collaboration?.claim - const ownChiefRun = permit?.[chiefInputPermit] - // Old released Chief turns never owned this workspace. The current Chief - // claim and any unreleased Project Chief still require exact exit proof. - const currentWorker = row => !(ownChiefRun && row.role === 'chief' && row.chiefRunId === ownChiefRun) && (row.attemptId === attempt.id || row.projectId === attempt.projectId && row.chiefRunId && (row.status !== 'released' || row.chiefRunId === claim?.chiefRunId)) - if (claim?.status === 'active' && !(ownChiefRun && claim.chiefRunId === ownChiefRun && claim.botId === `${attempt.projectId}:chief`) || Object.values(state.projectExecutions ?? {}).some(row => chief(row) && row.status === 'running' && row.id !== ownChiefRun) || Object.values(state.projectResourceLeases ?? {}).some(row => (row.attemptId === attempt.id || chief(state.projectExecutions?.[row.attemptId])) && row.status !== 'released') || Object.values(state.projectHarnessWorkers ?? {}).some(row => currentWorker(row) && !confirmedWorkerExit(state, row)) || Object.values(state.bots ?? {}).some(row => row.ephemeral && row.attemptId === attempt.id)) throw reject('Integration input is waiting for confirmed execution exit') + const claim = taskOf(attempt.taskId)?.collaboration?.claim, ownChiefRun = permit?.[chiefInputPermit] + const blockers = [] + if (claim?.status === 'active' && claim !== retiringClaim && !(ownChiefRun && claim.chiefRunId === ownChiefRun && claim.botId === `${attempt.projectId}:chief`)) blockers.push({ kind: 'claim-active', claimId: claim.id, action: 'attempt.reconcile-exit after result and exit reconciliation' }) + if (claim?.retirement && !rt.projectEffects?.intentCommitted(claim.retirement.effect)) blockers.push({ kind: 'claim-retirement-uncommitted', claimId: claim.id }) + for (const row of Object.values(state.projectExecutions ?? {})) if (chief(row) && row.status === 'running' && row.id !== ownChiefRun) blockers.push({ kind: 'chief-running', runId: row.id }) + for (const row of Object.values(state.projectResourceLeases ?? {})) if ((row.attemptId === attempt.id || chief(state.projectExecutions?.[row.attemptId])) && (row.status !== 'released' || row.preparation && row.preparation.exitConfirmed === false)) blockers.push({ kind: row.preparation?.exitConfirmed === false ? 'preparation-exit-unknown' : 'lease-not-released', leaseId: row.id }) + // Old released Chief turns never owned this workspace. Unknown current workers stay held. + for (const row of Object.values(state.projectHarnessWorkers ?? {})) { + const relevant = !(ownChiefRun && row.role === 'chief' && row.chiefRunId === ownChiefRun) && (row.attemptId === attempt.id || row.projectId === attempt.projectId && row.chiefRunId && (row.status !== 'released' || row.chiefRunId === claim?.chiefRunId)) + if (relevant && !confirmedWorkerExit(state, row)) blockers.push({ kind: 'worker-exit-unknown', workerId: row.id }) + } + for (const row of Object.values(state.bots ?? {})) if (row.ephemeral && row.attemptId === attempt.id) blockers.push({ kind: 'native-worker-present', workerId: row.id }) + return blockers + } + function integrationReleased(attempt, permit, retiringClaim) { + if (permit?.[childPermit]) { childInputs.assertProof(attempt.taskId, permit.proof.claim, permit.proof); return } + const blockers = integrationBlockers(attempt, permit, retiringClaim) + if (blockers.length) throw Object.assign(reject(`Integration input is waiting for confirmed execution exit: ${blockers.map(row => row.kind).join(', ')}`), { details: { kind: 'execution-checkpoint', attemptId: attempt.id, blockers } }) + } + async function reconcileExit(body, actor) { + const { attempt, task } = integrationAttempt(body, actor) + if (!['running', 'interrupted', 'failed'].includes(attempt.status) || attempt.cancelRequested || ['accepted', 'cancelled'].includes(task.status)) throw reject('Exit retirement cannot revive a cancelled or completed Attempt') + return rt.projectPools.retireExited(task.id, body, actor, async (currentTask, claim) => { + await rt.projectDelegations?.background(actor.memberId) + if (claim.attemptId !== attempt.id || claim.taskId !== task.id || claim.projectId !== task.projectId || task.deliveryAttemptId !== attempt.id) throw reject('Execution retirement requires the exact current Task/Attempt claim') + const worker = ownEntry(state.projectHarnessWorkers ?? {}, claim.workerId), native = ownEntry(state.projectNativeWorkers ?? {}, claim.workerId) + let proof + if (claim.chiefRunId) { + const run = ownEntry(state.projectExecutions ?? {}, claim.chiefRunId) + if (!run || run.projectId !== task.projectId || run.status === 'running' || !await rt.projectChiefControl.exitConfirmed(run)) throw reject('Chief retirement is waiting for confirmed execution exit') + proof = { chiefRunId: run.id, invocation: run.invocation, exitProof: run.exitProof ?? run.recovery?.proof ?? null } + } else { + const record = worker ?? native, lease = record && ownEntry(state.projectResourceLeases ?? {}, record.leaseId) + if (!record || record.id !== claim.workerId || record.projectId !== task.projectId || record.taskId !== task.id || record.attemptId !== attempt.id || ![attempt.roleSessions?.[record.role], ...(attempt.roleSessionHistory ?? [])].some(session => session?.id === record.sessionKey && session.poolClaimId === claim.id && session.generation === claim.generation) || !executionExitConfirmed(rt, record.id)) throw reject('Claim retirement is waiting for matching confirmed execution exit') + if (worker && (!lease || lease.status !== 'released' || lease.projectId !== record.projectId || lease.attemptId !== record.attemptId || lease.role !== record.role || lease.resourceId !== record.resourceId || lease.generation !== record.generation || lease.worker?.handle !== record.id || worker.releaseProof?.workerId !== record.id && worker.releaseProof?.kind !== 'systemd-prior-invocation-exited' || lease.releaseProof?.kind !== record.releaseProof?.kind || lease.releaseProof?.generation !== record.releaseProof?.generation || lease.releaseProof?.at !== record.releaseProof?.at)) throw reject('Claim retirement exit proof does not match its allocation') + if (worker?.resultHash && !claim.decision || claim.inputCorrection?.status === 'pending') throw reject('Reconcile the pending worker result or input correction before retiring its responsibility') + proof = { workerId: record.id, leaseId: record.leaseId, generation: record.generation, releaseProof: record.releaseProof } + } + if (Object.values(state.projectEffects ?? {}).some(row => row.taskId === task.id && row.payload?.attemptId === attempt.id && ['pending', 'running', 'failed'].includes(row.status) && ['github.candidate', 'github.publish', 'github.review', 'github.merge'].includes(row.kind))) throw reject('Reconcile pending external writes before retiring execution responsibility') + integrationReleased(attempt, undefined, claim) + rt.requireProjectAccess(actor, task.projectId, 'configure', task) + if (task.deliveryAttemptId !== attempt.id || attempt.cancelRequested) throw reject('Execution retirement Attempt changed during validation') + return proof + }) } async function integrationSnapshot(attempt) { const workspace = workspaceFor(attempt, 'developer') @@ -546,7 +583,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (!attempt || attempt.taskId !== task.id || attempt.projectId !== task.projectId) return null try { const project = projectOf(task.projectId, attempt) - return { baseSha: attempt.baseSha ?? null, checkoutSha: attempt.candidateSha ?? attempt.seedSha ?? attempt.baseSha ?? null, + return { executionCheckpoint: { generation: task.collaboration?.generation ?? 0, claimId: task.collaboration?.claim?.id ?? null, blockers: integrationBlockers(attempt) }, baseSha: attempt.baseSha ?? null, checkoutSha: attempt.candidateSha ?? attempt.seedSha ?? attempt.baseSha ?? null, availableInputs: await approvedRepositoryInputs(project), snapshot: await integrationSnapshot(attempt), preparationFailure: attempt.preparationFailures?.at(-1) ?? null, materialization: workspaceFor(attempt, 'developer')?.materialization ?? null, verificationRecovery: { attemptStatus: attempt.status, sealedCandidate: Boolean(attempt.candidateSha && attempt.evidence.implementation?.sha === attempt.candidateSha), testsPassedForCandidate: Boolean(attempt.evidence.tests?.passed && attempt.evidence.tests.sha === attempt.candidateSha), requires: ['current requirements and repository binding', 'clean sealed candidate', 'all old allocation exits confirmed', 'normal Task-role context admission'], reviewerRequires: 'matching passing host test command and immutable log' } } @@ -920,6 +957,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti integrationReleased(attempt) return { attemptId: attempt.id, generation: task.collaboration?.generation ?? 0, inputs: structuredClone(attempt.integrationInputs ?? []), availableInputs: await approvedRepositoryInputs(projectOf(task.projectId, attempt)), materialization: workspaceFor(attempt, 'developer')?.materialization ?? null, snapshot: await integrationSnapshot(attempt) } } + if (endpoint === 'project-attempt-reconcile-exit') return reconcileExit(body, actor) if (endpoint === 'project-attempt-integration-bind') return serialAttempt(body.attemptId, () => bindIntegrations(body, actor)) if (endpoint === 'project-task-run') return serial(() => start(body, actor)) if (endpoint === 'project-task-reject') return serialAttempt(body.attemptId, async () => { diff --git a/core/src/host/project-development-service.js b/core/src/host/project-development-service.js index c76aaa3c8..a0afcb662 100644 --- a/core/src/host/project-development-service.js +++ b/core/src/host/project-development-service.js @@ -22,7 +22,7 @@ const operations = Object.freeze({ 'handoff.get': 'project-handoff-get', 'handoff.list': 'project-handoff-list', 'task.propose': 'task-create', 'task.change.request': 'project-task-change-request', 'task.run': 'project-task-run', 'task.detail': 'project-task-detail', 'task.accept': 'project-task-accept', 'task.reject': 'project-task-reject', - 'attempt.stop': 'project-attempt-stop', 'attempt.cleanup': 'project-attempt-cleanup', + 'attempt.reconcile-exit': 'project-attempt-reconcile-exit', 'attempt.stop': 'project-attempt-stop', 'attempt.cleanup': 'project-attempt-cleanup', 'attempt.integration-state': 'project-attempt-integration-state', 'attempt.integration-bind': 'project-attempt-integration-bind', 'release.request': 'project-release-request', 'release.reconcile': 'project-release-reconcile', 'release.status': 'project-release-status', 'attempt.publication-retry': 'project-attempt-publication-retry', 'attempt.log': 'project-attempt-log', @@ -110,7 +110,7 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm export function createDevelopmentService(rt) { const receipts = rt.projectEffects ? createCommandReceipts(rt) : null - const management = new Set(['github.connection.configure', 'release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) + const management = new Set(['github.connection.configure', 'release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.reconcile-exit', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) const readCommand = new Set(['inbox.claim', 'inbox.renew', 'inbox.complete', 'inbox.fail', 'inbox.connection.configure', 'agent.mode', 'consultation.ask', 'consultation.reply']) const actionFor = name => ['task.propose', 'proposal.publish', 'comment.publish'].includes(name) ? 'create' : management.has(name) ? 'configure' : readCommand.has(name) ? 'read' : name === 'task.accept' || name === 'task.reject' ? 'accept' : 'task-write' diff --git a/core/src/host/project-pools.js b/core/src/host/project-pools.js index 2e0aebb48..fd0b61dd9 100644 --- a/core/src/host/project-pools.js +++ b/core/src/host/project-pools.js @@ -66,6 +66,7 @@ export function createProjectPools(rt) { acceptanceEffects = rt.projectEffects } } + rt.projectEffects?.register('pool.retire', { run: effect => ({ claimId: effect.payload.claimId, status: 'durable-exit-retirement' }) }) acceptanceAdapter() // Register on restart even when no new claim has been made. const taskOf = id => { const task = ownEntry(rt.state.tasks, id); if (!task?.projectId) throw refuse('Unknown Project Task'); return task } const definitions = projectId => rt.projectAgents.deliveryDefinitions(projectId) @@ -487,6 +488,35 @@ export function createProjectPools(rt) { } } } + async function retireExited(taskId, body, actor, verify) { + const task = taskOf(taskId), c = task.collaboration, claim = c?.claim + if (rt.readOnly?.() !== undefined) throw refuse('Execution retirement requires a writable host') + rt.requireProjectAccess(actor, task.projectId, 'configure', task) + rt.projects.requireActive(task.projectId) + if (claim?.id === body.claimId && claim.retirement && rt.projectEffects?.intentCommitted(claim.retirement.effect) && [c.generation, claim.retirement.previousGeneration].includes(body.generation)) return { retirement: clone(claim.retirement), generation: c.generation } + if (!claim || claim.id !== body.claimId || c.generation !== body.generation || !['active', 'interrupted', 'blocked', 'waiting-human'].includes(claim.status)) throw refuse('Execution retirement snapshot is stale') + reasonOf(body.reason) + const proof = await verify(task, claim) + rt.requireProjectAccess(actor, task.projectId, 'configure', task) + rt.projects.requireActive(task.projectId) + if (task.collaboration !== c || c.claim !== claim || c.generation !== body.generation || claim.id !== body.claimId) throw refuse('Execution retirement snapshot changed during validation') + const previous = clone(c), receipt = c.history.find(row => row.id === c.currentHandoffId) + if (!rt.projectEffects) throw refuse('Execution retirement requires durable Effects') + const token = rt.projectEffects.stage({ kind: 'pool.retire', key: claim.id, projectId: task.projectId, taskId, payload: { claimId: claim.id, generation: claim.generation, proof } }) + claim.status = 'interrupted'; claim.exitConfirmed = true + claim.retirement = { claimId: claim.id, generation: claim.generation, previousGeneration: body.generation, by: actor.memberId, reason: body.reason, proof, effect: token, at: Date.now() } + ++c.generation; c.status = 'blocked' + if (receipt) { receipt.status = 'interrupted'; receipt.finishedAt = Date.now(); receipt.execution = clone(claim) } + try { await rt.projectEffects.persist(token) } + catch (error) { + if (rt.projectEffects.discardUncommitted(token) && c.claim === claim && c.generation === body.generation + 1) { + for (const key of Object.keys(c)) delete c[key] + Object.assign(c, previous) + } + throw error + } + return { retirement: clone(claim.retirement), generation: c.generation } + } async function revokeExecution(taskId, claimId, reason) { const task = taskOf(taskId), c = task.collaboration if (!c || c.claim?.id !== claimId || c.claim.status !== 'active') return false @@ -638,7 +668,7 @@ 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, + correct, retireExited: (id, body, actor, verify) => serial(() => retireExited(id, body, actor, verify)), 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)), validateChiefRoute: (body, actor) => serial(() => validateChiefRoute(body, actor)), chiefRoute: (body, actor) => serial(() => chiefRoute(body, actor)) } } diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index 6b4ae3552..d6992f2f5 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -2024,3 +2024,84 @@ test('SDK publication observed before a writeback failure is reconciled through assert.equal(f.published.length, 0); assert.equal(observations, 2) assert.equal(attempt.publicationRecoveries[0].kind, 'sdk-candidate-reconcile') }) + +test('proven exited active claim retires durably before a fresh integration snapshot, without cancellation', async t => { + const f = await additionalIntegrationFixture(t, { merge: false }) + const c = f.task.collaboration, generation = c.generation + Object.assign(f.attempt.roleSessions.developer, { id: 'retiring-session', poolClaimId: 'retiring-claim', generation }) + const proof = { kind: 'sdk-stop-ack-and-process-exit', workerId: 'retiring-worker', generation: 7, at: Date.now() } + const lease = { id: 'retiring-lease', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, resourceId: 'test', role: 'developer', principalId: 'owner-1', generation: 7, status: 'released', worker: { kind: 'sdk', handle: 'retiring-worker' }, releaseProof: proof } + f.rt.state.projectResourceLeases[lease.id] = lease + f.rt.state.projectHarnessWorkers = { 'retiring-worker': { id: 'retiring-worker', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, role: 'developer', sessionKey: 'retiring-session', resourceId: 'test', principalId: 'owner-1', leaseId: lease.id, generation: 7, status: 'released', releaseProof: proof } } + c.claim = { id: 'retiring-claim', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:developer', workerId: 'retiring-worker', generation, status: 'active', exitConfirmed: false } + await assert.rejects(integrationCall(f, 'query', 'attempt.integration-state', {}), /confirmed execution exit/) + assert.equal((await f.rt.projectDelivery.recoveryFacts(f.task)).executionCheckpoint.blockers[0].kind, 'claim-active') + const payload = { generation, claimId: c.claim.id, reason: 'Execution exited without transferring its claim; preserve the draft' } + await assert.rejects(integrationCall(f, 'command', 'attempt.reconcile-exit', payload, bob), /access denied/) + const save = f.rt.save + f.rt.save = async () => {} + await assert.rejects(f.rt.projectDelivery.endpoint('project-attempt-reconcile-exit', { projectId: 'p1', attemptId: f.attempt.id, ...payload }, alice), /commit was not confirmed/) + assert.equal(c.claim.status, 'active'); assert.equal(c.generation, generation) + f.rt.save = save + const result = await integrationCall(f, 'command', 'attempt.reconcile-exit', payload, alice, 'retire-exact-claim') + assert.equal(result.result.retirement.claimId, 'retiring-claim') + assert.equal(c.claim.status, 'interrupted'); assert.equal(c.generation, generation + 1) + assert.ok(f.rt.projectEffects.intentCommitted(c.claim.retirement.effect)) + assert.equal(f.attempt.cancelRequested, undefined) + assert.equal((await integrationCall(f, 'command', 'attempt.reconcile-exit', payload, alice, 'retire-exact-claim')).replayed, true) + await integrationCall(f, 'command', 'attempt.reconcile-exit', payload, alice, 'repeat-retirement') + assert.equal(c.generation, generation + 1) + await assert.rejects(f.rt.projectPools.decide(f.task.id, { ...c.claim, generation }, { action: 'arbitrate', reason: 'Late old result' }), /stale/) + const saved = (await integrationCall(f, 'query', 'attempt.integration-state', {})).result + await integrationCall(f, 'command', 'attempt.integration-bind', { inputs: [{ ref: f.ref, sha: f.source }], generation: saved.generation, expectedWorkspace: saved.snapshot }) + assert.equal(f.attempt.integrationInputs[0].sha, f.source) + assert.equal(f.attempt.candidateSha, undefined); assert.equal(f.attempt.evidence.tests, undefined) + const disk = JSON.parse(readFileSync(join(f.home, 'state.json'), 'utf8')) + assert.equal(disk.tasks[f.task.id].collaboration.claim.status, 'interrupted') +}) + +test('exit retirement refuses guessed proof, pending results, cancellation and changed claims', async t => { + const f = await additionalIntegrationFixture(t, { merge: false }), c = f.task.collaboration + const session = f.attempt.roleSessions.developer + Object.assign(session, { poolClaimId: 'exact', generation: c.generation }) + const proof = { kind: 'sdk-stop-ack-and-process-exit', workerId: 'worker', generation: 4, at: 123 } + const lease = { id: 'lease', projectId: 'p1', attemptId: f.attempt.id, role: 'developer', resourceId: 'r', generation: 4, status: 'released', worker: { kind: 'sdk', handle: 'worker' }, releaseProof: proof } + f.rt.state.projectResourceLeases.lease = lease + const worker = { id: 'worker', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, role: 'developer', sessionKey: session.id, resourceId: 'r', leaseId: 'lease', generation: 4, status: 'released', releaseProof: proof } + f.rt.state.projectHarnessWorkers = { worker } + c.claim = { id: 'exact', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:developer', workerId: worker.id, generation: c.generation, status: 'active', exitConfirmed: true } + const payload = { generation: c.generation, claimId: 'exact', reason: 'Retire only the confirmed allocation' } + const denied = pattern => assert.rejects(integrationCall(f, 'command', 'attempt.reconcile-exit', payload), pattern) + delete worker.releaseProof; await denied(/confirmed execution exit/); worker.releaseProof = proof + lease.resourceId = 'foreign'; await denied(/allocation/); lease.resourceId = 'r' + session.poolClaimId = 'different'; await denied(/confirmed execution exit/); session.poolClaimId = 'exact' + worker.resultHash = 'pending'; await denied(/pending worker result/); delete worker.resultHash + f.rt.state.projectEffects.unknown = { taskId: f.task.id, kind: 'github.publish', status: 'running', payload: { attemptId: f.attempt.id } } + await denied(/pending external writes/); delete f.rt.state.projectEffects.unknown + f.attempt.cancelRequested = true; await denied(/cancelled/); delete f.attempt.cancelRequested + const background = f.rt.projectDelegations.background + f.rt.projectDelegations.background = async () => { c.generation++ } + await denied(/snapshot changed/) + f.rt.projectDelegations.background = background + assert.equal(c.claim.status, 'active'); assert.equal(c.claim.retirement, undefined) +}) + +test('native exited claim and old Chief invocation use their existing exact exit contracts', async t => { + const f = await additionalIntegrationFixture(t, { merge: false }), c = f.task.collaboration + const session = f.attempt.roleSessions.developer + Object.assign(session, { poolClaimId: 'native-claim', generation: c.generation }) + const row = { id: 'native-id', version: 'project-native-worker/v2', status: 'quiescent', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, role: 'developer', sessionKey: session.id, sessionId: 'physical', bindingKey: 'binding', generation: 1 } + row.releaseProof = { kind: 'native-agent-quiescent', registeredWorkEmpty: true, workerId: row.id, sessionId: row.sessionId, bindingKey: row.bindingKey, generation: 1 } + f.rt.state.projectNativeWorkers = { [row.id]: row } + c.claim = { id: 'native-claim', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:developer', workerId: row.id, generation: c.generation, status: 'interrupted' } + c.generation++ // Restart fenced the execution generation before explicit retirement. + await integrationCall(f, 'command', 'attempt.reconcile-exit', { generation: c.generation, claimId: c.claim.id, reason: 'Native registered work is quiescent' }) + assert.equal(c.claim.retirement.proof.workerId, row.id) + const generation = c.generation + const run = { id: 'old-chief-run', projectId: 'p1', kind: 'chief-control', invocation: 1, status: 'interrupted', exitProof: { kind: 'native-chief-prior-host-exited', confirmed: true, runId: 'old-chief-run', invocation: 1 } } + f.rt.state.projectExecutions[run.id] = run + c.claim = { id: 'chief-claim', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:chief', chiefRunId: run.id, generation, status: 'active' } + await integrationCall(f, 'command', 'attempt.reconcile-exit', { generation, claimId: c.claim.id, reason: 'The exact native Chief invocation has exited' }) + assert.equal(c.claim.retirement.proof.chiefRunId, run.id) + assert.equal(c.generation, generation + 1) +}) diff --git a/decisions/2026-11-04-claim-exit-retirement.md b/decisions/2026-11-04-claim-exit-retirement.md new file mode 100644 index 000000000..9923d6e38 --- /dev/null +++ b/decisions/2026-11-04-claim-exit-retirement.md @@ -0,0 +1,10 @@ +# Explicit execution retirement + +Owner authorized #2375/#2377/#2378/#2393 implementation, separate PRs and merge to +`delivery/ai-centric-development-service`. Reuse current pools, Effects and evidence; +Agent chooses business recovery. #2375 retires only the exact proven exited claim, +with durable commit and generation fencing before a fresh integration snapshot. +Preserve original stop cancellation and draft/history/unknown-exit protections. +No live task, credential, resource, deployment or unrelated UI changes. + +Written-by: Codex diff --git a/docs/backend-operations.md b/docs/backend-operations.md index 123317dbf..c617f4042 100644 --- a/docs/backend-operations.md +++ b/docs/backend-operations.md @@ -205,3 +205,16 @@ Codex 终端会解析资源内 SDK 自带的 CLI 并放到 PATH;codexLoginAvai 真正失效的 OAuth token 仍需原生登录流程;源码合入不会自动修复现场凭据。 网络超时不会被当成重新登录要求,工具自身的认证错误也不会冒充模型登录失效。 Task/Session/候选/Review 证据保留;Chief 决定换用已授权资源、等待或请求协助。 + +### Retire an already exited claim + +`attempt.stop` cancels; it is not pause/resume. If an exact worker has confirmed +exit but its claim remains active, a current manager may call v1 command +`attempt.reconcile-exit` with `attemptId`, `claimId`, current `generation` and +bounded `reason`. The command verifies the existing allocation/exit contracts and +commits retirement through pool Effects. Unresolved result/correction, external +writes, preparations or another running Chief remain explicit blockers. Read +`attempt.integration-state` again afterward; use its new generation and exact +snapshot for `attempt.integration-bind`. Neither command creates a candidate, +claims a PR, grants test/Review success or selects the next role. Chief context +includes the current execution checkpoint and blocker references. diff --git a/docs/development-service-api.md b/docs/development-service-api.md index e5f096a8e..9c275274e 100644 --- a/docs/development-service-api.md +++ b/docs/development-service-api.md @@ -161,6 +161,7 @@ Worker已登记不等于已运行;进程或Bot消失不等于退出;角色 | task.reject | attemptId、reason等原返工参数 | 管;原证据/停止/新Attempt路径,新Task不改变Chief裁定权 | | attempt.stop | attemptId | 管;请求原worker停止,未证实退出继续持有租约 | | attempt.cleanup | attemptId | 管;只在受支持的终止/退出状态清理,不删除Task历史 | +| attempt.reconcile-exit | attemptId、claimId、generation、reason | 管;核验实际退出与待对账责任,持久退场旧 claim 并 fence 代次;不取消/选路,随后重新取 integration-state | | attempt.integration-bind | attemptId、generation、inputs:[{ref,sha,purpose?}]、expectedWorkspace(取integration-state快照) | 管;实际引用、SHA/测试/Review绑定,非伪造“已合并” | | attempt.publication-retry | attemptId | 管;原PR绑定/幂等恢复,不另建重复PR | | release.request | attemptId、tag、sha、mode?:observe/publish、name?、notes? | 管;默认只观察,publish是显式发布授权 | diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index eddade666..9c55ade4e 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -14787,6 +14787,174 @@ "value" ] }, + "DevelopmentCommandAttemptReconcileExitPayload": { + "type": "object", + "properties": { + "attemptId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "generation": { + "type": "integer", + "minimum": 0 + }, + "claimId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "reason": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "attemptId", + "generation", + "claimId", + "reason" + ] + }, + "DevelopmentCommandAttemptReconcileExitRequest": { + "type": "object", + "properties": { + "endpoint": { + "const": "project-development", + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "version": { + "const": "project-development/v1", + "type": "string" + }, + "kind": { + "const": "command", + "type": "string" + }, + "name": { + "const": "attempt.reconcile-exit", + "type": "string" + }, + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "payload": { + "$ref": "#/components/schemas/DevelopmentCommandAttemptReconcileExitPayload" + }, + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,100}$" + }, + "expectedGeneration": { + "type": "integer", + "minimum": 0 + }, + "expectedCandidateSha": { + "anyOf": [ + { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + { + "type": "null" + } + ] + } + }, + "additionalProperties": false, + "required": [ + "version", + "kind", + "name", + "projectId", + "payload", + "requestId" + ] + } + }, + "additionalProperties": false, + "required": [ + "endpoint", + "payload" + ] + }, + "DevelopmentCommandAttemptReconcileExitResult": { + "type": "object", + "properties": { + "retirement": { + "$ref": "#/components/schemas/JsonValue" + }, + "generation": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": true, + "required": [ + "retirement", + "generation" + ] + }, + "DevelopmentCommandAttemptReconcileExitSuccess": { + "type": "object", + "properties": { + "ok": { + "const": true, + "type": "boolean" + }, + "value": { + "type": "object", + "properties": { + "version": { + "const": "project-development/v1", + "type": "string" + }, + "requestId": { + "type": "string", + "pattern": "^[A-Za-z0-9_-]{1,100}$" + }, + "result": { + "$ref": "#/components/schemas/DevelopmentCommandAttemptReconcileExitResult" + }, + "receipt": { + "$ref": "#/components/schemas/CommandReceipt" + }, + "replayed": { + "type": "boolean" + }, + "reconstructed": { + "type": "boolean" + } + }, + "additionalProperties": true, + "required": [ + "version", + "result", + "requestId" + ] + } + }, + "additionalProperties": false, + "required": [ + "ok", + "value" + ] + }, "DevelopmentCommandAttemptIntegrationBindPayload": { "type": "object", "properties": { @@ -30450,6 +30618,9 @@ { "$ref": "#/components/schemas/DevelopmentCommandAttemptPublicationRetryRequest" }, + { + "$ref": "#/components/schemas/DevelopmentCommandAttemptReconcileExitRequest" + }, { "$ref": "#/components/schemas/DevelopmentCommandAttemptIntegrationBindRequest" }, @@ -30989,7 +31160,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" } }, { @@ -31067,7 +31238,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" } }, { @@ -31145,7 +31316,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" } }, { @@ -31223,7 +31394,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" } }, { @@ -31623,7 +31794,7 @@ "notes": "Read-only transport/initiative history; it is not role claim acceptance.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "39abcbd04ea3d5f6bb6cf2cfbf80cab0b59e6afc9a2ef1042a490d80b5266883" + "sha256": "8464b135daf648f743491bcd4ec33b8ee0630f92f8ea9f397d96a6ea2ca815bc" } }, { @@ -31701,7 +31872,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "39abcbd04ea3d5f6bb6cf2cfbf80cab0b59e6afc9a2ef1042a490d80b5266883" + "sha256": "8464b135daf648f743491bcd4ec33b8ee0630f92f8ea9f397d96a6ea2ca815bc" } }, { @@ -31779,7 +31950,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" } }, { @@ -32101,7 +32272,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" } }, { @@ -32183,7 +32354,7 @@ "notes": "Although a query, the downstream integration checkpoint gate requires manager authority and confirmed release.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -32261,7 +32432,7 @@ "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path. environmentRole selects that role workspace latest preparation log and is mutually exclusive with SDK execution role. Preparation is not tests or inference.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -32422,7 +32593,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": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" + "sha256": "9b4718882f2ec2f854f0dddf68f4bec5677b5e762bf9ba18f5141add2f6ff09e" } }, { @@ -34310,7 +34481,7 @@ "notes": "New Project Task contracts require actual Chief adjudication; third-party clients cannot forge Chief credentials.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -34418,7 +34589,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -34525,7 +34696,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -34619,7 +34790,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -34723,7 +34894,117 @@ "notes": "phase=candidate observes and adopts an already SDK-published PR before host tests; it never creates a PR, rewrites SHA, grants a Review PASS or closes a Task. Original github.candidate Effect is durable; a failed Effect must first be explicitly retried through effect.retry. Without phase, retain existing reviewed publication reconciliation semantics.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + } + }, + { + "operationId": "development.command.attempt.reconcile-exit", + "transport": { + "method": "POST", + "path": "/api/bot", + "endpoint": "project-development", + "kind": "command", + "name": "attempt.reconcile-exit", + "version": "project-development/v1" + }, + "requestSchema": { + "$ref": "#/components/schemas/DevelopmentCommandAttemptReconcileExitRequest" + }, + "resultSchema": { + "$ref": "#/components/schemas/DevelopmentCommandAttemptReconcileExitResult" + }, + "successSchema": { + "$ref": "#/components/schemas/DevelopmentCommandAttemptReconcileExitSuccess" + }, + "errorSchema": { + "$ref": "#/components/schemas/DomainError" + }, + "security": [ + { + "memberSession": [] + }, + { + "personalAgentToken": [] + } + ], + "permission": "configure", + "humanRoles": [ + "owner", + "maintainer" + ], + "condition": "current-manager-and-active-project", + "personalAgent": { + "safe": false, + "watch": true, + "checks": [ + "unexpired-unrevoked-token", + "enabled-current-owner-principal", + "active-agent", + "explicit-token-project-scope", + "active-agent-project-participation", + "current-owner-project-rights", + "writes-require-current-watch-grant-and-generation", + "all-operation-specific-domain-preconditions" + ] + }, + "effective": "intersection-of-current-human-rights-token-scope-participation-delegation-and-domain-fences; never-role-name", + "preconditions": [ + { + "code": "current-project-membership", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "active-project", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "writable-store", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "exact-current-claim-and-generation", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "verified-allocation-exit", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "no-pending-results-or-unknown-external-writes", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "not-cancelled", + "enforcedBy": "server", + "clientCheckIsAuthority": false + } + ], + "stateChanges": { + "effects": [ + "durable-claim-retirement-and-generation-fence", + "preserve-drafts-and-evidence-no-business-routing" + ], + "effectsAreConditional": true, + "transitions": [], + "noTaskCreated": false, + "acknowledgementIsCompletion": false + }, + "idempotency": { + "mechanism": "command-receipt", + "retry": "same-principal-project-name-requestId-and-identical-payload; read-command.receipt-on-uncertain-outcome", + "unifiedCommandReceipt": true + }, + "invokesModel": false, + "notes": "Fetch attempt.integration-state again after retirement; attempt.stop remains cancellation.", + "implementation": { + "path": "core/src/host/project-delivery.js", + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -34832,7 +35113,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" } }, { @@ -35407,7 +35688,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": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" + "sha256": "9b4718882f2ec2f854f0dddf68f4bec5677b5e762bf9ba18f5141add2f6ff09e" } }, { @@ -35512,7 +35793,7 @@ "notes": "operationsContact selects an explicit assistance recipient, never host privileges; null clears it, omission retains it. autonomy budgets/guidance inform Chief judgement, not a fixed escalation workflow. Use existing schedule.configure targeting project-chief for hourly compensation; chief.state.compensation reports original nextCheckAt. 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": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" + "sha256": "9b4718882f2ec2f854f0dddf68f4bec5677b5e762bf9ba18f5141add2f6ff09e" } }, { @@ -44801,7 +45082,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "39abcbd04ea3d5f6bb6cf2cfbf80cab0b59e6afc9a2ef1042a490d80b5266883" + "sha256": "8464b135daf648f743491bcd4ec33b8ee0630f92f8ea9f397d96a6ea2ca815bc" }, "aliasOf": "development.query.handoff.get" }, @@ -44877,7 +45158,7 @@ "notes": "Read-only transport/initiative history; it is not role claim acceptance.", "implementation": { "path": "core/src/host/project-pools.js", - "sha256": "39abcbd04ea3d5f6bb6cf2cfbf80cab0b59e6afc9a2ef1042a490d80b5266883" + "sha256": "8464b135daf648f743491bcd4ec33b8ee0630f92f8ea9f397d96a6ea2ca815bc" }, "aliasOf": "development.query.handoff.list" }, @@ -45157,7 +45438,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" }, "aliasOf": "development.query.task.detail" }, @@ -45254,7 +45535,7 @@ "notes": "New Project Task contracts require actual Chief adjudication; third-party clients cannot forge Chief credentials.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" }, "aliasOf": "development.command.task.accept" }, @@ -45347,7 +45628,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" }, "aliasOf": "development.command.task.reject" }, @@ -45439,7 +45720,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" }, "aliasOf": "development.command.attempt.stop" }, @@ -45531,7 +45812,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" }, "aliasOf": "development.command.attempt.cleanup" }, @@ -45920,7 +46201,7 @@ "notes": "phase=candidate observes and adopts an already SDK-published PR before host tests; it never creates a PR, rewrites SHA, grants a Review PASS or closes a Task. Original github.candidate Effect is durable; a failed Effect must first be explicitly retried through effect.retry. Without phase, retain existing reviewed publication reconciliation semantics.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" }, "aliasOf": "development.command.attempt.publication-retry" }, @@ -45996,7 +46277,7 @@ "notes": "UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path. environmentRole selects that role workspace latest preparation log and is mutually exclusive with SDK execution role. Preparation is not tests or inference.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35" + "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" }, "aliasOf": "development.query.attempt.log" }, @@ -46304,7 +46585,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": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" + "sha256": "9b4718882f2ec2f854f0dddf68f4bec5677b5e762bf9ba18f5141add2f6ff09e" }, "aliasOf": "development.command.chief.inspect" }, @@ -46407,7 +46688,7 @@ "notes": "operationsContact selects an explicit assistance recipient, never host privileges; null clears it, omission retains it. autonomy budgets/guidance inform Chief judgement, not a fixed escalation workflow. Use existing schedule.configure targeting project-chief for hourly compensation; chief.state.compensation reports original nextCheckAt. 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": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" + "sha256": "9b4718882f2ec2f854f0dddf68f4bec5677b5e762bf9ba18f5141add2f6ff09e" }, "aliasOf": "development.command.chief.configure" }, @@ -46483,7 +46764,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": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6" + "sha256": "9b4718882f2ec2f854f0dddf68f4bec5677b5e762bf9ba18f5141add2f6ff09e" }, "aliasOf": "development.query.chief.state" }, @@ -46928,7 +47209,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": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca" + "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" }, "aliasOf": "development.query.pools" }, @@ -47189,6 +47470,7 @@ "task.run", "task.accept", "task.reject", + "attempt.reconcile-exit", "attempt.stop", "attempt.cleanup", "attempt.integration-bind", @@ -47236,6 +47518,7 @@ "task.detail": "project-task-detail", "task.accept": "project-task-accept", "task.reject": "project-task-reject", + "attempt.reconcile-exit": "project-attempt-reconcile-exit", "attempt.stop": "project-attempt-stop", "attempt.cleanup": "project-attempt-cleanup", "attempt.integration-state": "project-attempt-integration-state", @@ -47295,8 +47578,8 @@ ], "nativeEndpointCount": 104, "versionedQueryCount": 29, - "versionedCommandCount": 34, - "dispatchVariantCount": 186, + "versionedCommandCount": 35, + "dispatchVariantCount": 187, "sources": { "core/src/host/agents.js": "79908a1904312d18b8ddac4bde534210eb1bc5f8e92136bbf35a3591042e9692", "core/src/host/approvals.js": "0892ca1bad7c996854d91f56c57068e407b508ba8d42c34868a4eae221e30e2a", @@ -47348,7 +47631,7 @@ "core/src/host/project-assistant.js": "4fa4309d4aa7abf78b6ba4e55ba754f320384f931e0e4a5c6620c859e1b50676", "core/src/host/project-board.js": "dfb0b4bc05ae42335632e3edbba883a03c5da2fb17899b23e8226320ea08aec4", "core/src/host/project-chief-assistance.js": "261f3767cf7fd4466f057c625b32e74b9c0a90c53c0fca3fcc835ac0f11c567b", - "core/src/host/project-chief-control.js": "8a8c1af429f3f001989af241fe96106580a883d294483c0a18509f2820e1c9b6", + "core/src/host/project-chief-control.js": "9b4718882f2ec2f854f0dddf68f4bec5677b5e762bf9ba18f5141add2f6ff09e", "core/src/host/project-chief-lifecycle.js": "ed85bb1be90198910b20ffd605895812dfa848fce1049f7bd27b32dbfae3a328", "core/src/host/project-chief-protocol.js": "d56d4970a8a86f620005ad93ab70fecbd557d2f4980679c5c228237768fcd8d4", "core/src/host/project-chief-recovery.js": "b4efeef007222a87249a808bc49deb84e0e6014c700bb661629a9eb8426327f7", @@ -47363,8 +47646,8 @@ "core/src/host/project-credentials.js": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea", "core/src/host/project-delegations.js": "d4d86192551994a3419e0d2ca0f64bc4d50a75960970d2b79ed9f97b82940903", "core/src/host/project-delivery-template.js": "f149d09786ac6db41a7ebeccb7620cce62b57f334d3ddd5e3c66ee673e5b66a8", - "core/src/host/project-delivery.js": "d3919fd6a5035fe5c9d27184384c6edc380e7991442c5c2388ae8833c3f23c35", - "core/src/host/project-development-service.js": "90e7e22e5906842324ba0ede5359b09e6d225bee388b4e3721250ab8b951f8ca", + "core/src/host/project-delivery.js": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b", + "core/src/host/project-development-service.js": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "dc02f89eb221d543805d115e48a9534882a344d233bf089299e43b1a1a4ede63", "core/src/host/project-execution-authority.js": "9c5ffb4bc9153d8f9d9e7901f7aef2aee90cacc19f19d8a0726ac0adb93e3e36", @@ -47390,7 +47673,7 @@ "core/src/host/project-merge-policy.js": "78b5c4830e5466d102298d5ded6665f65f89537d908e1cf85b0438b868548552", "core/src/host/project-metrics.js": "80f209d76955e5a3058fa66757543059fded1030625e5e155be5254494ce5264", "core/src/host/project-native-sessions.js": "cbc3c32cfd21f4364440a84dbab33fe9ea7018f464959574342029d848def48e", - "core/src/host/project-pools.js": "39abcbd04ea3d5f6bb6cf2cfbf80cab0b59e6afc9a2ef1042a490d80b5266883", + "core/src/host/project-pools.js": "8464b135daf648f743491bcd4ec33b8ee0630f92f8ea9f397d96a6ea2ca815bc", "core/src/host/project-private-file.js": "e8b51d8da7bcd72658eb6eb953dbf9d0054ea0ee519b0229785e8ea0aacef6df", "core/src/host/project-process-proof.js": "d4c7e8b4f0d2dd177c3e02fd0afe62b4c13ca10348d8f1caf43c011cda47388b", "core/src/host/project-receipt-correction.js": "7162d771d2e28fb4f55af6d492d9b9dc18c9e96f7fbacf660b84c551281ec167", diff --git a/docs/subsystems/backend-service.md b/docs/subsystems/backend-service.md index f0d5c4d98..4a92439b2 100644 --- a/docs/subsystems/backend-service.md +++ b/docs/subsystems/backend-service.md @@ -144,3 +144,11 @@ credential issuance API or deployment change; see [role contract](../role-github #2398 extends original HealthCheck/Terminal/ExecutionMetadata schemas with profile binding and sanitized SDK auth facts. Existing permissions and routes remain; only the explicit terminal administrator receives target paths. OpenAPI is regenerated. + +## Proven-exit retirement (#2375) + +The v1 `attempt.reconcile-exit` command exposes existing pool/Effects retirement +under configure authority. It requires an exact current claim/generation and +verified physical exit; it does not cancel an Attempt or confer integration/PR +acceptance. OpenAPI and operations describe re-reading the workspace snapshot +before binding a source. Chief recovery facts retain specific blocker references. diff --git a/docs/subsystems/project-chief-control.md b/docs/subsystems/project-chief-control.md index f699903c2..ad48c51bb 100644 --- a/docs/subsystems/project-chief-control.md +++ b/docs/subsystems/project-chief-control.md @@ -287,3 +287,7 @@ Inspection projects recipient/delivery/claim/reply and recovery-budget metadata; other Chief schedules expose actual native nextDueAt. Generation, requirements, Attempt/candidate and contact/role changes supersede old assistance. See [operator and API guide](../chief-assistance.md). + +## Shared exit verification (#2375) + +The existing inspection exit verifier is also used by explicit claim retirement for native/SDK Chief runs. Only exact invocation evidence can discharge responsibility; the generic unmanaged-process predicate is unchanged. Recovery facts are not business routing instructions. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index 89252c820..e0e7474f1 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -673,3 +673,7 @@ Reviewer needs a real exact-SHA, Session-marked Review by a different App; optio Tester checks never replace host tests. Host merge uses separately selected App identity and existing policy. Legacy publication remains unchanged; disabling App mode during an App Attempt refuses personal/sealing fallback. [Contract](../role-github-apps.md). + +## Exit reconciliation (#2375) + +`attempt.reconcile-exit` requires current configure authority, attemptId, claimId, generation and reason. It rechecks exact worker/Session/allocation exit, preparation and Chief occupancy, pending results/correction and unknown publication effects before committing pool retirement. It preserves drafts/candidates and does not cancel or revive the Attempt. Fetch a fresh `attempt.integration-state` before `attempt.integration-bind`; inputs are not candidate/PR evidence. diff --git a/docs/subsystems/project-development-service.md b/docs/subsystems/project-development-service.md index ec353197b..62401da3a 100644 --- a/docs/subsystems/project-development-service.md +++ b/docs/subsystems/project-development-service.md @@ -251,3 +251,7 @@ operation adopts retained, remotely verified SDK artifacts through original Effe it creates no PR, grants no test/Review/Task completion and respects current execution exit/requirements/authorization. Public role/App status never returns credentials. [Wire contract](../openapi/flybot.openapi.json). + +## Exit retirement command (#2375) + +`attempt.reconcile-exit` is a configure-authorized command over the existing delivery/pool services and command receipts. It returns the committed retirement and new generation. Stale claims, incomplete exit or unresolved external writes remain errors. It neither stops a worker nor chooses recovery routing. diff --git a/docs/subsystems/project-pools.md b/docs/subsystems/project-pools.md index 66cf1c67e..aa6e143b1 100644 --- a/docs/subsystems/project-pools.md +++ b/docs/subsystems/project-pools.md @@ -205,3 +205,7 @@ See [receipt correction](project-inputs.md#invalid-receipt-correction-2338). #2347 preflights recovery routes before changing generation, returns unsupported routes to Chief, and reuses the existing delivery adapter for evidence-supported Tester/Reviewer continuation. No default next role or extra acceptance authority. + +## Confirmed-exit retirement (#2375) + +An explicit manager/Chief recovery action uses the existing pool lock and Effects to retire the exact exited claim and fence its generation. Failed/unconfirmed persistence rolls back the staged retirement. Receipt history retains the old claim; late results cannot transfer initiative. No next role is selected. diff --git a/docs/third-party-bot-integration.md b/docs/third-party-bot-integration.md index 44d8ee592..f70a8daf7 100644 --- a/docs/third-party-bot-integration.md +++ b/docs/third-party-bot-integration.md @@ -4,8 +4,8 @@ 本规范描述当前交付分支的**实际公开网关**,供 grok-bot、dots 等个人代理的客户端实现使用。 OpenAPI 3.1 JSON 可直接下载解析;不需要启动 Swagger,也没有新增 `/openapi.json` HTTP 路由。 -它覆盖104个原生分派入口(其中包含 `project-development`)、29个 v1 查询、34个 v1 命令, -并展开个人会话和旧工作流的子操作,共186种请求分支。入口数量和操作名称由源码校验。 +它覆盖104个原生分派入口(其中包含 `project-development`)、29个 v1 查询、35个 v1 命令, +并展开个人会话和旧工作流的子操作,共187种请求分支。入口数量和操作名称由源码校验。 这份规范不等于部署证明,也不表示已实现特定厂商的回调或登录适配器。 #2308 增加[统一私人消息箱与通知协议](agent-inbox.md):原生/外部代理共用领取、续租、结果提交及恢复, 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 012acaf5d..72577182a 100644 --- a/patches/ds-bot/files/src/host/project-chief-control.js +++ b/patches/ds-bot/files/src/host/project-chief-control.js @@ -466,7 +466,7 @@ export function createProjectChiefControl(rt) { for (const run of Object.values(rt.state.projectExecutions ?? {})) if (run.kind === 'chief-control' && run.status === 'running') { run.status = 'interrupted'; run.waitingFor = 'recovery'; run.recoveryIssue = { version: 'chief-recovery/v1', kind: 'host-recovering', retryable: true, conditions: ['prior execution exit reconciliation'], actions: ['wait for the existing recovery pass'] }; run.failure = 'Host restarted before Chief result was validated'; changed = true } return changed } - return { configure, view, request, scheduled, scheduledState, pump, recover, requireJobActor, validateActor, applyDecision, template: structuredClone(template) } + return { exitConfirmed: inspectionExited, configure, view, request, scheduled, scheduledState, pump, recover, requireJobActor, validateActor, applyDecision, template: structuredClone(template) } } export function install(rt) { rt.projectChiefControl = createProjectChiefControl(rt) diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index 59f8513dd..b2db78610 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -456,15 +456,52 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (attempt.protocol !== 'project-handoff/v1') throw reject('Integration input admission requires Project Bot pools') return { attempt, task } } - function integrationReleased(attempt, permit) { - if (permit?.[childPermit]) { childInputs.assertProof(attempt.taskId, permit.proof.claim, permit.proof); return } + function integrationBlockers(attempt, permit, retiringClaim) { const chief = row => row?.kind === 'chief-control' && row.projectId === attempt.projectId - const claim = taskOf(attempt.taskId)?.collaboration?.claim - const ownChiefRun = permit?.[chiefInputPermit] - // Old released Chief turns never owned this workspace. The current Chief - // claim and any unreleased Project Chief still require exact exit proof. - const currentWorker = row => !(ownChiefRun && row.role === 'chief' && row.chiefRunId === ownChiefRun) && (row.attemptId === attempt.id || row.projectId === attempt.projectId && row.chiefRunId && (row.status !== 'released' || row.chiefRunId === claim?.chiefRunId)) - if (claim?.status === 'active' && !(ownChiefRun && claim.chiefRunId === ownChiefRun && claim.botId === `${attempt.projectId}:chief`) || Object.values(state.projectExecutions ?? {}).some(row => chief(row) && row.status === 'running' && row.id !== ownChiefRun) || Object.values(state.projectResourceLeases ?? {}).some(row => (row.attemptId === attempt.id || chief(state.projectExecutions?.[row.attemptId])) && row.status !== 'released') || Object.values(state.projectHarnessWorkers ?? {}).some(row => currentWorker(row) && !confirmedWorkerExit(state, row)) || Object.values(state.bots ?? {}).some(row => row.ephemeral && row.attemptId === attempt.id)) throw reject('Integration input is waiting for confirmed execution exit') + const claim = taskOf(attempt.taskId)?.collaboration?.claim, ownChiefRun = permit?.[chiefInputPermit] + const blockers = [] + if (claim?.status === 'active' && claim !== retiringClaim && !(ownChiefRun && claim.chiefRunId === ownChiefRun && claim.botId === `${attempt.projectId}:chief`)) blockers.push({ kind: 'claim-active', claimId: claim.id, action: 'attempt.reconcile-exit after result and exit reconciliation' }) + if (claim?.retirement && !rt.projectEffects?.intentCommitted(claim.retirement.effect)) blockers.push({ kind: 'claim-retirement-uncommitted', claimId: claim.id }) + for (const row of Object.values(state.projectExecutions ?? {})) if (chief(row) && row.status === 'running' && row.id !== ownChiefRun) blockers.push({ kind: 'chief-running', runId: row.id }) + for (const row of Object.values(state.projectResourceLeases ?? {})) if ((row.attemptId === attempt.id || chief(state.projectExecutions?.[row.attemptId])) && (row.status !== 'released' || row.preparation && row.preparation.exitConfirmed === false)) blockers.push({ kind: row.preparation?.exitConfirmed === false ? 'preparation-exit-unknown' : 'lease-not-released', leaseId: row.id }) + // Old released Chief turns never owned this workspace. Unknown current workers stay held. + for (const row of Object.values(state.projectHarnessWorkers ?? {})) { + const relevant = !(ownChiefRun && row.role === 'chief' && row.chiefRunId === ownChiefRun) && (row.attemptId === attempt.id || row.projectId === attempt.projectId && row.chiefRunId && (row.status !== 'released' || row.chiefRunId === claim?.chiefRunId)) + if (relevant && !confirmedWorkerExit(state, row)) blockers.push({ kind: 'worker-exit-unknown', workerId: row.id }) + } + for (const row of Object.values(state.bots ?? {})) if (row.ephemeral && row.attemptId === attempt.id) blockers.push({ kind: 'native-worker-present', workerId: row.id }) + return blockers + } + function integrationReleased(attempt, permit, retiringClaim) { + if (permit?.[childPermit]) { childInputs.assertProof(attempt.taskId, permit.proof.claim, permit.proof); return } + const blockers = integrationBlockers(attempt, permit, retiringClaim) + if (blockers.length) throw Object.assign(reject(`Integration input is waiting for confirmed execution exit: ${blockers.map(row => row.kind).join(', ')}`), { details: { kind: 'execution-checkpoint', attemptId: attempt.id, blockers } }) + } + async function reconcileExit(body, actor) { + const { attempt, task } = integrationAttempt(body, actor) + if (!['running', 'interrupted', 'failed'].includes(attempt.status) || attempt.cancelRequested || ['accepted', 'cancelled'].includes(task.status)) throw reject('Exit retirement cannot revive a cancelled or completed Attempt') + return rt.projectPools.retireExited(task.id, body, actor, async (currentTask, claim) => { + await rt.projectDelegations?.background(actor.memberId) + if (claim.attemptId !== attempt.id || claim.taskId !== task.id || claim.projectId !== task.projectId || task.deliveryAttemptId !== attempt.id) throw reject('Execution retirement requires the exact current Task/Attempt claim') + const worker = ownEntry(state.projectHarnessWorkers ?? {}, claim.workerId), native = ownEntry(state.projectNativeWorkers ?? {}, claim.workerId) + let proof + if (claim.chiefRunId) { + const run = ownEntry(state.projectExecutions ?? {}, claim.chiefRunId) + if (!run || run.projectId !== task.projectId || run.status === 'running' || !await rt.projectChiefControl.exitConfirmed(run)) throw reject('Chief retirement is waiting for confirmed execution exit') + proof = { chiefRunId: run.id, invocation: run.invocation, exitProof: run.exitProof ?? run.recovery?.proof ?? null } + } else { + const record = worker ?? native, lease = record && ownEntry(state.projectResourceLeases ?? {}, record.leaseId) + if (!record || record.id !== claim.workerId || record.projectId !== task.projectId || record.taskId !== task.id || record.attemptId !== attempt.id || ![attempt.roleSessions?.[record.role], ...(attempt.roleSessionHistory ?? [])].some(session => session?.id === record.sessionKey && session.poolClaimId === claim.id && session.generation === claim.generation) || !executionExitConfirmed(rt, record.id)) throw reject('Claim retirement is waiting for matching confirmed execution exit') + if (worker && (!lease || lease.status !== 'released' || lease.projectId !== record.projectId || lease.attemptId !== record.attemptId || lease.role !== record.role || lease.resourceId !== record.resourceId || lease.generation !== record.generation || lease.worker?.handle !== record.id || worker.releaseProof?.workerId !== record.id && worker.releaseProof?.kind !== 'systemd-prior-invocation-exited' || lease.releaseProof?.kind !== record.releaseProof?.kind || lease.releaseProof?.generation !== record.releaseProof?.generation || lease.releaseProof?.at !== record.releaseProof?.at)) throw reject('Claim retirement exit proof does not match its allocation') + if (worker?.resultHash && !claim.decision || claim.inputCorrection?.status === 'pending') throw reject('Reconcile the pending worker result or input correction before retiring its responsibility') + proof = { workerId: record.id, leaseId: record.leaseId, generation: record.generation, releaseProof: record.releaseProof } + } + if (Object.values(state.projectEffects ?? {}).some(row => row.taskId === task.id && row.payload?.attemptId === attempt.id && ['pending', 'running', 'failed'].includes(row.status) && ['github.candidate', 'github.publish', 'github.review', 'github.merge'].includes(row.kind))) throw reject('Reconcile pending external writes before retiring execution responsibility') + integrationReleased(attempt, undefined, claim) + rt.requireProjectAccess(actor, task.projectId, 'configure', task) + if (task.deliveryAttemptId !== attempt.id || attempt.cancelRequested) throw reject('Execution retirement Attempt changed during validation') + return proof + }) } async function integrationSnapshot(attempt) { const workspace = workspaceFor(attempt, 'developer') @@ -546,7 +583,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (!attempt || attempt.taskId !== task.id || attempt.projectId !== task.projectId) return null try { const project = projectOf(task.projectId, attempt) - return { baseSha: attempt.baseSha ?? null, checkoutSha: attempt.candidateSha ?? attempt.seedSha ?? attempt.baseSha ?? null, + return { executionCheckpoint: { generation: task.collaboration?.generation ?? 0, claimId: task.collaboration?.claim?.id ?? null, blockers: integrationBlockers(attempt) }, baseSha: attempt.baseSha ?? null, checkoutSha: attempt.candidateSha ?? attempt.seedSha ?? attempt.baseSha ?? null, availableInputs: await approvedRepositoryInputs(project), snapshot: await integrationSnapshot(attempt), preparationFailure: attempt.preparationFailures?.at(-1) ?? null, materialization: workspaceFor(attempt, 'developer')?.materialization ?? null, verificationRecovery: { attemptStatus: attempt.status, sealedCandidate: Boolean(attempt.candidateSha && attempt.evidence.implementation?.sha === attempt.candidateSha), testsPassedForCandidate: Boolean(attempt.evidence.tests?.passed && attempt.evidence.tests.sha === attempt.candidateSha), requires: ['current requirements and repository binding', 'clean sealed candidate', 'all old allocation exits confirmed', 'normal Task-role context admission'], reviewerRequires: 'matching passing host test command and immutable log' } } @@ -920,6 +957,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti integrationReleased(attempt) return { attemptId: attempt.id, generation: task.collaboration?.generation ?? 0, inputs: structuredClone(attempt.integrationInputs ?? []), availableInputs: await approvedRepositoryInputs(projectOf(task.projectId, attempt)), materialization: workspaceFor(attempt, 'developer')?.materialization ?? null, snapshot: await integrationSnapshot(attempt) } } + if (endpoint === 'project-attempt-reconcile-exit') return reconcileExit(body, actor) if (endpoint === 'project-attempt-integration-bind') return serialAttempt(body.attemptId, () => bindIntegrations(body, actor)) if (endpoint === 'project-task-run') return serial(() => start(body, actor)) if (endpoint === 'project-task-reject') return serialAttempt(body.attemptId, async () => { 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 c76aaa3c8..a0afcb662 100644 --- a/patches/ds-bot/files/src/host/project-development-service.js +++ b/patches/ds-bot/files/src/host/project-development-service.js @@ -22,7 +22,7 @@ const operations = Object.freeze({ 'handoff.get': 'project-handoff-get', 'handoff.list': 'project-handoff-list', 'task.propose': 'task-create', 'task.change.request': 'project-task-change-request', 'task.run': 'project-task-run', 'task.detail': 'project-task-detail', 'task.accept': 'project-task-accept', 'task.reject': 'project-task-reject', - 'attempt.stop': 'project-attempt-stop', 'attempt.cleanup': 'project-attempt-cleanup', + 'attempt.reconcile-exit': 'project-attempt-reconcile-exit', 'attempt.stop': 'project-attempt-stop', 'attempt.cleanup': 'project-attempt-cleanup', 'attempt.integration-state': 'project-attempt-integration-state', 'attempt.integration-bind': 'project-attempt-integration-bind', 'release.request': 'project-release-request', 'release.reconcile': 'project-release-reconcile', 'release.status': 'project-release-status', 'attempt.publication-retry': 'project-attempt-publication-retry', 'attempt.log': 'project-attempt-log', @@ -110,7 +110,7 @@ export function taskView(state, task, { activity = {}, roleCatalog, isIntentComm export function createDevelopmentService(rt) { const receipts = rt.projectEffects ? createCommandReceipts(rt) : null - const management = new Set(['github.connection.configure', 'release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) + const management = new Set(['github.connection.configure', 'release.request', 'release.reconcile', 'role.decide', 'attempt.stop', 'attempt.cleanup', 'queue.retry', 'chief.inspect', 'chief.configure', 'schedule.configure', 'effect.retry', 'attempt.reconcile-exit', 'attempt.integration-bind', 'attempt.publication-retry', 'source.subscribe', 'source.disable', 'source.poll']) const readCommand = new Set(['inbox.claim', 'inbox.renew', 'inbox.complete', 'inbox.fail', 'inbox.connection.configure', 'agent.mode', 'consultation.ask', 'consultation.reply']) const actionFor = name => ['task.propose', 'proposal.publish', 'comment.publish'].includes(name) ? 'create' : management.has(name) ? 'configure' : readCommand.has(name) ? 'read' : name === 'task.accept' || name === 'task.reject' ? 'accept' : 'task-write' diff --git a/patches/ds-bot/files/src/host/project-pools.js b/patches/ds-bot/files/src/host/project-pools.js index 2e0aebb48..fd0b61dd9 100644 --- a/patches/ds-bot/files/src/host/project-pools.js +++ b/patches/ds-bot/files/src/host/project-pools.js @@ -66,6 +66,7 @@ export function createProjectPools(rt) { acceptanceEffects = rt.projectEffects } } + rt.projectEffects?.register('pool.retire', { run: effect => ({ claimId: effect.payload.claimId, status: 'durable-exit-retirement' }) }) acceptanceAdapter() // Register on restart even when no new claim has been made. const taskOf = id => { const task = ownEntry(rt.state.tasks, id); if (!task?.projectId) throw refuse('Unknown Project Task'); return task } const definitions = projectId => rt.projectAgents.deliveryDefinitions(projectId) @@ -487,6 +488,35 @@ export function createProjectPools(rt) { } } } + async function retireExited(taskId, body, actor, verify) { + const task = taskOf(taskId), c = task.collaboration, claim = c?.claim + if (rt.readOnly?.() !== undefined) throw refuse('Execution retirement requires a writable host') + rt.requireProjectAccess(actor, task.projectId, 'configure', task) + rt.projects.requireActive(task.projectId) + if (claim?.id === body.claimId && claim.retirement && rt.projectEffects?.intentCommitted(claim.retirement.effect) && [c.generation, claim.retirement.previousGeneration].includes(body.generation)) return { retirement: clone(claim.retirement), generation: c.generation } + if (!claim || claim.id !== body.claimId || c.generation !== body.generation || !['active', 'interrupted', 'blocked', 'waiting-human'].includes(claim.status)) throw refuse('Execution retirement snapshot is stale') + reasonOf(body.reason) + const proof = await verify(task, claim) + rt.requireProjectAccess(actor, task.projectId, 'configure', task) + rt.projects.requireActive(task.projectId) + if (task.collaboration !== c || c.claim !== claim || c.generation !== body.generation || claim.id !== body.claimId) throw refuse('Execution retirement snapshot changed during validation') + const previous = clone(c), receipt = c.history.find(row => row.id === c.currentHandoffId) + if (!rt.projectEffects) throw refuse('Execution retirement requires durable Effects') + const token = rt.projectEffects.stage({ kind: 'pool.retire', key: claim.id, projectId: task.projectId, taskId, payload: { claimId: claim.id, generation: claim.generation, proof } }) + claim.status = 'interrupted'; claim.exitConfirmed = true + claim.retirement = { claimId: claim.id, generation: claim.generation, previousGeneration: body.generation, by: actor.memberId, reason: body.reason, proof, effect: token, at: Date.now() } + ++c.generation; c.status = 'blocked' + if (receipt) { receipt.status = 'interrupted'; receipt.finishedAt = Date.now(); receipt.execution = clone(claim) } + try { await rt.projectEffects.persist(token) } + catch (error) { + if (rt.projectEffects.discardUncommitted(token) && c.claim === claim && c.generation === body.generation + 1) { + for (const key of Object.keys(c)) delete c[key] + Object.assign(c, previous) + } + throw error + } + return { retirement: clone(claim.retirement), generation: c.generation } + } async function revokeExecution(taskId, claimId, reason) { const task = taskOf(taskId), c = task.collaboration if (!c || c.claim?.id !== claimId || c.claim.status !== 'active') return false @@ -638,7 +668,7 @@ 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, + correct, retireExited: (id, body, actor, verify) => serial(() => retireExited(id, body, actor, verify)), 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)), validateChiefRoute: (body, actor) => serial(() => validateChiefRoute(body, actor)), chiefRoute: (body, actor) => serial(() => chiefRoute(body, actor)) } } diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index 6b4ae3552..d6992f2f5 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -2024,3 +2024,84 @@ test('SDK publication observed before a writeback failure is reconciled through assert.equal(f.published.length, 0); assert.equal(observations, 2) assert.equal(attempt.publicationRecoveries[0].kind, 'sdk-candidate-reconcile') }) + +test('proven exited active claim retires durably before a fresh integration snapshot, without cancellation', async t => { + const f = await additionalIntegrationFixture(t, { merge: false }) + const c = f.task.collaboration, generation = c.generation + Object.assign(f.attempt.roleSessions.developer, { id: 'retiring-session', poolClaimId: 'retiring-claim', generation }) + const proof = { kind: 'sdk-stop-ack-and-process-exit', workerId: 'retiring-worker', generation: 7, at: Date.now() } + const lease = { id: 'retiring-lease', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, resourceId: 'test', role: 'developer', principalId: 'owner-1', generation: 7, status: 'released', worker: { kind: 'sdk', handle: 'retiring-worker' }, releaseProof: proof } + f.rt.state.projectResourceLeases[lease.id] = lease + f.rt.state.projectHarnessWorkers = { 'retiring-worker': { id: 'retiring-worker', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, role: 'developer', sessionKey: 'retiring-session', resourceId: 'test', principalId: 'owner-1', leaseId: lease.id, generation: 7, status: 'released', releaseProof: proof } } + c.claim = { id: 'retiring-claim', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:developer', workerId: 'retiring-worker', generation, status: 'active', exitConfirmed: false } + await assert.rejects(integrationCall(f, 'query', 'attempt.integration-state', {}), /confirmed execution exit/) + assert.equal((await f.rt.projectDelivery.recoveryFacts(f.task)).executionCheckpoint.blockers[0].kind, 'claim-active') + const payload = { generation, claimId: c.claim.id, reason: 'Execution exited without transferring its claim; preserve the draft' } + await assert.rejects(integrationCall(f, 'command', 'attempt.reconcile-exit', payload, bob), /access denied/) + const save = f.rt.save + f.rt.save = async () => {} + await assert.rejects(f.rt.projectDelivery.endpoint('project-attempt-reconcile-exit', { projectId: 'p1', attemptId: f.attempt.id, ...payload }, alice), /commit was not confirmed/) + assert.equal(c.claim.status, 'active'); assert.equal(c.generation, generation) + f.rt.save = save + const result = await integrationCall(f, 'command', 'attempt.reconcile-exit', payload, alice, 'retire-exact-claim') + assert.equal(result.result.retirement.claimId, 'retiring-claim') + assert.equal(c.claim.status, 'interrupted'); assert.equal(c.generation, generation + 1) + assert.ok(f.rt.projectEffects.intentCommitted(c.claim.retirement.effect)) + assert.equal(f.attempt.cancelRequested, undefined) + assert.equal((await integrationCall(f, 'command', 'attempt.reconcile-exit', payload, alice, 'retire-exact-claim')).replayed, true) + await integrationCall(f, 'command', 'attempt.reconcile-exit', payload, alice, 'repeat-retirement') + assert.equal(c.generation, generation + 1) + await assert.rejects(f.rt.projectPools.decide(f.task.id, { ...c.claim, generation }, { action: 'arbitrate', reason: 'Late old result' }), /stale/) + const saved = (await integrationCall(f, 'query', 'attempt.integration-state', {})).result + await integrationCall(f, 'command', 'attempt.integration-bind', { inputs: [{ ref: f.ref, sha: f.source }], generation: saved.generation, expectedWorkspace: saved.snapshot }) + assert.equal(f.attempt.integrationInputs[0].sha, f.source) + assert.equal(f.attempt.candidateSha, undefined); assert.equal(f.attempt.evidence.tests, undefined) + const disk = JSON.parse(readFileSync(join(f.home, 'state.json'), 'utf8')) + assert.equal(disk.tasks[f.task.id].collaboration.claim.status, 'interrupted') +}) + +test('exit retirement refuses guessed proof, pending results, cancellation and changed claims', async t => { + const f = await additionalIntegrationFixture(t, { merge: false }), c = f.task.collaboration + const session = f.attempt.roleSessions.developer + Object.assign(session, { poolClaimId: 'exact', generation: c.generation }) + const proof = { kind: 'sdk-stop-ack-and-process-exit', workerId: 'worker', generation: 4, at: 123 } + const lease = { id: 'lease', projectId: 'p1', attemptId: f.attempt.id, role: 'developer', resourceId: 'r', generation: 4, status: 'released', worker: { kind: 'sdk', handle: 'worker' }, releaseProof: proof } + f.rt.state.projectResourceLeases.lease = lease + const worker = { id: 'worker', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, role: 'developer', sessionKey: session.id, resourceId: 'r', leaseId: 'lease', generation: 4, status: 'released', releaseProof: proof } + f.rt.state.projectHarnessWorkers = { worker } + c.claim = { id: 'exact', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:developer', workerId: worker.id, generation: c.generation, status: 'active', exitConfirmed: true } + const payload = { generation: c.generation, claimId: 'exact', reason: 'Retire only the confirmed allocation' } + const denied = pattern => assert.rejects(integrationCall(f, 'command', 'attempt.reconcile-exit', payload), pattern) + delete worker.releaseProof; await denied(/confirmed execution exit/); worker.releaseProof = proof + lease.resourceId = 'foreign'; await denied(/allocation/); lease.resourceId = 'r' + session.poolClaimId = 'different'; await denied(/confirmed execution exit/); session.poolClaimId = 'exact' + worker.resultHash = 'pending'; await denied(/pending worker result/); delete worker.resultHash + f.rt.state.projectEffects.unknown = { taskId: f.task.id, kind: 'github.publish', status: 'running', payload: { attemptId: f.attempt.id } } + await denied(/pending external writes/); delete f.rt.state.projectEffects.unknown + f.attempt.cancelRequested = true; await denied(/cancelled/); delete f.attempt.cancelRequested + const background = f.rt.projectDelegations.background + f.rt.projectDelegations.background = async () => { c.generation++ } + await denied(/snapshot changed/) + f.rt.projectDelegations.background = background + assert.equal(c.claim.status, 'active'); assert.equal(c.claim.retirement, undefined) +}) + +test('native exited claim and old Chief invocation use their existing exact exit contracts', async t => { + const f = await additionalIntegrationFixture(t, { merge: false }), c = f.task.collaboration + const session = f.attempt.roleSessions.developer + Object.assign(session, { poolClaimId: 'native-claim', generation: c.generation }) + const row = { id: 'native-id', version: 'project-native-worker/v2', status: 'quiescent', projectId: 'p1', taskId: f.task.id, attemptId: f.attempt.id, role: 'developer', sessionKey: session.id, sessionId: 'physical', bindingKey: 'binding', generation: 1 } + row.releaseProof = { kind: 'native-agent-quiescent', registeredWorkEmpty: true, workerId: row.id, sessionId: row.sessionId, bindingKey: row.bindingKey, generation: 1 } + f.rt.state.projectNativeWorkers = { [row.id]: row } + c.claim = { id: 'native-claim', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:developer', workerId: row.id, generation: c.generation, status: 'interrupted' } + c.generation++ // Restart fenced the execution generation before explicit retirement. + await integrationCall(f, 'command', 'attempt.reconcile-exit', { generation: c.generation, claimId: c.claim.id, reason: 'Native registered work is quiescent' }) + assert.equal(c.claim.retirement.proof.workerId, row.id) + const generation = c.generation + const run = { id: 'old-chief-run', projectId: 'p1', kind: 'chief-control', invocation: 1, status: 'interrupted', exitProof: { kind: 'native-chief-prior-host-exited', confirmed: true, runId: 'old-chief-run', invocation: 1 } } + f.rt.state.projectExecutions[run.id] = run + c.claim = { id: 'chief-claim', taskId: f.task.id, projectId: 'p1', attemptId: f.attempt.id, botId: 'p1:chief', chiefRunId: run.id, generation, status: 'active' } + await integrationCall(f, 'command', 'attempt.reconcile-exit', { generation, claimId: c.claim.id, reason: 'The exact native Chief invocation has exited' }) + assert.equal(c.claim.retirement.proof.chiefRunId, run.id) + assert.equal(c.generation, generation + 1) +}) diff --git a/scripts/api-contracts.mjs b/scripts/api-contracts.mjs index 2d279574b..bd55a8fa3 100644 --- a/scripts/api-contracts.mjs +++ b/scripts/api-contracts.mjs @@ -209,6 +209,7 @@ c('task.run', obj({ ...taskId, targetRole: role, repository: nullable(repository c('task.accept', obj(attemptId, ['attemptId']), result({ attempt: ref('Attempt') }), { permission: 'accept', source: 'core/src/host/project-delivery.js', preconditions: [...projectActive, 'legacy-task-human-acceptance-only', 'exact-verified-current-candidate-tests-review-and-pr'], effects: ['legacy-attempt-and-task-accepted'], notes: 'New Project Task contracts require actual Chief adjudication; third-party clients cannot forge Chief credentials.' }) c('task.reject', obj({ ...attemptId, reason: str(4000) }, ['attemptId', 'reason']), result({ attempt: ref('Attempt') }), { permission: 'accept', source: 'core/src/host/project-delivery.js', preconditions: [...projectActive, 'current-attempt-and-confirmed-exit'], effects: ['attempt-returned-evidence-preserved', 'rework-follows-current-task-contract'] }) for (const name of ['attempt.stop', 'attempt.cleanup', 'attempt.publication-retry']) c(name, obj(attemptId, ['attemptId']), record({ attempt: ref('Attempt'), stopping: bool, remainingWorkers: int() }, ['attempt']), { permission: 'configure', source: 'core/src/host/project-delivery.js', preconditions: [...projectActive, name === 'attempt.cleanup' ? 'terminal-attempt-and-confirmed-worker-release' : name === 'attempt.publication-retry' ? 'same-current-candidate-and-original-pr-binding' : 'current-run-control-authority'], effects: [name === 'attempt.stop' ? 'stop-requested-unknown-exit-retains-capacity' : name === 'attempt.cleanup' ? 'only-owned-released-workspaces-cleaned-history-retained' : 'existing-publication-reconciled-no-second-pr'] }) +c('attempt.reconcile-exit', obj({ ...attemptId, generation: int(), claimId: id, reason: str() }, ['attemptId', 'generation', 'claimId', 'reason']), result({ retirement: json, generation: int() }), { permission: 'configure', source: 'core/src/host/project-delivery.js', preconditions: [...projectActive, 'exact-current-claim-and-generation', 'verified-allocation-exit', 'no-pending-results-or-unknown-external-writes', 'not-cancelled'], effects: ['durable-claim-retirement-and-generation-fence', 'preserve-drafts-and-evidence-no-business-routing'], notes: 'Fetch attempt.integration-state again after retirement; attempt.stop remains cancellation.' }) c('attempt.integration-bind', obj({ ...attemptId, generation: int(), inputs: array(obj({ ref: str(), sha, purpose: choice('inspect', 'integrate') }, ['ref', 'sha']), 20), expectedWorkspace: json }, ['attemptId', 'generation', 'inputs', 'expectedWorkspace']), record({ attempt: ref('Attempt'), inputs: array(json), admission: json }), { permission: 'configure', source: 'core/src/host/project-delivery.js', preconditions: [...projectActive, 'matching-current-generation', 'released-integration-checkpoint', 'exact-workspace-snapshot-from-attempt.integration-state', 'verified-operator-source-refs'], effects: ['immutable-trusted-integration-inputs-bound-no-fake-test-or-merge-evidence'] }) c('release.request', obj({ ...attemptId, tag: str(101), sha, mode: choice('observe', 'publish'), name: str(120), notes: str(4000) }, ['attemptId', 'tag', 'sha']), result({ release: ref('Release'), already: bool }, ['release']), { permission: 'configure', source: 'core/src/host/project-releases.js', preconditions: [...projectActive, 'chief-admitted-current-running-release-attempt', 'sha-equals-pinned-attempt-base', 'explicit-github-release-provider-and-private-credential-configured', 'current-human-or-full-watch-manager-authority-not-chief'], effects: ['durable-release-authorization-recorded', 'observe-default-never-publishes', 'explicit-publish-may-create-real-external-release'], notes: 'Can perform irreversible external publication only when mode=publish is explicitly sent. Does not merge, upload assets or deploy. Unknown results must reconcile.' }) c('release.reconcile', obj(attemptId, ['attemptId']), result({ release: ref('Release'), reconciliation: json }), { permission: 'configure', source: 'core/src/host/project-releases.js', preconditions: [...projectActive, 'existing-current-release-request', 'original-authority-and-binding-still-valid'], effects: ['existing-external-result-observed-never-repeat-post'] })