diff --git a/.github/scripts/backend-docs.test.mjs b/.github/scripts/backend-docs.test.mjs index 208fede67..eb6ea0cc7 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, 35) - for (const name of ['README.md', 'README.zh.md']) assert.match(read(name), /29查询\/35命令/) + assert.equal(queries.length, 30); assert.equal(rows(commandText).length, 35) + for (const name of ['README.md', 'README.zh.md']) assert.match(read(name), /30查询\/35命令/) }) test('quickstart JSON, shell syntax and local documentation links stay usable', () => { diff --git a/PROJECT.md b/PROJECT.md index 479100752..249340b4a 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -3,6 +3,8 @@ #2375 adds explicit exit-proven claim retirement through existing pools/Effects; preserve drafts, pending results and unknown writes. No cancellation or automatic routing. +#2377 makes successor baseline choice explicit and durable; preserve cancelled history, Task-role context and draft lineage without inventing PR or test evidence. + 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 @@ -62,19 +64,7 @@ complete live App/provider/release/multi-repository/UI acceptance or authorize d ## Fork Project State — 2026-10-31 -### Backend delivery branch — 2026-11-02 - -`delivery/ai-centric-development-service` packages the complete S1–S6 backend at1811e3b8f, -including verified-exit, approved integration and Chief/SDK recovery fixes. Its root -README documents existing-service authentication, direct v1/legacy HTTP usage, -explicit task publication/start, isolated source demo setup and evidence limits. -This is a documentation/branch handoff, not another service listener, schema change, -main merge, runtime rollout or takeover of original frontend Task40/PR48. -No deployment credentials or operator private files are included. - -### Earlier service implementation evidence - -Detailed S1–S6 implementation/recovery history is [archived verbatim](docs/history/2026-11-03-service-integration.md). Existing contracts remain in their subsystem docs. New convergence work is not deployed; preserve healthy frontend Task40/PR48 and native schedules. Backend work remains in this owner-authorized session. +Earlier source snapshot/deployment checkpoints are [preserved verbatim](docs/history/2026-11-04-recovery-source-checkpoints.md). Current interfaces follow the README and subsystem contracts. Keep original frontend Task40/PR48 and healthy schedules; this backend work is direct-session source delivery without deployment. ## Historical upstream Project State diff --git a/README.md b/README.md index 3289c65f6..fdd0fd114 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, | 需要 | 文档 | | --- | --- | -| 对象、权限、全部29查询/35命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | +| 对象、权限、全部30查询/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 78577ad84..3f16e1174 100644 --- a/README.zh.md +++ b/README.zh.md @@ -81,7 +81,7 @@ HTTP 入口为 `POST /api/bot`、事件为 `GET /api/project-events`, | 需要 | 文档 | | --- | --- | -| 对象、权限、全部29查询/35命令、原生接口、幂等与SSE | [API参考](docs/development-service-api.md) | +| 对象、权限、全部30查询/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 2781545d3..dd9f9864e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -3,6 +3,8 @@ #2375: retire proven exited responsibility before fresh integration admission; source/isolated tests remain separate from live recovery. +#2377: continue the verified original seed or explicitly integrate a new baseline; no silent draft reset or cancelled Attempt revival. + ## Resource authentication reliability — 2026-11-04 #2398: existing profile binding, correct SDK auth feedback and same-profile setup; @@ -119,16 +121,7 @@ Completed M7.2–M7.5 implementation checkpoints are retained verbatim in [the historical record](docs/history/2026-11-03-completed-m7-checkpoints.md). Current role/resource/Session contracts and outstanding acceptance remain below and in the active roadmap. -### M7 validation update — 2026-11-01 - -Owner requested Codex-only real acceptance on the current machine using zy95-12 GitHub. -Run the opt-in dedicated-profile SDK/Workflow/PR/return/retry/acceptance path and record -its exact source SHA. Do not block this scoped demonstration on Claude configuration; -keep the broader mixed-Harness/two-resource criteria explicitly outstanding. - -Codex-only local demonstration: passed at `73161f3`; real artifact PR #31 returned, -#32 re-tested/re-reviewed and script-accepted at its exact SHA. No merge/deployment. -Broader Claude/mixed-resource/browser-auth/restart checks remain separate future work. +Earlier Codex demo acceptance is [preserved verbatim](docs/history/2026-11-04-recovery-source-checkpoints.md); it grants no new live-provider, deployment or credential authority. Current tests and live acceptance remain distinct. ## Owner-authorized collaboration repairs — 2026-11-01 diff --git a/changelog.d/2377.fixed.md b/changelog.d/2377.fixed.md new file mode 100644 index 000000000..7b321001d --- /dev/null +++ b/changelog.d/2377.fixed.md @@ -0,0 +1 @@ +Preserve cancelled delivery drafts through an explicit, durable successor baseline choice and retain structured recovery lineage across queueing. diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index b2db78610..ed6c934c3 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -9,7 +9,7 @@ import { execFile } from 'node:child_process' import { createHash, randomUUID } from 'node:crypto' import { mkdir, readFile, realpath, rm, writeFile, lstat } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { promisify } from 'node:util' +import { promisify, isDeepStrictEqual } from 'node:util' import { ownEntry, setEntry } from './store.js' import { createProjectReleases } from './project-releases.js' import { sealedLogPage } from './project-history-pages.js' @@ -102,6 +102,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti context.assertCurrent() if (task.deliveryAttemptId !== attempt.id || attempt.cancelRequested || attempt.candidateSha !== effect.payload.candidateSha) throw new EffectCancelled('Publication changed during validation') } + rt.projectEffects?.register('attempt.continue', { run: row => ({ attemptId: row.payload.attemptId, status: 'durable-continuation' }) }, { replace: true }) rt.projectEffects?.register('integration.admit', { run: row => ({ admissionId: row.payload.admissionId, status: 'durable-input-admission' }) }, { replace: true }) rt.projectEffects?.register('github.publish', { validate: validatePublication, @@ -229,6 +230,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti attempt.status = status attempt.failure = { message: String(error?.message ?? error).slice(0, 1000), at: Date.now() } attempt.updatedAt = Date.now() + if (attempt.protocol === 'project-handoff/v1' && attempt.runId) rt.bindProject({ kind: 'run', id: attempt.runId, projectId: attempt.projectId, taskId: attempt.taskId, attemptId: attempt.id, status: 'failed' }) rt.taskDeliveryState?.(attempt.taskId, attempt.id, 'failed', attempt.failure.message) for (const lease of Object.values(attempt.resourceLeases ?? {})) await rt.projectResources?.release(lease.id, lease.generation) await rt.save() @@ -314,6 +316,36 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti await git(repository, ['worktree', 'add', '--detach', path, attempt.baseSha]); workspace.status = 'allocated'; await rt.save() } } + async function continuationState(attempt, actor, permission = 'configure') { + const task = taskOf(attempt.taskId) + rt.requireProjectAccess(actor, task.projectId, permission, task) + integrationReleased(attempt) + const snapshot = await integrationSnapshot(attempt), workspace = workspaceFor(attempt, 'developer') + const latestBaseSha = await git(settingsOf(projectOf(attempt.projectId, attempt)).path, ['rev-parse', 'HEAD']) + rt.requireProjectAccess(actor, task.projectId, permission, task) + if (task.deliveryAttemptId !== attempt.id) throw reject('Continuation Attempt changed during snapshot') + return { attemptId: attempt.id, generation: task.collaboration?.generation ?? 0, snapshot, latestBaseSha, baseSha: attempt.baseSha ?? null, seedSha: attempt.candidateSha ?? attempt.seedSha ?? attempt.baseSha ?? null, + retainedDraft: Boolean(snapshot && await workspaceGit(workspace, ['status', '--porcelain'])), inputs: structuredClone(attempt.integrationInputs ?? []), modes: ['continue', 'latest'] } + } + async function continuationOf(body, actor, task, previous, project, repositoryBinding) { + if (!previous || previous.protocol !== 'project-handoff/v1') { + if (body.continuation !== undefined) throw reject('Continuation needs an existing pool Attempt') + return null + } + if (body.continuation === undefined && !workspaceFor(previous, 'developer')) return null + const facts = await continuationState(previous, actor, body.continuation ? 'configure' : 'task-write'), selected = body.continuation + if (!selected) { + if (facts.retainedDraft) throw Object.assign(reject('Preserved draft requires an explicit continuation choice before starting another Attempt'), { code: 'bot/workspace-baseline', details: { kind: 'continuation-required', ...facts } }) + return null + } + if (!['continue', 'latest'].includes(selected.mode) || selected.previousAttemptId !== previous.id || selected.generation !== facts.generation || !isDeepStrictEqual(selected.expectedWorkspace, facts.snapshot) || selected.mode === 'latest' && selected.expectedBaseSha !== facts.latestBaseSha) throw reject('Continuation workspace or generation changed; fetch a fresh continuation snapshot') + if (previous.requirement.repository !== project.repository || JSON.stringify(previous.repositoryBinding) !== JSON.stringify(repositoryBinding)) throw reject('Continuation repository binding changed') + if (selected.mode === 'continue' && JSON.stringify(previous.requirement) !== JSON.stringify(requirementsOf(task, project.repository))) throw reject('Continuation requirements changed; choose a new baseline explicitly') + if (selected.mode === 'continue') await verifyIntegrationInputs(previous) + const currentFacts = await continuationState(previous, actor) + if (!isDeepStrictEqual(currentFacts, facts)) throw reject('Continuation workspace changed during admission') + return { version: 'project-continuation/v1', mode: selected.mode, previousAttemptId: previous.id, sourceWorkspaceId: facts.snapshot?.workspaceId ?? null, snapshot: facts.snapshot, generation: facts.generation, latestBaseSha: facts.latestBaseSha, baseSha: facts.baseSha, seedSha: facts.seedSha, retainedDraft: facts.retainedDraft, admissions: (previous.integrationAdmissions ?? []).map(row => row.id), by: actor.memberId, at: Date.now() } + } async function start(body, actor) { const task = taskOf(body.taskId) if (!task?.projectId) throw reject('A delivery needs a Project Task') @@ -325,7 +357,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const requestKey = hash(`${project.id}\n${task.id}\n${body.requestId}`) const prior = Object.values(attempts()).find(attempt => attempt.requestKey === requestKey) if (prior) { - if (body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw reject('Execution request content changed on replay') + if (body.continuation !== undefined && !isDeepStrictEqual(body.continuation, prior.continuationRequest ? JSON.parse(prior.continuationRequest) : undefined) || body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw reject('Execution request content changed on replay') return { attempt: view(prior), already: true } } if (Object.values(attempts()).some(attempt => attempt.taskId === task.id && (ongoing.has(attempt.status) || attempt.status === 'waiting-acceptance'))) throw reject('Task already has an active Attempt') @@ -334,16 +366,32 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const settings = await preflightRepository(rt, project) if (initialTarget) initialRoleTarget(rt, task, { initialTarget }) const previous = current(task.id) + const continuation = await continuationOf(body, actor, task, previous, project, repositoryBinding) const publicationBinding = await publicationOf(previous, project) + if (continuation) { + const currentFacts = await continuationState(previous, actor) + if (current(task.id) !== previous || currentFacts.generation !== continuation.generation || !isDeepStrictEqual(currentFacts.snapshot, continuation.snapshot) || continuation.mode === 'latest' && currentFacts.latestBaseSha !== continuation.latestBaseSha || continuation.mode === 'continue' && JSON.stringify(previous.requirement) !== JSON.stringify(requirementsOf(task, project.repository))) throw reject('Continuation changed before durable admission') + } const feedback = previous?.failure ? { previousAttemptId: previous.id, kind: previous.returned ? 'human-return' : 'execution-failure', reason: previous.failure.message, candidateSha: previous.candidateSha ?? null } : null const at = Date.now() const attempt = { id: `attempt-${randomUUID()}`, projectId: project.id, taskId: task.id, requestKey, workflowVersion: DELIVERY_VERSION, status: 'preparing', initiatedBy: actor.memberId, executionOwnerId: project.ownerUserId, repositoryBinding, requirement: requirementsOf(task, project.repository), ...(initialTarget ? { initialTarget } : {}), - ...(feedback ? { feedback } : {}), ...(publicationBinding ? { publicationBinding, seedSha: previous.candidateSha ?? previous.seedSha ?? publicationBinding.previousSha, baseSha: previous.baseSha } : {}), workspaces: {}, evidence: {}, createdAt: at, updatedAt: at } + ...(feedback ? { feedback } : {}), ...(continuation ? { continuation, continuationRequest: JSON.stringify(body.continuation), ...(continuation.mode === 'latest' ? { baseSha: continuation.latestBaseSha } : { baseSha: previous.baseSha, seedSha: previous.candidateSha ?? previous.seedSha ?? previous.baseSha, ...(previous.integrationInputs ? { integrationInputs: structuredClone(previous.integrationInputs), integrationAdmissions: structuredClone(previous.integrationAdmissions ?? []) } : {}) }) } : {}), ...(publicationBinding ? { publicationBinding, seedSha: previous.candidateSha ?? previous.seedSha ?? publicationBinding.previousSha, baseSha: continuation?.mode === 'latest' ? continuation.latestBaseSha : previous.baseSha } : {}), workspaces: {}, evidence: {}, createdAt: at, updatedAt: at } + if (continuation) { rt.requireProjectAccess(actor, task.projectId, 'configure', task); rt.projects.requireActive(task.projectId) } setEntry(attempts(), attempt.id, attempt) task.deliveryAttemptId = attempt.id - await rt.save() // Reserve idempotency and ownership before any filesystem effects. + if (continuation) { + if (!rt.projectEffects) throw reject('Continuation needs durable Effects') + const token = rt.projectEffects.stage({ kind: 'attempt.continue', key: requestKey, projectId: task.projectId, taskId: task.id, payload: { attemptId: attempt.id, previousAttemptId: previous.id, continuation } }) + attempt.continuation.effect = token + try { await rt.projectEffects.persist(token) } + catch (error) { + if (rt.projectEffects.discardUncommitted(token) && task.deliveryAttemptId === attempt.id) { delete attempts()[attempt.id]; task.deliveryAttemptId = previous.id } + throw error + } + } else await rt.save() // Reserve ownership before filesystem effects. + try { const definitions = rt.projectAgents?.deliveryDefinitions(project.id) attempt.agentDefinitions = definitions @@ -583,7 +631,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 { 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, + return { continuation: structuredClone(attempt.continuation ?? null), baselineIssue: attempt.preparationFailures?.findLast(row => row.code === 'bot/workspace-baseline') ?? null, 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' } } @@ -952,6 +1000,10 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (ownEntry(attempts(), body.attemptId) !== attempt || source() !== evidence) throw reject('Log source changed during query') return page } + if (endpoint === 'project-attempt-continuation-state') { + const { attempt } = integrationAttempt(body, actor) + return continuationState(attempt, actor) + } if (endpoint === 'project-attempt-integration-state') { const { attempt, task } = integrationAttempt(body, actor) integrationReleased(attempt) diff --git a/core/src/host/project-development-service.js b/core/src/host/project-development-service.js index a0afcb662..4da535d27 100644 --- a/core/src/host/project-development-service.js +++ b/core/src/host/project-development-service.js @@ -23,7 +23,7 @@ const operations = Object.freeze({ '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.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', + 'attempt.continuation-state': 'project-attempt-continuation-state', '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', 'queue.cancel': 'project-queue-cancel', 'queue.retry': 'project-queue-retry', @@ -35,7 +35,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['github.connection', 'inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['github.connection', 'inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.continuation-state', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -166,7 +166,7 @@ export function createDevelopmentService(rt) { attemptsPage: { total: rows.length, limit, before: end > limit ? page[0].id : null } } } function context(body) { - if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures', 'resumptions', 'merge', 'sdkPublication', 'publicationRecoveries'].includes(body.section)) throw failure('Unknown Attempt context section') + if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures', 'continuation', 'resumptions', 'merge', 'sdkPublication', 'publicationRecoveries'].includes(body.section)) throw failure('Unknown Attempt context section') const attempt = ownEntry(rt.state.projectAttempts ?? {}, body.attemptId) const text = JSON.stringify(attempt[body.section] ?? null), offset = body.offset ?? 0, limit = body.limit ?? 8000 if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length || !Number.isSafeInteger(limit) || limit < 1 || limit > 16000) throw failure('Invalid context page') diff --git a/core/src/host/project-scheduler.js b/core/src/host/project-scheduler.js index 7d0ecff1d..d60cd4a42 100644 --- a/core/src/host/project-scheduler.js +++ b/core/src/host/project-scheduler.js @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util' // Persisted Project admission queue. The existing Workflow service still dispatches Runs. import { createHash, randomUUID } from 'node:crypto' import { ownEntry, setEntry } from './store.js' @@ -104,18 +105,19 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = return { admission: structuredClone(task.projectTask.admission), waitingFor: 'chief' } } rt.projectTaskLifecycle?.requireExecutable(task) + if (body.continuation !== undefined) rt.requireProjectAccess(actor, project.id, 'configure', task) const execution = taskRepository(rt, project, task, body) await preflight(rt, execution.project) if (typeof body.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(body.requestId)) throw error('A run needs a stable requestId') const requestKey = createHash('sha256').update(`${project.id}\n${task.id}\n${body.requestId}`).digest('hex') const prior = Object.values(state.projectAttempts ?? {}).find(attempt => attempt.requestKey === requestKey) if (prior) { - if (body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw error('Execution request content changed on replay') + if (body.continuation !== undefined && !isDeepStrictEqual(body.continuation, prior.continuationRequest ? JSON.parse(prior.continuationRequest) : undefined) || body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw error('Execution request content changed on replay') return { attempt: structuredClone(prior), already: true } } const queued = Object.values(entries()).find(entry => entry.requestKey === requestKey || entry.taskId === task.id && entry.status === 'waiting') if (queued) { - if (body.repository !== undefined && queued.repositoryBinding && body.repository !== queued.repositoryBinding.repository || body.targetRole !== undefined && body.targetRole !== queued.initialTarget?.role) throw error('Queued request target cannot change on replay') + if (!isDeepStrictEqual(body.continuation, queued.continuation) || body.repository !== undefined && queued.repositoryBinding && body.repository !== queued.repositoryBinding.repository || body.targetRole !== undefined && body.targetRole !== queued.initialTarget?.role) throw error('Queued request target cannot change on replay') return { queue: structuredClone(queued), already: true } } if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended') @@ -124,7 +126,7 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = const resourceReason = resourceBlock(project.id, initialTarget) if (slots(project.id) < quota(project) && !resourceReason && !Object.values(entries()).some(row => row.projectId === project.id && row.status === 'waiting')) return rt.projectDelivery.endpoint('project-task-run', { ...body, repositoryBinding: execution.binding, ...(initialTarget ? { initialTarget } : {}) }, actor) const entry = { id: `queue-${randomUUID()}`, projectId: project.id, taskId: task.id, requestId: body.requestId, requestKey, - memberId: actor.memberId, repositoryBinding: execution.binding, ...(initialTarget ? { initialTarget } : {}), status: 'waiting', order: queueOrder(), ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } + memberId: actor.memberId, ...(body.continuation ? { continuation: structuredClone(body.continuation) } : {}), repositoryBinding: execution.binding, ...(initialTarget ? { initialTarget } : {}), status: 'waiting', order: queueOrder(), ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } setEntry(entries(), entry.id, entry) await rt.save() return { queue: structuredClone(entry) } @@ -176,7 +178,7 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = entry.updatedAt = Date.now() await rt.save() try { - const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId, repositoryBinding: entry.repositoryBinding, initialTarget: entry.initialTarget }, { memberId: entry.memberId }) + const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId, repositoryBinding: entry.repositoryBinding, initialTarget: entry.initialTarget, ...(entry.continuation ? { continuation: entry.continuation } : {}) }, { memberId: entry.memberId }) entry.status = result.attempt?.status === 'failed' ? 'failed' : 'started' entry.attemptId = result.attempt?.id ?? null if (result.attempt?.failure) entry.reason = result.attempt.failure.message diff --git a/core/src/host/project-service-views.js b/core/src/host/project-service-views.js index 16604c021..08835e0e1 100644 --- a/core/src/host/project-service-views.js +++ b/core/src/host/project-service-views.js @@ -55,6 +55,7 @@ export function attemptMetadataView(attempt) { const result = pick(attempt, ['id', 'protocol', 'projectId', 'taskId', 'workflowId', 'workflowVersion', 'runId', 'status', 'initiatedBy', 'baseSha', 'seedSha', 'candidateSha', 'branch', 'createdAt', 'updatedAt', 'acceptedAt', 'acceptedBy', 'acceptedViaChief', 'cancelRequested', 'supersededBy', 'roleAssignments']) if (attempt.requirement) result.requirement = { ...pick(attempt.requirement, ['repository', 'projectTaskRevision']), ...Object.fromEntries(['title', 'criteria', 'impact', 'scope', 'note'].filter(key => attempt.requirement[key] !== undefined).map(key => [key, short(attempt.requirement[key])])) } if (attempt.failure) result.failure = { ...pick(attempt.failure, ['at']), message: short(attempt.failure.message) } + if (attempt.continuation) result.continuation = pick(attempt.continuation, ['version', 'mode', 'previousAttemptId', 'sourceWorkspaceId', 'generation', 'baseSha', 'seedSha', 'retainedDraft', 'admissions', 'by', 'at']) if (attempt.feedback) result.feedback = { ...pick(attempt.feedback, ['previousAttemptId', 'kind', 'candidateSha']), reason: short(attempt.feedback.reason) } if (attempt.release) result.release = { ...pick(attempt.release, ['id', 'status', 'observedAt']), ...pick(attempt.release.spec, ['repository', 'tag', 'sha', 'mode']), sourceRef: { query: 'release.status', attemptId: attempt.id } } if (attempt.merge) result.merge = pick(attempt.merge, ['version', 'status', 'reason', 'effectId', 'candidateSha', 'mergeCommitSha', 'baseBranch', 'method', 'createdAt', 'observedAt', 'nextCheckAt', 'deadlineAt']) @@ -73,7 +74,7 @@ export function attemptMetadataView(attempt) { if (key === 'execution') result.evidence.execution = Object.fromEntries(Object.entries(row).slice(0, 32).map(([role, value]) => [role, evidence(value)])) else result.evidence[key] = evidence(row) } - result.sourceRefs = Object.fromEntries(['evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'merge', 'sdkPublication', 'publicationRecoveries'].map(section => [section, { query: 'attempt.context', attemptId: attempt.id, section }])) + result.sourceRefs = Object.fromEntries(['evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'merge', 'sdkPublication', 'publicationRecoveries', 'continuation'].map(section => [section, { query: 'attempt.context', attemptId: attempt.id, section }])) result.projectionVersion = 'project-attempt-summary/v1' result.preview = true return result diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index d6992f2f5..1c5106025 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -2105,3 +2105,94 @@ test('native exited claim and old Chief invocation use their existing exact exit assert.equal(c.claim.retirement.proof.chiefRunId, run.id) assert.equal(c.generation, generation + 1) }) + +for (const kind of ['local', 'docker']) test(kind + ' cancelled successor explicitly continues the original seed and draft without a PR binding', async t => { + const f = await baselineResourceFixture(t, kind) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + const oldSeed = f.previous.seedSha ?? f.previous.baseSha + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + assert.equal(f.previous.status, 'cancelled') + f.rt.state.projectWorkspaces = structuredClone(f.rt.state.projectWorkspaces) + const snapshot = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const started = await f.rt.projectDelivery.endpoint('project-task-run', { taskId: f.task.id, requestId: 'continue-cancelled', continuation: { mode: 'continue', previousAttemptId: f.previous.id, generation: snapshot.generation, expectedWorkspace: snapshot.snapshot } }, alice) + const next = f.rt.state.projectAttempts[started.attempt.id] + assert.notEqual(next.id, f.previous.id); assert.equal(next.seedSha, oldSeed) + assert.equal(next.continuation.previousAttemptId, f.previous.id) + assert.equal(next.continuation.sourceWorkspaceId, f.workspace.id) + assert.equal(next.publicationBinding, undefined); assert.equal(next.evidence.tests, undefined) + await prepareRole(f.rt, f.task, { id: 'continued', attemptId: next.id, generation: 20 }, f.definition) + assert.equal(next.workspaces.developer.path, f.workspace.path) + assert.equal(git(next.workspaces.developer.path, ['rev-parse', 'HEAD']), oldSeed) + assert.match(readFileSync(join(f.workspace.path, 'code.mjs'), 'utf8'), /preservedDraft/) + assert.equal(f.previous.status, 'cancelled') +}) + +test('draft continuation is an explicit durable choice and never inherits stale inputs or tests', async t => { + const f = await baselineResourceFixture(t) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + const query = () => f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const snapshot = await query(), before = Object.keys(f.rt.state.projectAttempts).length + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', { taskId: f.task.id, requestId: 'no-choice' }, alice), error => error.code === 'bot/workspace-baseline' && error.details?.kind === 'continuation-required') + assert.equal(Object.keys(f.rt.state.projectAttempts).length, before) + const continuation = { mode: 'continue', previousAttemptId: f.previous.id, generation: snapshot.generation, expectedWorkspace: snapshot.snapshot } + const body = { taskId: f.task.id, requestId: 'chosen-continuation', continuation } + writeFileSync(join(f.workspace.path, 'new-draft.txt'), 'Concurrent draft edit\n') + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', body, alice), /workspace or generation changed/) + continuation.expectedWorkspace = (await query()).snapshot + const save = f.rt.save; f.rt.save = async () => {} + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', body, alice), /commit was not confirmed/) + assert.equal(f.task.deliveryAttemptId, f.previous.id) + assert.equal(Object.keys(f.rt.state.projectAttempts).length, before) + f.rt.save = save + f.previous.integrationInputs = [{ version: 'project-integration-input/v1', repository: 'example/demo', ref: 'refs/remotes/operator/moved', sha: f.latest }] + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', body, alice), /git|source|reference|ref|command/i) + delete f.previous.integrationInputs + const result = await f.rt.projectDelivery.endpoint('project-task-run', body, alice) + assert.equal((await f.rt.projectDelivery.endpoint('project-task-run', body, alice)).already, true) + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', { ...body, continuation: { ...continuation, mode: 'latest' } }, alice), /content changed/) + const next = f.rt.state.projectAttempts[result.attempt.id] + assert.ok(f.rt.projectEffects.intentCommitted(next.continuation.effect)) + assert.equal(next.evidence.tests, undefined) +}) + +test('latest-baseline successor preserves draft and reports the old workspace before explicit integration', async t => { + const f = await baselineResourceFixture(t) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + const saved = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const result = await f.rt.projectDelivery.endpoint('project-task-run', { taskId: f.task.id, requestId: 'choose-latest', continuation: { mode: 'latest', expectedBaseSha: saved.latestBaseSha, previousAttemptId: f.previous.id, generation: saved.generation, expectedWorkspace: saved.snapshot } }, alice) + const next = f.rt.state.projectAttempts[result.attempt.id] + assert.equal(next.baseSha, f.latest) + await assert.rejects(prepareRole(f.rt, f.task, { id: 'latest-admission', attemptId: next.id, generation: 20 }, f.definition), error => error.code === 'bot/workspace-baseline') + const recovery = await f.rt.projectDelivery.recoveryFacts(f.task) + assert.equal(recovery.continuation.sourceWorkspaceId, f.workspace.id) + assert.equal(recovery.baselineIssue.details.workspaceId, f.workspace.id) + assert.equal(Object.keys(next.workspaces).length, 0) + assert.match(readFileSync(join(f.workspace.path, 'code.mjs'), 'utf8'), /preservedDraft/) + assert.equal(next.integrationInputs, undefined) +}) + +test('capacity queue preserves explicit continuation and rejects changed replay intent', async t => { + const f = await baselineResourceFixture(t) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + const saved = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const continuation = { mode: 'continue', previousAttemptId: f.previous.id, generation: saved.generation, expectedWorkspace: saved.snapshot } + f.rt.state.projects.p1.quota = { maxConcurrent: 1 } + f.rt.state.projectAttempts.busy = { id: 'busy', projectId: 'p1', taskId: 'other', status: 'running' } + const body = { taskId: f.task.id, requestId: 'queued-continuation', continuation } + const result = await f.rt.projectScheduler.request(body, alice) + assert.deepEqual(result.queue.continuation, continuation) + await assert.rejects(f.rt.projectScheduler.request({ ...body, continuation: { ...continuation, mode: 'latest' } }, alice), /target cannot change/) + delete f.rt.state.projectAttempts.busy + await f.rt.projectScheduler.drain() + const row = f.rt.state.projectQueue[result.queue.id] + assert.equal(row.status, 'started', row.reason) + assert.equal(f.rt.state.projectAttempts[row.attemptId].seedSha, saved.seedSha) + assert.equal(f.previous.status, 'cancelled') +}) diff --git a/decisions/2026-11-04-successor-continuation.md b/decisions/2026-11-04-successor-continuation.md new file mode 100644 index 000000000..81240c43d --- /dev/null +++ b/decisions/2026-11-04-successor-continuation.md @@ -0,0 +1,9 @@ +# Successor baseline choice + +Owner authorizes #2377 implementation and merge to the delivery branch. A cancelled +Attempt stays cancelled; continuing original work creates a successor with verified +seed/source/draft lineage. Explicit latest-baseline choice retains the draft and +normal integration gates. Preserve Task-role context and require fresh tests/Review. +No live task, resource or credential changes; Agent chooses the continuation. + +Written-by: Codex diff --git a/docs/development-service-api.md b/docs/development-service-api.md index 9c275274e..6ef7ced88 100644 --- a/docs/development-service-api.md +++ b/docs/development-service-api.md @@ -124,6 +124,7 @@ SDK角色worker使用宿主内部绑定,不可拿“Dev”字符串调用角 | sources | 原source查询过滤/分页 | 当前项目绑定的Issue/PR来源订阅与游标;读 | | execution.boundary | taskId? | 当前权限/仓库/资源/Harness边界及readiness;不运行测试/不授予权限 | | attempt.context | attemptId、section、offset?、limit?:1..16000、expectedSha256? | roleSessions/roleSessionHistory/rolePolicies/evidence/feedback/requirement/failure/agentDefinitions/integrationAdmissions精确页;读 | +| attempt.continuation-state | attemptId | 管;返回取消/中断后当前工作区快照、代次与可选 continue/latest,供 task.run 显式续作 | | attempt.integration-state | attemptId | 当前候选/受信集成绑定、availableInputs、materialization和工作区快照;读 | | attempt.log | attemptId、role?、offset?、limit?:4..8192、expectedSha256? | UTF-8字节页,返回text/sha256/nextOffset/totalBytes;读,不接受宿主路径 | | release.status | attemptId | 当前Release请求/目标/外部证据;项目读,不发布 | @@ -275,3 +276,5 @@ sdkPublication/publicationRecoveries分页源。 Effect对账采纳真实SHA。失败Effect先走effect.retry;不创建新PR、不重写SHA、 不替代测试/Review/Chief关闭。省略phase保留原有审核后发布恢复前置条件。 完整操作顺序、示例和凭据边界见[角色App指南](role-github-apps.md)和OpenAPI。 + +`task.run` 可带 `continuation:{mode:"continue"|"latest",previousAttemptId,generation,expectedWorkspace,expectedBaseSha?}`,需要当前管理权限;快照取 `attempt.continuation-state`。continue 沿用已核验旧 seed 与来源,latest 要求 expectedBaseSha 等于快照的 latestBaseSha,且不丢弃草稿,基线冲突仍须显式 integrate。取消的前序不会复活,新 Attempt 不继承测试/Review 通过。 diff --git a/docs/history/2026-11-04-recovery-source-checkpoints.md b/docs/history/2026-11-04-recovery-source-checkpoints.md new file mode 100644 index 000000000..a3060019e --- /dev/null +++ b/docs/history/2026-11-04-recovery-source-checkpoints.md @@ -0,0 +1,30 @@ +# Preserved source and validation checkpoints + +Historical source text retained during bounded-memory recovery work; current authority remains in PROJECT.md and subsystem docs. + +### Backend delivery branch — 2026-11-02 + +`delivery/ai-centric-development-service` packages the complete S1–S6 backend at1811e3b8f, +including verified-exit, approved integration and Chief/SDK recovery fixes. Its root +README documents existing-service authentication, direct v1/legacy HTTP usage, +explicit task publication/start, isolated source demo setup and evidence limits. +This is a documentation/branch handoff, not another service listener, schema change, +main merge, runtime rollout or takeover of original frontend Task40/PR48. +No deployment credentials or operator private files are included. + +### Earlier service implementation evidence + +Detailed S1–S6 implementation/recovery history is [archived verbatim](docs/history/2026-11-03-service-integration.md). Existing contracts remain in their subsystem docs. New convergence work is not deployed; preserve healthy frontend Task40/PR48 and native schedules. Backend work remains in this owner-authorized session. + + +### M7 validation update — 2026-11-01 + +Owner requested Codex-only real acceptance on the current machine using zy95-12 GitHub. +Run the opt-in dedicated-profile SDK/Workflow/PR/return/retry/acceptance path and record +its exact source SHA. Do not block this scoped demonstration on Claude configuration; +keep the broader mixed-Harness/two-resource criteria explicitly outstanding. + +Codex-only local demonstration: passed at `73161f3`; real artifact PR #31 returned, +#32 re-tested/re-reviewed and script-accepted at its exact SHA. No merge/deployment. +Broader Claude/mixed-resource/browser-auth/restart checks remain separate future work. + diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index 9c55ade4e..1d3ead3c7 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -1969,6 +1969,9 @@ "required": [ "id" ] + }, + "continuation": { + "$ref": "#/components/schemas/Continuation" } }, "additionalProperties": true, @@ -6026,6 +6029,98 @@ "reauthenticationRequired" ] }, + "Continuation": { + "type": "object", + "properties": { + "version": { + "const": "project-continuation/v1", + "type": "string" + }, + "mode": { + "type": "string", + "enum": [ + "continue", + "latest" + ] + }, + "previousAttemptId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "sourceWorkspaceId": { + "anyOf": [ + { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + { + "type": "null" + } + ] + }, + "generation": { + "type": "integer", + "minimum": 0 + }, + "baseSha": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + "seedSha": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + "retainedDraft": { + "type": "boolean" + }, + "admissions": { + "type": "array", + "items": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + } + }, + "by": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "at": { + "type": "integer", + "minimum": 0 + } + }, + "additionalProperties": false, + "required": [ + "version", + "mode", + "previousAttemptId", + "sourceWorkspaceId", + "generation", + "retainedDraft" + ] + }, "DevelopmentQueryInboxListPayload": { "type": "object", "properties": { @@ -9640,6 +9735,7 @@ "integrationAdmissions", "testHistory", "preparationFailures", + "continuation", "resumptions", "merge", "sdkPublication", @@ -9783,6 +9879,214 @@ "value" ] }, + "DevelopmentQueryAttemptContinuationStatePayload": { + "type": "object", + "properties": { + "attemptId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "phase": { + "const": "candidate", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "attemptId" + ] + }, + "DevelopmentQueryAttemptContinuationStateRequest": { + "type": "object", + "properties": { + "endpoint": { + "const": "project-development", + "type": "string" + }, + "payload": { + "type": "object", + "properties": { + "version": { + "const": "project-development/v1", + "type": "string" + }, + "kind": { + "const": "query", + "type": "string" + }, + "name": { + "const": "attempt.continuation-state", + "type": "string" + }, + "projectId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "payload": { + "$ref": "#/components/schemas/DevelopmentQueryAttemptContinuationStatePayload" + }, + "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" + ] + } + }, + "additionalProperties": false, + "required": [ + "endpoint", + "payload" + ] + }, + "DevelopmentQueryAttemptContinuationStateResult": { + "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 + }, + "snapshot": { + "$ref": "#/components/schemas/JsonValue" + }, + "latestBaseSha": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "baseSha": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + "seedSha": { + "anyOf": [ + { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + { + "type": "null" + } + ] + }, + "retainedDraft": { + "type": "boolean" + }, + "inputs": { + "type": "array", + "items": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "modes": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "continue", + "latest" + ] + } + } + }, + "additionalProperties": true, + "required": [ + "attemptId", + "generation", + "snapshot", + "latestBaseSha", + "baseSha", + "seedSha", + "retainedDraft", + "inputs", + "modes" + ] + }, + "DevelopmentQueryAttemptContinuationStateSuccess": { + "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/DevelopmentQueryAttemptContinuationStateResult" + }, + "receipt": { + "$ref": "#/components/schemas/CommandReceipt" + }, + "replayed": { + "type": "boolean" + }, + "reconstructed": { + "type": "boolean" + } + }, + "additionalProperties": true, + "required": [ + "version", + "result" + ] + } + }, + "additionalProperties": false, + "required": [ + "ok", + "value" + ] + }, "DevelopmentQueryAttemptIntegrationStatePayload": { "type": "object", "properties": { @@ -13832,6 +14136,42 @@ "type": "null" } ] + }, + "continuation": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "continue", + "latest" + ] + }, + "expectedBaseSha": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "previousAttemptId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "generation": { + "type": "integer", + "minimum": 0 + }, + "expectedWorkspace": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "additionalProperties": false, + "required": [ + "mode", + "previousAttemptId", + "generation", + "expectedWorkspace" + ] } }, "additionalProperties": false, @@ -27742,6 +28082,42 @@ } ] }, + "continuation": { + "type": "object", + "properties": { + "mode": { + "type": "string", + "enum": [ + "continue", + "latest" + ] + }, + "expectedBaseSha": { + "type": "string", + "pattern": "^[a-f0-9]{40}$" + }, + "previousAttemptId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "generation": { + "type": "integer", + "minimum": 0 + }, + "expectedWorkspace": { + "$ref": "#/components/schemas/JsonValue" + } + }, + "additionalProperties": false, + "required": [ + "mode", + "previousAttemptId", + "generation", + "expectedWorkspace" + ] + }, "requestId": { "type": "string", "pattern": "^[A-Za-z0-9_-]{1,100}$" @@ -30537,6 +30913,9 @@ { "$ref": "#/components/schemas/DevelopmentQueryAttemptContextRequest" }, + { + "$ref": "#/components/schemas/DevelopmentQueryAttemptContinuationStateRequest" + }, { "$ref": "#/components/schemas/DevelopmentQueryAttemptIntegrationStateRequest" }, @@ -31160,7 +31539,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": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" } }, { @@ -31238,7 +31617,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": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" } }, { @@ -31316,7 +31695,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": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" } }, { @@ -31394,7 +31773,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": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" } }, { @@ -31950,7 +32329,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": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" } }, { @@ -32272,7 +32651,89 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-development-service.js", - "sha256": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" + } + }, + { + "operationId": "development.query.attempt.continuation-state", + "transport": { + "method": "POST", + "path": "/api/bot", + "endpoint": "project-development", + "kind": "query", + "name": "attempt.continuation-state", + "version": "project-development/v1" + }, + "requestSchema": { + "$ref": "#/components/schemas/DevelopmentQueryAttemptContinuationStateRequest" + }, + "resultSchema": { + "$ref": "#/components/schemas/DevelopmentQueryAttemptContinuationStateResult" + }, + "successSchema": { + "$ref": "#/components/schemas/DevelopmentQueryAttemptContinuationStateSuccess" + }, + "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-attempt", + "enforcedBy": "server", + "clientCheckIsAuthority": false + }, + { + "code": "confirmed-execution-exit", + "enforcedBy": "server", + "clientCheckIsAuthority": false + } + ], + "stateChanges": { + "effects": [], + "effectsAreConditional": true, + "transitions": [], + "noTaskCreated": false, + "acknowledgementIsCompletion": false + }, + "idempotency": { + "mechanism": "read-only", + "retry": "read-again-with-current-authorization-and-cursor", + "unifiedCommandReceipt": false + }, + "invokesModel": false, + "notes": "Read continuation facts even after cancellation. task.run selects a successor, never revives the cancelled Attempt.", + "implementation": { + "path": "core/src/host/project-delivery.js", + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -32354,7 +32815,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -32432,7 +32893,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -34371,7 +34832,7 @@ "notes": "Never bypasses Chief. Capacity busy means queue, not permission to create another Task. Cross-repository auto-split remains deferred.", "implementation": { "path": "core/src/host/project-scheduler.js", - "sha256": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6" + "sha256": "461d26576faeb5017ddcfc7b0d9b1ef8748e3a9aaab2eda9af9a2eef37706aef" } }, { @@ -34481,7 +34942,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -34589,7 +35050,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -34696,7 +35157,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -34790,7 +35251,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -34894,7 +35355,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -35004,7 +35465,7 @@ "notes": "Fetch attempt.integration-state again after retirement; attempt.stop remains cancellation.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -35113,7 +35574,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" } }, { @@ -35463,7 +35924,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-scheduler.js", - "sha256": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6" + "sha256": "461d26576faeb5017ddcfc7b0d9b1ef8748e3a9aaab2eda9af9a2eef37706aef" } }, { @@ -35589,7 +36050,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-scheduler.js", - "sha256": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6" + "sha256": "461d26576faeb5017ddcfc7b0d9b1ef8748e3a9aaab2eda9af9a2eef37706aef" } }, { @@ -45362,7 +45823,7 @@ "notes": "Never bypasses Chief. Capacity busy means queue, not permission to create another Task. Cross-repository auto-split remains deferred.", "implementation": { "path": "core/src/host/project-scheduler.js", - "sha256": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6" + "sha256": "461d26576faeb5017ddcfc7b0d9b1ef8748e3a9aaab2eda9af9a2eef37706aef" }, "aliasOf": "development.command.task.run" }, @@ -45438,7 +45899,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": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" }, "aliasOf": "development.query.task.detail" }, @@ -45535,7 +45996,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" }, "aliasOf": "development.command.task.accept" }, @@ -45628,7 +46089,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" }, "aliasOf": "development.command.task.reject" }, @@ -45720,7 +46181,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" }, "aliasOf": "development.command.attempt.stop" }, @@ -45812,7 +46273,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" }, "aliasOf": "development.command.attempt.cleanup" }, @@ -46201,7 +46662,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" }, "aliasOf": "development.command.attempt.publication-retry" }, @@ -46277,7 +46738,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b" + "sha256": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9" }, "aliasOf": "development.query.attempt.log" }, @@ -46375,7 +46836,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-scheduler.js", - "sha256": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6" + "sha256": "461d26576faeb5017ddcfc7b0d9b1ef8748e3a9aaab2eda9af9a2eef37706aef" }, "aliasOf": "development.command.queue.cancel" }, @@ -46488,7 +46949,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-scheduler.js", - "sha256": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6" + "sha256": "461d26576faeb5017ddcfc7b0d9b1ef8748e3a9aaab2eda9af9a2eef37706aef" }, "aliasOf": "development.command.queue.retry" }, @@ -47209,7 +47670,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": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c" + "sha256": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58" }, "aliasOf": "development.query.pools" }, @@ -47450,6 +47911,7 @@ "execution.boundary", "effects", "attempt.context", + "attempt.continuation-state", "attempt.integration-state", "attempt.log", "chief.state", @@ -47521,6 +47983,7 @@ "attempt.reconcile-exit": "project-attempt-reconcile-exit", "attempt.stop": "project-attempt-stop", "attempt.cleanup": "project-attempt-cleanup", + "attempt.continuation-state": "project-attempt-continuation-state", "attempt.integration-state": "project-attempt-integration-state", "attempt.integration-bind": "project-attempt-integration-bind", "release.request": "project-release-request", @@ -47577,9 +48040,9 @@ "project-close-confirm" ], "nativeEndpointCount": 104, - "versionedQueryCount": 29, + "versionedQueryCount": 30, "versionedCommandCount": 35, - "dispatchVariantCount": 187, + "dispatchVariantCount": 188, "sources": { "core/src/host/agents.js": "79908a1904312d18b8ddac4bde534210eb1bc5f8e92136bbf35a3591042e9692", "core/src/host/approvals.js": "0892ca1bad7c996854d91f56c57068e407b508ba8d42c34868a4eae221e30e2a", @@ -47646,8 +48109,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": "0526703c001a967da0226c917ecb6949024d1c3b038ddd12c4e6f2da727f295b", - "core/src/host/project-development-service.js": "dcbfe2d3e8e87d4124e24cb6755cc59748c2f1b9540c486aa78ecc20bff5422c", + "core/src/host/project-delivery.js": "e1b774b03bbbf4346ea760c0e489d827210df9e4c9a5e4d5301127a57cf2ddc9", + "core/src/host/project-development-service.js": "a9ca26c49a860b8a2837361e8c0d1b0a12a786a6303321b96970031558b46b58", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "dc02f89eb221d543805d115e48a9534882a344d233bf089299e43b1a1a4ede63", "core/src/host/project-execution-authority.js": "9c5ffb4bc9153d8f9d9e7901f7aef2aee90cacc19f19d8a0726ac0adb93e3e36", @@ -47691,10 +48154,10 @@ "core/src/host/project-rooms.js": "148ba6f39086dc74e0c9c92ab368361b256b5185490d7983e957dd28890cfbe9", "core/src/host/project-runtime-setup.js": "9dbb23e21deef68a779845ed0e31ec0feaefa3c3eaf35b27f26f8c5de797c61c", "core/src/host/project-scheduled-tasks.js": "78c0e29bb830710dd9109d6a5fd1a67214fff01c55a79a38a3df7e5c3a194324", - "core/src/host/project-scheduler.js": "9419010ed18fcfe801cab23562ea2ad53cb4dc500078ff1ec2af4438cc6726a6", + "core/src/host/project-scheduler.js": "461d26576faeb5017ddcfc7b0d9b1ef8748e3a9aaab2eda9af9a2eef37706aef", "core/src/host/project-service-events.js": "71917d1ab367a9db8bef8c1d1003228255a57528e84a57e76de3a39d08ac28ec", "core/src/host/project-service-sse.js": "df97d6b11156da885a6a47d50a4a77c8c99b6af5eb0066de14312cc512762297", - "core/src/host/project-service-views.js": "742afc90668878e353b5353013f4865763ff496cb1c8d42b8fe1ffb76743d728", + "core/src/host/project-service-views.js": "c4923c2717779d22d6995aec64186ae17eb2ef6b914904a7daee0e1246dad43c", "core/src/host/project-service.js": "8ba30c1d7c066867eae06a6612d5bbba3bc2278f34fcf7f1c2242876565dba8f", "core/src/host/project-session-continuation.js": "8d1312bfbe1b69abbaefd8d55dc351bfe19aaa6ae85f16e83a9dd7ff148dfa4d", "core/src/host/project-sources.js": "cf4c11c8e853f3091c4d159b850d06fe7e8bf291fdc616d251eb23d071d63403", diff --git a/docs/subsystems/backend-service.md b/docs/subsystems/backend-service.md index 4a92439b2..caa76385c 100644 --- a/docs/subsystems/backend-service.md +++ b/docs/subsystems/backend-service.md @@ -152,3 +152,7 @@ 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. + +## Explicit successor continuation (#2377) + +The API inventory describes attempt.continuation-state and the optional task.run continuation object, preserving configure authority, current snapshots, explicit baseline choice and cancelled-predecessor semantics. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index e0e7474f1..534c78ef4 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -677,3 +677,7 @@ an App Attempt refuses personal/sealing fallback. [Contract](../role-github-apps ## 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. + +## Explicit successor continuation (#2377) + +`attempt.continuation-state` returns an exited current Attempt snapshot. Explicit `task.run.continuation` chooses continue or latest using previousAttemptId/generation/expectedWorkspace. Continue revalidates and retains original seed, base and durable integration admissions; latest retains draft lineage and requires ordinary integrate admission when the new checkout conflicts. Cancelled Attempts remain cancelled; successors get fresh test/Review evidence. Missing draft choice fails before creating a successor. An original pool Effect commits the intent before filesystem work. diff --git a/docs/subsystems/project-development-service.md b/docs/subsystems/project-development-service.md index 62401da3a..f92257d6b 100644 --- a/docs/subsystems/project-development-service.md +++ b/docs/subsystems/project-development-service.md @@ -255,3 +255,7 @@ exit/requirements/authorization. Public role/App status never returns credential ## 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. + +## Explicit successor continuation (#2377) + +`attempt.continuation-state` is a configure query. `task.run.continuation` additionally requires configure permission and exact draft/generation facts. The ordinary capacity queue carries the choice unchanged; stale queued snapshots refuse on admission. Metadata exposes safe predecessor/workspace references rather than private Session contents. diff --git a/docs/subsystems/project-service-contracts.md b/docs/subsystems/project-service-contracts.md index f72b5e240..3a9701bf5 100644 --- a/docs/subsystems/project-service-contracts.md +++ b/docs/subsystems/project-service-contracts.md @@ -162,3 +162,7 @@ optional candidate adoption phase of the original publication-retry command. #2398 execution metadata includes safe profile identity and known authenticationFailure codes. No profile paths or provider error payload enter shared Task views. Actual setup paths are returned only through the existing human resource-administrator terminal. + +## Explicit successor continuation (#2377) + +Attempt metadata includes safe continuation mode, predecessor, workspace id, source baseline and admission references. Full evidence remains retrievable under current scope; input text/PR numbers cannot manufacture continuation admission. diff --git a/docs/subsystems/project-workbench.md b/docs/subsystems/project-workbench.md index c07d73321..7eec951a6 100644 --- a/docs/subsystems/project-workbench.md +++ b/docs/subsystems/project-workbench.md @@ -515,3 +515,9 @@ exitUnknown using the actual admission predicates. occupied measures resource le activeSlots measures project execution. They have independent configured limits. #2334 metadata preflight validates dependencyEnvironment shape without running commands. Receiving executor and independent host observations occur through existing preparation, not a cached metadata-ready flag. See [dependency outcomes](project-delivery.md#dependency-readiness-and-verification-outcomes-2334). + +## Explicit successor continuation (#2377) + +The original scheduler preserves explicit continuation intent through capacity queueing and revalidates it at actual dispatch. Changed intent on a request replay is rejected. It neither selects a baseline nor discards retained drafts. + +Cancelled/failed pool Attempts mark their canonical Run failed too; unknown physical leases/workers continue to count independently, while a confirmed-ended Run cannot leave a phantom queue slot. diff --git a/docs/third-party-bot-integration.md b/docs/third-party-bot-integration.md index f70a8daf7..217ea5ae2 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 查询、35个 v1 命令, -并展开个人会话和旧工作流的子操作,共187种请求分支。入口数量和操作名称由源码校验。 +它覆盖104个原生分派入口(其中包含 `project-development`)、30个 v1 查询、35个 v1 命令, +并展开个人会话和旧工作流的子操作,共188种请求分支。入口数量和操作名称由源码校验。 这份规范不等于部署证明,也不表示已实现特定厂商的回调或登录适配器。 #2308 增加[统一私人消息箱与通知协议](agent-inbox.md):原生/外部代理共用领取、续租、结果提交及恢复, diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index b2db78610..ed6c934c3 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -9,7 +9,7 @@ import { execFile } from 'node:child_process' import { createHash, randomUUID } from 'node:crypto' import { mkdir, readFile, realpath, rm, writeFile, lstat } from 'node:fs/promises' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' -import { promisify } from 'node:util' +import { promisify, isDeepStrictEqual } from 'node:util' import { ownEntry, setEntry } from './store.js' import { createProjectReleases } from './project-releases.js' import { sealedLogPage } from './project-history-pages.js' @@ -102,6 +102,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti context.assertCurrent() if (task.deliveryAttemptId !== attempt.id || attempt.cancelRequested || attempt.candidateSha !== effect.payload.candidateSha) throw new EffectCancelled('Publication changed during validation') } + rt.projectEffects?.register('attempt.continue', { run: row => ({ attemptId: row.payload.attemptId, status: 'durable-continuation' }) }, { replace: true }) rt.projectEffects?.register('integration.admit', { run: row => ({ admissionId: row.payload.admissionId, status: 'durable-input-admission' }) }, { replace: true }) rt.projectEffects?.register('github.publish', { validate: validatePublication, @@ -229,6 +230,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti attempt.status = status attempt.failure = { message: String(error?.message ?? error).slice(0, 1000), at: Date.now() } attempt.updatedAt = Date.now() + if (attempt.protocol === 'project-handoff/v1' && attempt.runId) rt.bindProject({ kind: 'run', id: attempt.runId, projectId: attempt.projectId, taskId: attempt.taskId, attemptId: attempt.id, status: 'failed' }) rt.taskDeliveryState?.(attempt.taskId, attempt.id, 'failed', attempt.failure.message) for (const lease of Object.values(attempt.resourceLeases ?? {})) await rt.projectResources?.release(lease.id, lease.generation) await rt.save() @@ -314,6 +316,36 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti await git(repository, ['worktree', 'add', '--detach', path, attempt.baseSha]); workspace.status = 'allocated'; await rt.save() } } + async function continuationState(attempt, actor, permission = 'configure') { + const task = taskOf(attempt.taskId) + rt.requireProjectAccess(actor, task.projectId, permission, task) + integrationReleased(attempt) + const snapshot = await integrationSnapshot(attempt), workspace = workspaceFor(attempt, 'developer') + const latestBaseSha = await git(settingsOf(projectOf(attempt.projectId, attempt)).path, ['rev-parse', 'HEAD']) + rt.requireProjectAccess(actor, task.projectId, permission, task) + if (task.deliveryAttemptId !== attempt.id) throw reject('Continuation Attempt changed during snapshot') + return { attemptId: attempt.id, generation: task.collaboration?.generation ?? 0, snapshot, latestBaseSha, baseSha: attempt.baseSha ?? null, seedSha: attempt.candidateSha ?? attempt.seedSha ?? attempt.baseSha ?? null, + retainedDraft: Boolean(snapshot && await workspaceGit(workspace, ['status', '--porcelain'])), inputs: structuredClone(attempt.integrationInputs ?? []), modes: ['continue', 'latest'] } + } + async function continuationOf(body, actor, task, previous, project, repositoryBinding) { + if (!previous || previous.protocol !== 'project-handoff/v1') { + if (body.continuation !== undefined) throw reject('Continuation needs an existing pool Attempt') + return null + } + if (body.continuation === undefined && !workspaceFor(previous, 'developer')) return null + const facts = await continuationState(previous, actor, body.continuation ? 'configure' : 'task-write'), selected = body.continuation + if (!selected) { + if (facts.retainedDraft) throw Object.assign(reject('Preserved draft requires an explicit continuation choice before starting another Attempt'), { code: 'bot/workspace-baseline', details: { kind: 'continuation-required', ...facts } }) + return null + } + if (!['continue', 'latest'].includes(selected.mode) || selected.previousAttemptId !== previous.id || selected.generation !== facts.generation || !isDeepStrictEqual(selected.expectedWorkspace, facts.snapshot) || selected.mode === 'latest' && selected.expectedBaseSha !== facts.latestBaseSha) throw reject('Continuation workspace or generation changed; fetch a fresh continuation snapshot') + if (previous.requirement.repository !== project.repository || JSON.stringify(previous.repositoryBinding) !== JSON.stringify(repositoryBinding)) throw reject('Continuation repository binding changed') + if (selected.mode === 'continue' && JSON.stringify(previous.requirement) !== JSON.stringify(requirementsOf(task, project.repository))) throw reject('Continuation requirements changed; choose a new baseline explicitly') + if (selected.mode === 'continue') await verifyIntegrationInputs(previous) + const currentFacts = await continuationState(previous, actor) + if (!isDeepStrictEqual(currentFacts, facts)) throw reject('Continuation workspace changed during admission') + return { version: 'project-continuation/v1', mode: selected.mode, previousAttemptId: previous.id, sourceWorkspaceId: facts.snapshot?.workspaceId ?? null, snapshot: facts.snapshot, generation: facts.generation, latestBaseSha: facts.latestBaseSha, baseSha: facts.baseSha, seedSha: facts.seedSha, retainedDraft: facts.retainedDraft, admissions: (previous.integrationAdmissions ?? []).map(row => row.id), by: actor.memberId, at: Date.now() } + } async function start(body, actor) { const task = taskOf(body.taskId) if (!task?.projectId) throw reject('A delivery needs a Project Task') @@ -325,7 +357,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const requestKey = hash(`${project.id}\n${task.id}\n${body.requestId}`) const prior = Object.values(attempts()).find(attempt => attempt.requestKey === requestKey) if (prior) { - if (body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw reject('Execution request content changed on replay') + if (body.continuation !== undefined && !isDeepStrictEqual(body.continuation, prior.continuationRequest ? JSON.parse(prior.continuationRequest) : undefined) || body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw reject('Execution request content changed on replay') return { attempt: view(prior), already: true } } if (Object.values(attempts()).some(attempt => attempt.taskId === task.id && (ongoing.has(attempt.status) || attempt.status === 'waiting-acceptance'))) throw reject('Task already has an active Attempt') @@ -334,16 +366,32 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const settings = await preflightRepository(rt, project) if (initialTarget) initialRoleTarget(rt, task, { initialTarget }) const previous = current(task.id) + const continuation = await continuationOf(body, actor, task, previous, project, repositoryBinding) const publicationBinding = await publicationOf(previous, project) + if (continuation) { + const currentFacts = await continuationState(previous, actor) + if (current(task.id) !== previous || currentFacts.generation !== continuation.generation || !isDeepStrictEqual(currentFacts.snapshot, continuation.snapshot) || continuation.mode === 'latest' && currentFacts.latestBaseSha !== continuation.latestBaseSha || continuation.mode === 'continue' && JSON.stringify(previous.requirement) !== JSON.stringify(requirementsOf(task, project.repository))) throw reject('Continuation changed before durable admission') + } const feedback = previous?.failure ? { previousAttemptId: previous.id, kind: previous.returned ? 'human-return' : 'execution-failure', reason: previous.failure.message, candidateSha: previous.candidateSha ?? null } : null const at = Date.now() const attempt = { id: `attempt-${randomUUID()}`, projectId: project.id, taskId: task.id, requestKey, workflowVersion: DELIVERY_VERSION, status: 'preparing', initiatedBy: actor.memberId, executionOwnerId: project.ownerUserId, repositoryBinding, requirement: requirementsOf(task, project.repository), ...(initialTarget ? { initialTarget } : {}), - ...(feedback ? { feedback } : {}), ...(publicationBinding ? { publicationBinding, seedSha: previous.candidateSha ?? previous.seedSha ?? publicationBinding.previousSha, baseSha: previous.baseSha } : {}), workspaces: {}, evidence: {}, createdAt: at, updatedAt: at } + ...(feedback ? { feedback } : {}), ...(continuation ? { continuation, continuationRequest: JSON.stringify(body.continuation), ...(continuation.mode === 'latest' ? { baseSha: continuation.latestBaseSha } : { baseSha: previous.baseSha, seedSha: previous.candidateSha ?? previous.seedSha ?? previous.baseSha, ...(previous.integrationInputs ? { integrationInputs: structuredClone(previous.integrationInputs), integrationAdmissions: structuredClone(previous.integrationAdmissions ?? []) } : {}) }) } : {}), ...(publicationBinding ? { publicationBinding, seedSha: previous.candidateSha ?? previous.seedSha ?? publicationBinding.previousSha, baseSha: continuation?.mode === 'latest' ? continuation.latestBaseSha : previous.baseSha } : {}), workspaces: {}, evidence: {}, createdAt: at, updatedAt: at } + if (continuation) { rt.requireProjectAccess(actor, task.projectId, 'configure', task); rt.projects.requireActive(task.projectId) } setEntry(attempts(), attempt.id, attempt) task.deliveryAttemptId = attempt.id - await rt.save() // Reserve idempotency and ownership before any filesystem effects. + if (continuation) { + if (!rt.projectEffects) throw reject('Continuation needs durable Effects') + const token = rt.projectEffects.stage({ kind: 'attempt.continue', key: requestKey, projectId: task.projectId, taskId: task.id, payload: { attemptId: attempt.id, previousAttemptId: previous.id, continuation } }) + attempt.continuation.effect = token + try { await rt.projectEffects.persist(token) } + catch (error) { + if (rt.projectEffects.discardUncommitted(token) && task.deliveryAttemptId === attempt.id) { delete attempts()[attempt.id]; task.deliveryAttemptId = previous.id } + throw error + } + } else await rt.save() // Reserve ownership before filesystem effects. + try { const definitions = rt.projectAgents?.deliveryDefinitions(project.id) attempt.agentDefinitions = definitions @@ -583,7 +631,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 { 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, + return { continuation: structuredClone(attempt.continuation ?? null), baselineIssue: attempt.preparationFailures?.findLast(row => row.code === 'bot/workspace-baseline') ?? null, 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' } } @@ -952,6 +1000,10 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (ownEntry(attempts(), body.attemptId) !== attempt || source() !== evidence) throw reject('Log source changed during query') return page } + if (endpoint === 'project-attempt-continuation-state') { + const { attempt } = integrationAttempt(body, actor) + return continuationState(attempt, actor) + } if (endpoint === 'project-attempt-integration-state') { const { attempt, task } = integrationAttempt(body, actor) integrationReleased(attempt) 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 a0afcb662..4da535d27 100644 --- a/patches/ds-bot/files/src/host/project-development-service.js +++ b/patches/ds-bot/files/src/host/project-development-service.js @@ -23,7 +23,7 @@ const operations = Object.freeze({ '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.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', + 'attempt.continuation-state': 'project-attempt-continuation-state', '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', 'queue.cancel': 'project-queue-cancel', 'queue.retry': 'project-queue-retry', @@ -35,7 +35,7 @@ const operations = Object.freeze({ 'execution.boundary': 'project-execution-boundary', 'pools': 'project-task-pools', 'effect.retry': 'project-effect-retry', 'comment.publish': 'project-github-comment', }) -const queries = new Set(['github.connection', 'inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) +const queries = new Set(['github.connection', 'inbox.list', 'inbox.get', 'inbox.connection', 'release.status', 'compatibility', 'roles', 'role.proposals', 'consultation.get', 'consultation.list', 'handoff.get', 'handoff.list', 'task.history', 'project.snapshot', 'events', 'command.receipt', 'command.result', 'tasks', 'task', 'task.detail', 'inputs', 'sources', 'execution.boundary', 'effects', 'attempt.context', 'attempt.continuation-state', 'attempt.integration-state', 'attempt.log', 'chief.state', 'pools']) const legacy = new Set(Object.values(operations)) const readEndpoints = new Set([...queries].map(name => operations[name]).filter(Boolean)) export const developmentQuery = body => record(body) && body.version === DEVELOPMENT_PROTOCOL && body.kind === 'query' && queries.has(body.name) @@ -166,7 +166,7 @@ export function createDevelopmentService(rt) { attemptsPage: { total: rows.length, limit, before: end > limit ? page[0].id : null } } } function context(body) { - if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures', 'resumptions', 'merge', 'sdkPublication', 'publicationRecoveries'].includes(body.section)) throw failure('Unknown Attempt context section') + if (!['roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures', 'continuation', 'resumptions', 'merge', 'sdkPublication', 'publicationRecoveries'].includes(body.section)) throw failure('Unknown Attempt context section') const attempt = ownEntry(rt.state.projectAttempts ?? {}, body.attemptId) const text = JSON.stringify(attempt[body.section] ?? null), offset = body.offset ?? 0, limit = body.limit ?? 8000 if (!Number.isSafeInteger(offset) || offset < 0 || offset > text.length || !Number.isSafeInteger(limit) || limit < 1 || limit > 16000) throw failure('Invalid context page') diff --git a/patches/ds-bot/files/src/host/project-scheduler.js b/patches/ds-bot/files/src/host/project-scheduler.js index 7d0ecff1d..d60cd4a42 100644 --- a/patches/ds-bot/files/src/host/project-scheduler.js +++ b/patches/ds-bot/files/src/host/project-scheduler.js @@ -1,3 +1,4 @@ +import { isDeepStrictEqual } from 'node:util' // Persisted Project admission queue. The existing Workflow service still dispatches Runs. import { createHash, randomUUID } from 'node:crypto' import { ownEntry, setEntry } from './store.js' @@ -104,18 +105,19 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = return { admission: structuredClone(task.projectTask.admission), waitingFor: 'chief' } } rt.projectTaskLifecycle?.requireExecutable(task) + if (body.continuation !== undefined) rt.requireProjectAccess(actor, project.id, 'configure', task) const execution = taskRepository(rt, project, task, body) await preflight(rt, execution.project) if (typeof body.requestId !== 'string' || !/^[A-Za-z0-9_-]{1,100}$/.test(body.requestId)) throw error('A run needs a stable requestId') const requestKey = createHash('sha256').update(`${project.id}\n${task.id}\n${body.requestId}`).digest('hex') const prior = Object.values(state.projectAttempts ?? {}).find(attempt => attempt.requestKey === requestKey) if (prior) { - if (body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw error('Execution request content changed on replay') + if (body.continuation !== undefined && !isDeepStrictEqual(body.continuation, prior.continuationRequest ? JSON.parse(prior.continuationRequest) : undefined) || body.repository !== undefined && body.repository !== prior.requirement?.repository || body.targetRole !== undefined && prior.initialTarget && body.targetRole !== prior.initialTarget.role) throw error('Execution request content changed on replay') return { attempt: structuredClone(prior), already: true } } const queued = Object.values(entries()).find(entry => entry.requestKey === requestKey || entry.taskId === task.id && entry.status === 'waiting') if (queued) { - if (body.repository !== undefined && queued.repositoryBinding && body.repository !== queued.repositoryBinding.repository || body.targetRole !== undefined && body.targetRole !== queued.initialTarget?.role) throw error('Queued request target cannot change on replay') + if (!isDeepStrictEqual(body.continuation, queued.continuation) || body.repository !== undefined && queued.repositoryBinding && body.repository !== queued.repositoryBinding.repository || body.targetRole !== undefined && body.targetRole !== queued.initialTarget?.role) throw error('Queued request target cannot change on replay') return { queue: structuredClone(queued), already: true } } if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended') @@ -124,7 +126,7 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = const resourceReason = resourceBlock(project.id, initialTarget) if (slots(project.id) < quota(project) && !resourceReason && !Object.values(entries()).some(row => row.projectId === project.id && row.status === 'waiting')) return rt.projectDelivery.endpoint('project-task-run', { ...body, repositoryBinding: execution.binding, ...(initialTarget ? { initialTarget } : {}) }, actor) const entry = { id: `queue-${randomUUID()}`, projectId: project.id, taskId: task.id, requestId: body.requestId, requestKey, - memberId: actor.memberId, repositoryBinding: execution.binding, ...(initialTarget ? { initialTarget } : {}), status: 'waiting', order: queueOrder(), ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } + memberId: actor.memberId, ...(body.continuation ? { continuation: structuredClone(body.continuation) } : {}), repositoryBinding: execution.binding, ...(initialTarget ? { initialTarget } : {}), status: 'waiting', order: queueOrder(), ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } setEntry(entries(), entry.id, entry) await rt.save() return { queue: structuredClone(entry) } @@ -176,7 +178,7 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = entry.updatedAt = Date.now() await rt.save() try { - const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId, repositoryBinding: entry.repositoryBinding, initialTarget: entry.initialTarget }, { memberId: entry.memberId }) + const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId, repositoryBinding: entry.repositoryBinding, initialTarget: entry.initialTarget, ...(entry.continuation ? { continuation: entry.continuation } : {}) }, { memberId: entry.memberId }) entry.status = result.attempt?.status === 'failed' ? 'failed' : 'started' entry.attemptId = result.attempt?.id ?? null if (result.attempt?.failure) entry.reason = result.attempt.failure.message diff --git a/patches/ds-bot/files/src/host/project-service-views.js b/patches/ds-bot/files/src/host/project-service-views.js index 16604c021..08835e0e1 100644 --- a/patches/ds-bot/files/src/host/project-service-views.js +++ b/patches/ds-bot/files/src/host/project-service-views.js @@ -55,6 +55,7 @@ export function attemptMetadataView(attempt) { const result = pick(attempt, ['id', 'protocol', 'projectId', 'taskId', 'workflowId', 'workflowVersion', 'runId', 'status', 'initiatedBy', 'baseSha', 'seedSha', 'candidateSha', 'branch', 'createdAt', 'updatedAt', 'acceptedAt', 'acceptedBy', 'acceptedViaChief', 'cancelRequested', 'supersededBy', 'roleAssignments']) if (attempt.requirement) result.requirement = { ...pick(attempt.requirement, ['repository', 'projectTaskRevision']), ...Object.fromEntries(['title', 'criteria', 'impact', 'scope', 'note'].filter(key => attempt.requirement[key] !== undefined).map(key => [key, short(attempt.requirement[key])])) } if (attempt.failure) result.failure = { ...pick(attempt.failure, ['at']), message: short(attempt.failure.message) } + if (attempt.continuation) result.continuation = pick(attempt.continuation, ['version', 'mode', 'previousAttemptId', 'sourceWorkspaceId', 'generation', 'baseSha', 'seedSha', 'retainedDraft', 'admissions', 'by', 'at']) if (attempt.feedback) result.feedback = { ...pick(attempt.feedback, ['previousAttemptId', 'kind', 'candidateSha']), reason: short(attempt.feedback.reason) } if (attempt.release) result.release = { ...pick(attempt.release, ['id', 'status', 'observedAt']), ...pick(attempt.release.spec, ['repository', 'tag', 'sha', 'mode']), sourceRef: { query: 'release.status', attemptId: attempt.id } } if (attempt.merge) result.merge = pick(attempt.merge, ['version', 'status', 'reason', 'effectId', 'candidateSha', 'mergeCommitSha', 'baseBranch', 'method', 'createdAt', 'observedAt', 'nextCheckAt', 'deadlineAt']) @@ -73,7 +74,7 @@ export function attemptMetadataView(attempt) { if (key === 'execution') result.evidence.execution = Object.fromEntries(Object.entries(row).slice(0, 32).map(([role, value]) => [role, evidence(value)])) else result.evidence[key] = evidence(row) } - result.sourceRefs = Object.fromEntries(['evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'merge', 'sdkPublication', 'publicationRecoveries'].map(section => [section, { query: 'attempt.context', attemptId: attempt.id, section }])) + result.sourceRefs = Object.fromEntries(['evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'merge', 'sdkPublication', 'publicationRecoveries', 'continuation'].map(section => [section, { query: 'attempt.context', attemptId: attempt.id, section }])) result.projectionVersion = 'project-attempt-summary/v1' result.preview = true return result diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index d6992f2f5..1c5106025 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -2105,3 +2105,94 @@ test('native exited claim and old Chief invocation use their existing exact exit assert.equal(c.claim.retirement.proof.chiefRunId, run.id) assert.equal(c.generation, generation + 1) }) + +for (const kind of ['local', 'docker']) test(kind + ' cancelled successor explicitly continues the original seed and draft without a PR binding', async t => { + const f = await baselineResourceFixture(t, kind) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + const oldSeed = f.previous.seedSha ?? f.previous.baseSha + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + assert.equal(f.previous.status, 'cancelled') + f.rt.state.projectWorkspaces = structuredClone(f.rt.state.projectWorkspaces) + const snapshot = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const started = await f.rt.projectDelivery.endpoint('project-task-run', { taskId: f.task.id, requestId: 'continue-cancelled', continuation: { mode: 'continue', previousAttemptId: f.previous.id, generation: snapshot.generation, expectedWorkspace: snapshot.snapshot } }, alice) + const next = f.rt.state.projectAttempts[started.attempt.id] + assert.notEqual(next.id, f.previous.id); assert.equal(next.seedSha, oldSeed) + assert.equal(next.continuation.previousAttemptId, f.previous.id) + assert.equal(next.continuation.sourceWorkspaceId, f.workspace.id) + assert.equal(next.publicationBinding, undefined); assert.equal(next.evidence.tests, undefined) + await prepareRole(f.rt, f.task, { id: 'continued', attemptId: next.id, generation: 20 }, f.definition) + assert.equal(next.workspaces.developer.path, f.workspace.path) + assert.equal(git(next.workspaces.developer.path, ['rev-parse', 'HEAD']), oldSeed) + assert.match(readFileSync(join(f.workspace.path, 'code.mjs'), 'utf8'), /preservedDraft/) + assert.equal(f.previous.status, 'cancelled') +}) + +test('draft continuation is an explicit durable choice and never inherits stale inputs or tests', async t => { + const f = await baselineResourceFixture(t) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + const query = () => f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const snapshot = await query(), before = Object.keys(f.rt.state.projectAttempts).length + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', { taskId: f.task.id, requestId: 'no-choice' }, alice), error => error.code === 'bot/workspace-baseline' && error.details?.kind === 'continuation-required') + assert.equal(Object.keys(f.rt.state.projectAttempts).length, before) + const continuation = { mode: 'continue', previousAttemptId: f.previous.id, generation: snapshot.generation, expectedWorkspace: snapshot.snapshot } + const body = { taskId: f.task.id, requestId: 'chosen-continuation', continuation } + writeFileSync(join(f.workspace.path, 'new-draft.txt'), 'Concurrent draft edit\n') + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', body, alice), /workspace or generation changed/) + continuation.expectedWorkspace = (await query()).snapshot + const save = f.rt.save; f.rt.save = async () => {} + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', body, alice), /commit was not confirmed/) + assert.equal(f.task.deliveryAttemptId, f.previous.id) + assert.equal(Object.keys(f.rt.state.projectAttempts).length, before) + f.rt.save = save + f.previous.integrationInputs = [{ version: 'project-integration-input/v1', repository: 'example/demo', ref: 'refs/remotes/operator/moved', sha: f.latest }] + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', body, alice), /git|source|reference|ref|command/i) + delete f.previous.integrationInputs + const result = await f.rt.projectDelivery.endpoint('project-task-run', body, alice) + assert.equal((await f.rt.projectDelivery.endpoint('project-task-run', body, alice)).already, true) + await assert.rejects(f.rt.projectDelivery.endpoint('project-task-run', { ...body, continuation: { ...continuation, mode: 'latest' } }, alice), /content changed/) + const next = f.rt.state.projectAttempts[result.attempt.id] + assert.ok(f.rt.projectEffects.intentCommitted(next.continuation.effect)) + assert.equal(next.evidence.tests, undefined) +}) + +test('latest-baseline successor preserves draft and reports the old workspace before explicit integration', async t => { + const f = await baselineResourceFixture(t) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + const saved = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const result = await f.rt.projectDelivery.endpoint('project-task-run', { taskId: f.task.id, requestId: 'choose-latest', continuation: { mode: 'latest', expectedBaseSha: saved.latestBaseSha, previousAttemptId: f.previous.id, generation: saved.generation, expectedWorkspace: saved.snapshot } }, alice) + const next = f.rt.state.projectAttempts[result.attempt.id] + assert.equal(next.baseSha, f.latest) + await assert.rejects(prepareRole(f.rt, f.task, { id: 'latest-admission', attemptId: next.id, generation: 20 }, f.definition), error => error.code === 'bot/workspace-baseline') + const recovery = await f.rt.projectDelivery.recoveryFacts(f.task) + assert.equal(recovery.continuation.sourceWorkspaceId, f.workspace.id) + assert.equal(recovery.baselineIssue.details.workspaceId, f.workspace.id) + assert.equal(Object.keys(next.workspaces).length, 0) + assert.match(readFileSync(join(f.workspace.path, 'code.mjs'), 'utf8'), /preservedDraft/) + assert.equal(next.integrationInputs, undefined) +}) + +test('capacity queue preserves explicit continuation and rejects changed replay intent', async t => { + const f = await baselineResourceFixture(t) + writeFileSync(join(f.workspace.path, 'code.mjs'), 'export const preservedDraft = true;\n') + f.previous.status = 'running' + await f.rt.projectEndpoint('project-attempt-stop', { attemptId: f.previous.id }, alice) + const saved = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { projectId: 'p1', attemptId: f.previous.id }, alice) + const continuation = { mode: 'continue', previousAttemptId: f.previous.id, generation: saved.generation, expectedWorkspace: saved.snapshot } + f.rt.state.projects.p1.quota = { maxConcurrent: 1 } + f.rt.state.projectAttempts.busy = { id: 'busy', projectId: 'p1', taskId: 'other', status: 'running' } + const body = { taskId: f.task.id, requestId: 'queued-continuation', continuation } + const result = await f.rt.projectScheduler.request(body, alice) + assert.deepEqual(result.queue.continuation, continuation) + await assert.rejects(f.rt.projectScheduler.request({ ...body, continuation: { ...continuation, mode: 'latest' } }, alice), /target cannot change/) + delete f.rt.state.projectAttempts.busy + await f.rt.projectScheduler.drain() + const row = f.rt.state.projectQueue[result.queue.id] + assert.equal(row.status, 'started', row.reason) + assert.equal(f.rt.state.projectAttempts[row.attemptId].seedSha, saved.seedSha) + assert.equal(f.previous.status, 'cancelled') +}) diff --git a/scripts/api-contracts.mjs b/scripts/api-contracts.mjs index bd55a8fa3..c7cf5b222 100644 --- a/scripts/api-contracts.mjs +++ b/scripts/api-contracts.mjs @@ -186,7 +186,8 @@ q('pools', empty, result({ pools: array(record({ botId: id, role, tasks: array(j q('inputs', obj({ ...taskId, ...page, limit: int(1, 200) }, ['taskId']), record({ inputs: array(ref('Input')), next: nullable(id) }), { source: 'core/src/host/project-inputs.js', notes: 'Receipt states distinguish persisted, delivered, model-declared-read and handled; none imply Task completion.' }) q('sources', obj({ threadId: id, eventId: id, offset: int(), ...page }), record({ sources: array(json), threads: array(json), subscriptions: array(json), events: array(json), next: nullable(id), event: json, text: str(), offset: int(), nextOffset: nullable(int()), sha256: hash }), { source: 'core/src/host/project-sources.js' }) q('execution.boundary', obj(taskId), record({ projectId: id, profile: json, authority: json, task: nullable(json), repository: json, resources: array(json), harnesses: json, limits: json }), { source: 'core/src/host/project-execution-boundary.js', notes: 'Reports effective current boundaries; it neither runs tests nor grants tools/credentials.' }) -q('attempt.context', obj({ ...attemptId, section: choice('roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures', 'resumptions', 'merge', 'sdkPublication', 'publicationRecoveries'), ...sourcePage }, ['attemptId', 'section']), ref('SourcePage')) +q('attempt.context', obj({ ...attemptId, section: choice('roleSessions', 'roleSessionHistory', 'rolePolicies', 'evidence', 'feedback', 'requirement', 'failure', 'agentDefinitions', 'integrationAdmissions', 'testHistory', 'preparationFailures', 'continuation', 'resumptions', 'merge', 'sdkPublication', 'publicationRecoveries'), ...sourcePage }, ['attemptId', 'section']), ref('SourcePage')) +q('attempt.continuation-state', obj(attemptId, ['attemptId']), result({ attemptId: id, generation: int(), snapshot: json, latestBaseSha: sha, baseSha: nullable(sha), seedSha: nullable(sha), retainedDraft: bool, inputs: array(json), modes: array(choice('continue', 'latest')) }), { permission: 'configure', source: 'core/src/host/project-delivery.js', preconditions: ['current-attempt', 'confirmed-execution-exit'], notes: 'Read continuation facts even after cancellation. task.run selects a successor, never revives the cancelled Attempt.' }) q('attempt.integration-state', obj(attemptId, ['attemptId']), result({ attemptId: id, generation: int(), inputs: array(json), availableInputs: array(json), materialization: nullable(json), snapshot: json }), { permission: 'configure', source: 'core/src/host/project-delivery.js', preconditions: ['current-project-manager', 'current-released-integration-checkpoint'], notes: 'Although a query, the downstream integration checkpoint gate requires manager authority and confirmed release.' }) q('attempt.log', obj({ ...attemptId, role, environmentRole: role, offset: int(), limit: int(4, 8192), expectedSha256: hash }, ['attemptId']), ref('LogPage'), { source: 'core/src/host/project-delivery.js', notes: 'UTF-8 byte offsets, not character offsets; nextOffset preserves character boundaries. Rechecks rights after source I/O. No caller path. environmentRole selects that role workspace latest preparation log and is mutually exclusive with SDK execution role. Preparation is not tests or inference.' }) q('release.status', obj(attemptId, ['attemptId']), result({ release: nullable(ref('Release')) }), { source: 'core/src/host/project-releases.js', preconditions: ['current-project-membership', 'current-release-task-attempt'] }) @@ -411,3 +412,7 @@ schemas.Terminal.properties.profile = obj({ ...schemas.HarnessProfile.properties schemas.Terminal.properties.codexLoginAvailable = bool native.get('project-runtime-check').notes += ' Health is bound to the actual selected profile. Legacy/changed profile readiness must be checked again. authRequired preserves a safe SDK error code; it is not a code-test/Review failure or a Task routing decision.' native.get('project-terminal-open').notes += ' Returns the actual HOME/configDir only to this authorized administrator. Codex CLI resolution uses the resource SDK installation; re-login happens in that terminal CODEX_HOME. No host auth cache is copied.' + +commands.get('task.run').request.properties.continuation = obj({ mode: choice('continue', 'latest'), expectedBaseSha: sha, previousAttemptId: id, generation: int(), expectedWorkspace: json }, ['mode', 'previousAttemptId', 'generation', 'expectedWorkspace']) +schemas.Continuation = obj({ version: lit('project-continuation/v1'), mode: choice('continue', 'latest'), previousAttemptId: id, sourceWorkspaceId: nullable(id), generation: int(), baseSha: nullable(sha), seedSha: nullable(sha), retainedDraft: bool, admissions: array(id), by: id, at: int() }, ['version', 'mode', 'previousAttemptId', 'sourceWorkspaceId', 'generation', 'retainedDraft']) +schemas.Attempt.properties.continuation = ref('Continuation')