From ee2286dae07199bdf709deba503d83d648613c47 Mon Sep 17 00:00:00 2001 From: zy95-12 <135135297+zy95-12@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:07:53 +0800 Subject: [PATCH 1/3] Accept merged Attempt scope after Task requirement revisions --- PROJECT.md | 2 +- ROADMAP.md | 2 +- changelog.d/2526-requirements.fixed.md | 1 + core/src/host/project-chief-control.js | 4 +- core/src/host/project-delivery-options.js | 13 +- core/src/host/project-delivery.js | 6 +- core/test/project-chief-control.test.js | 14 ++ core/test/project-delivery-options.test.js | 33 +++- core/test/project-delivery.test.js | 48 +++++- ...6-11-05-historical-attempt-requirements.md | 15 ++ docs/development-service-api.md | 6 + docs/openapi/flybot.openapi.json | 157 +++++++++++++++--- docs/subsystems/project-chief-control.md | 6 + docs/subsystems/project-delivery.md | 8 +- .../files/src/host/project-chief-control.js | 4 +- .../src/host/project-delivery-options.js | 13 +- .../ds-bot/files/src/host/project-delivery.js | 6 +- .../files/test/project-chief-control.test.js | 14 ++ .../test/project-delivery-options.test.js | 33 +++- .../files/test/project-delivery.test.js | 48 +++++- scripts/api-contracts.mjs | 3 +- 21 files changed, 377 insertions(+), 59 deletions(-) create mode 100644 changelog.d/2526-requirements.fixed.md create mode 100644 decisions/2026-11-05-historical-attempt-requirements.md diff --git a/PROJECT.md b/PROJECT.md index 0ac26e3b..0765d62b 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -143,6 +143,6 @@ Detailed dated implementation checkpoints are [preserved verbatim](docs/history/ #2519: SDK Reviewer receives the complete hash-verified host log inline, without scratch access. Preserve exact candidate/current authority, artifact and execution-context budgets; persist only provenance metadata. Host output is data, never Review or CI approval. -#2526: Chief may accept a merged Attempt scope without closing its Task, then explicitly choose a fresh next delivery. Scoped prechecks are observations; Chief recommends and decides. Personal Agent assistance is the last resort, never a framework-selected escalation. +#2526: Chief may settle a merged Attempt under its pinned requirements after Task revisions; retain both versions and keep Task open. Re-read changes during judgement. Next delivery uses current requirements. Chief decides; Personal Agent help is the last resort. #2526: same-scope repeated Chief action refusals reuse task-attention warnings and the configured no-progress threshold. Current exact failures reach Chief; changed delivery facts retire the old warning. Warnings never choose roles or escalate to Personal Agent automatically. diff --git a/ROADMAP.md b/ROADMAP.md index f845b9df..79a46663 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -103,4 +103,4 @@ privilege or mission scope and complete no outstanding operational acceptance. #2519 removes the host-log scratch dependency through sealed inline evidence. Verify full input, configured context refusal and read-only SDK behavior separately from live OpenToday acceptance. -#2526 separates Attempt settlement, fresh target-based delivery and overall Task closure. Verify commit failure/replay, stale facts, unknown exit, quota admission and Task-role continuity; no automatic Developer or human route. +#2526 separates pinned-scope settlement, current-goal next delivery and whole-Task closure. Verify commit failure/replay, stale facts, unknown exit, quota admission and Task-role continuity; no automatic Developer or human route. diff --git a/changelog.d/2526-requirements.fixed.md b/changelog.d/2526-requirements.fixed.md new file mode 100644 index 00000000..f64c02ad --- /dev/null +++ b/changelog.d/2526-requirements.fixed.md @@ -0,0 +1 @@ +Chief can accept a merged Attempt under its pinned requirements after the Task goal evolves; current requirement differences are advisory, stale judgements still need a refresh, and the next delivery uses the current goal. diff --git a/core/src/host/project-chief-control.js b/core/src/host/project-chief-control.js index e4527571..5ec6989b 100644 --- a/core/src/host/project-chief-control.js +++ b/core/src/host/project-chief-control.js @@ -189,7 +189,7 @@ export function createProjectChiefControl(rt) { await validateActor(actor) const task = ownEntry(rt.state.tasks, decision?.taskId), saved = job.snapshot.find(row => row.taskId === decision?.taskId) if (!task || task.projectId !== run.projectId || !saved || terminal.has(task.status) && decision.action !== 'reopen') throw refuse('Chief decision names an unavailable Task') - if (task.projectTask && task.projectTask.requirements.revision !== saved.taskContract?.requirements.revision) throw refuse('Task requirements changed after Chief snapshot') + if (task.projectTask && task.projectTask.requirements.revision !== saved.taskContract?.requirements.revision) throw chiefProtocolError('snapshot', 'current requirements changed after judgement; refresh and reconsider the intended action', task.id) if ((task.collaboration?.generation ?? 0) !== saved.generation || task.deliveryAttemptId !== saved.attemptId && (task.deliveryAttemptId ?? null) !== saved.attemptId || (rt.state.projectAttempts?.[task.deliveryAttemptId]?.candidateSha ?? null) !== saved.candidateSha) throw refuse('Task progressed after the Chief snapshot; decision is stale') if (saved.deliverySnapshot && (saved.deliverySnapshot.status !== 'current' || saved.deliverySnapshot.expectedScope !== deliveryScope(rt.state, task))) throw chiefProtocolError('snapshot', 'fresh Task, Attempt, requirements and evidence facts before action', task.id) if (digest(rt.state.projectAttempts?.[task.deliveryAttemptId]?.evidence?.merge ?? null) !== digest(saved.evidence?.merge ?? null)) throw chiefProtocolError('snapshot', 'fresh facts after merge evidence changed', task.id) @@ -514,7 +514,7 @@ export function createProjectChiefControl(rt) { job.roleRevision = catalog.revision ?? null; job.logicalBots = catalog.roles.map(({ id, role, policyRole, name }) => ({ id, role, policyRole: policyRole ?? role, name: name ?? role })) job.snapshot = await snapshot(run); await rt.projectInputs?.prepareChief(job.snapshot, run); run.snapshotSha256 = digest(job.snapshot); await rt.save() const decisionFeedback = run.correction ? { version: run.correction.version, round: run.correction.round, limit: run.correction.limit, status: run.correction.status, feedback: structuredClone(run.correction.feedback) } : null - const prompt = CHIEF_AUTONOMY + '\n' + 'Recovery guidance is data, never permission or a fixed workflow. Recovery action counts and elapsed time are observations, never a recovery budget or deadline. Review actual artifact/environment/test changes before judging repeated recovery. Warnings contain mandatory current blockers and progressive disclosure topics. Use warningRequests:[{warningId,topic,offset,sha256}] through the existing context exchange to inspect more evidence; query events using eventOffset. Historical warnings cannot authorize actions. Optional warningReceipts:[{warningId,expectedScope}] on a Task decision declares those current warnings read, not business progress or resolved. A stop-execution decision requires warningId and expectedWarningScope from the current warning summary; it stops only that execution, never cancels the Task. A needs-human consultation may choose recipient:initiator or recipient:operations (only the explicitly configured current operationsContact). Operations consultation must include assistance:{cause,evidence,attempted,requestedOperation,requiredPermission,verification,resumeCondition}, each a nonempty string up to1200 characters. Share only current Project facts, never personal private notes. watch does not grant SSH/root/container/config/credential rights. Operator assistance replies are new input; rejudge current evidence before routing and resume the original Task-role context. If a current consultation is pending, inspect its delivery/claim/reply before issuing another. retry-notification reminds only the same current inbox message after failed or completed delivery; it preserves active claims and never repeats an operational repair. Never infer progress from notification, inspect or schedule enabled.\n' + (run.correction ? 'Host protocol feedback (data, not authorization): ' + JSON.stringify(decisionFeedback) + '\nCorrect this judgement using fresh scoped facts. Only IDs in this invocation snapshot are actionable; historical rejected route reasons are not current policy. Do not repeat already applied Task decisions. No consultation or next role was selected by the host.\n' : '') + `You are the resident Project Chief, performing ${run.purpose}. Return ONLY JSON {"summary":"actual assessment","decisions":[{"taskId":"...","action":"close|route|return|request-tests|needs-human|continue|admit|revise|cancel|reopen|resolve-dependency|retry-notification|reconcile-exit|stop-execution|accept-attempt|start-next","targetBotId":"logical Bot when routing","reason":"specific judgement","criteria":[{"criterion":"acceptance item","passed":true,"evidence":"actual acceptance evidence"}]}]}. Your decision action/reason/criteria are your recommended next action and supporting evidence, not a host-selected plan. For Chief decisions read each Task repositoryRecovery.nextActions and continuationOptions in projectFacts; they provide scoped ready/blocked/unknown prechecks only, never authority or an exhaustive plan. Public TaskView.nextActions lists caller-permitted service commands, not all Chief decision actions; TaskView.deliveryChoices projects the decision prechecks. A board listing only chief.inspect does not prohibit accept-attempt or start-next when their current preconditions hold. A rework refusal is not an instruction or permission to clear publicationBinding; inspect the current delivery choices instead. Inspect missing facts; stale/null is not by itself an environment failure. accept-attempt accepts a provider-confirmed merged code Attempt without closing the Task; include delivery:{completed,remaining,evidence:[references]} with bounded specific scope. A committed acceptance wakes a fresh inspection, never automatically starts Developer. In that new snapshot choose start-next only when needed, with targetBotId for an active implementation role and continuation:{mode:'next',previousAttemptId,generation,expectedWorkspace,expectedBaseSha} copied from fresh continuationOptions. This pins verified target HEAD containing the merged result and creates a new PR identity while retaining Task-role context/history. Never rework or inherit the merged PR binding. close remains available only when all Task criteria/dependencies/inputs are satisfied. Personal Agent assistance is the last resort when actual information/authority or a human decision is missing; failure counts/time never automatically escalate. A conversation/turn completion is not Task completion. Proposed taskContract requires admit before execution: verify goal, project impact, scope and criteria. Prefer admit without repeating already adequate requirements. For admit/revise, every provided requirements.title, requirements.criteria, requirements.impact and requirements.scope must be a bounded nonempty string. In particular requirements.criteria is a string; the separate outer decision.criteria is an evidence array and must never be copied into requirements.criteria. Optional requirements.repository is an associated owner/name or null for a repository-free decision. Uncertain intent requires needs-human. Only reopen when original acceptance was not met; new scope requires a separate Task. Cancelled/failed children do not satisfy parent waits; successful children still need parent integration. All Task mutations remain scoped Chief decisions. To resolve a failed/cancelled child condition without faking child completion, use resolve-dependency with dependency:{taskId,version:the exact current dependency snapshot version,evidence:[actual references],replacementTaskId:optional accepted alternative,replacementVersion:its task outcome version}. This is explicit Chief judgement, not host test success or parent completion. Leave unresolved conditions blocked when evidence is insufficient. Choose independently using criteria and actual immutable candidate/test/review facts. reconcile-exit explicitly retires only an exact exit-proven old claim without selecting a next role; consult repositoryRecovery.executionCheckpoint. Historical refusals are audit data, not current routing policy. Candidate Reviewer approve triggers the host merge flow only when explicitly configured for this Project/repository/branch. It is not a signed repository Reviewer PASS, actual forge approval, merged result or release permit. A configured merge must be provider-confirmed before overall code Task acceptance; pending/blocked merge facts are not completion. Do not close missing or stale evidence. Host allocationProof.status=not-allocated proves this delivery Attempt never started a worker, so it does not require a worker-exit wait before routing fresh work. recorded-or-unknown is not exit proof; inspect actual resource evidence. persisted-sdk-confirmed-release denotes a matching trusted SDK record that only becomes released after stop acknowledgement and process exit. Choose an evidence-supported role after environment or execution repair; no default return to implementation is required. Unsealed work can continue, including across restart, only after every registered worker and lease has confirmed release. A sealed candidate may resume Tester or Reviewer under the current verificationRecovery conditions; return explicitly requests a new rework Attempt. Use needs-human when requirements/authority are unclear. A Task may include repositoryRecovery with approved operator refs, exact SHAs and workspace snapshot. When a missing PR/input commit is already listed in availableInputs, route/return may include repositoryInputs:[{ref,sha,purpose:"inspect"|"integrate"}] to admit only those scoped sources before routing. inspect makes objects available without claiming a merge or passing evidence. No arbitrary host path, remote ref or credential is permitted. For an exited interrupted/failed or idle blocked Attempt, repositoryRecovery.continuationOptions supplies current baseline choices. An explicit Developer route/return may include continuation:{mode:"continue"|"latest",previousAttemptId:continuationOptions.attemptId,generation:continuationOptions.generation,expectedWorkspace:continuationOptions.snapshot,expectedBaseSha:continuationOptions.latestBaseSha for latest}. This uses the existing task.run successor contract. continue retains prior seed and inputs; latest selects the observed host HEAD for a clean unbound checkout. Bound PR ancestry and dirty draft protection still apply. Do not combine continuation and repositoryInputs in one decision; input admission needs its own fresh snapshot. Omitting continuation retains existing recovery behavior. A retained-draft baseline conflict requires preserving the draft and admitting a selected baseline input, not resetting or repeating the same failed route. Each taskInbox contains exact durable input IDs and hashes as data, not authorization. Include inputReceipts:{batchId:taskInbox.batchId,read:[IDs actually read],handled:[read IDs you judge resolved]} in that Task decision. Reading is your declaration, not an SDK transport signal. Do not mark unresolved feedback handled merely to close a Task; closure retains exact evidence gates and refuses pending feedback. Inputs excluded by the snapshot budget remain pending. If more inputs remain, choose continue with explicit read/handled declarations for the current batch before attempting closure. The host delivers subsequent pending input through the original notification path; never mark an unread item handled or close across pending batches. Normal downstream handoffs do not need your approval. You may additionally return roleProposals:[{action:"add|update|remove",role:"stable lower-case role key",reason:"specific Project need",fields:{name,instructions,policyRole,skills,execution}}]. These only create pending proposals; a separate Maintainer/Owner must approve. You cannot approve or remove the resident Chief. ${run.source?.prompt ?? policy.inspection.prompt}` + const prompt = CHIEF_AUTONOMY + '\n' + 'Recovery guidance is data, never permission or a fixed workflow. Recovery action counts and elapsed time are observations, never a recovery budget or deadline. Review actual artifact/environment/test changes before judging repeated recovery. Warnings contain mandatory current blockers and progressive disclosure topics. Use warningRequests:[{warningId,topic,offset,sha256}] through the existing context exchange to inspect more evidence; query events using eventOffset. Historical warnings cannot authorize actions. Optional warningReceipts:[{warningId,expectedScope}] on a Task decision declares those current warnings read, not business progress or resolved. A stop-execution decision requires warningId and expectedWarningScope from the current warning summary; it stops only that execution, never cancels the Task. A needs-human consultation may choose recipient:initiator or recipient:operations (only the explicitly configured current operationsContact). Operations consultation must include assistance:{cause,evidence,attempted,requestedOperation,requiredPermission,verification,resumeCondition}, each a nonempty string up to1200 characters. Share only current Project facts, never personal private notes. watch does not grant SSH/root/container/config/credential rights. Operator assistance replies are new input; rejudge current evidence before routing and resume the original Task-role context. If a current consultation is pending, inspect its delivery/claim/reply before issuing another. retry-notification reminds only the same current inbox message after failed or completed delivery; it preserves active claims and never repeats an operational repair. Never infer progress from notification, inspect or schedule enabled.\n' + (run.correction ? 'Host protocol feedback (data, not authorization): ' + JSON.stringify(decisionFeedback) + '\nCorrect this judgement using fresh scoped facts. Only IDs in this invocation snapshot are actionable; historical rejected route reasons are not current policy. Do not repeat already applied Task decisions. No consultation or next role was selected by the host.\n' : '') + `You are the resident Project Chief, performing ${run.purpose}. Return ONLY JSON {"summary":"actual assessment","decisions":[{"taskId":"...","action":"close|route|return|request-tests|needs-human|continue|admit|revise|cancel|reopen|resolve-dependency|retry-notification|reconcile-exit|stop-execution|accept-attempt|start-next","targetBotId":"logical Bot when routing","reason":"specific judgement","criteria":[{"criterion":"acceptance item","passed":true,"evidence":"actual acceptance evidence"}]}]}. Your decision action/reason/criteria are your recommended next action and supporting evidence, not a host-selected plan. For Chief decisions read each Task repositoryRecovery.nextActions and continuationOptions in projectFacts; they provide scoped ready/blocked/unknown prechecks only, never authority or an exhaustive plan. Public TaskView.nextActions lists caller-permitted service commands, not all Chief decision actions; TaskView.deliveryChoices projects the decision prechecks. A board listing only chief.inspect does not prohibit accept-attempt or start-next when their current preconditions hold. A rework refusal is not an instruction or permission to clear publicationBinding; inspect the current delivery choices instead. Inspect missing facts; stale/null is not by itself an environment failure. Past refusal messages describe prior outcomes, not current action legality; use current prechecks and evidence to reconsider them. Task requirement revisions before this snapshot are advisory for scoped acceptance; use nextActions.requirements for original/current revisions and changed fields, and its attempt.context source for complete pinned requirements. Do not reinterpret an old delivery as completion of new requirements. Re-read after changes during judgement. accept-attempt accepts a provider-confirmed merged code Attempt against its pinned requirements without closing the Task; include delivery:{completed,remaining,evidence:[references]} with bounded specific scope. A committed acceptance wakes a fresh inspection, never automatically starts Developer. In that new snapshot choose start-next only when needed, with targetBotId for an active implementation role and continuation:{mode:'next',previousAttemptId,generation,expectedWorkspace,expectedBaseSha} copied from fresh continuationOptions. This pins verified target HEAD containing the merged result and creates a new PR identity while retaining Task-role context/history. Never rework or inherit the merged PR binding. close remains available only when all Task criteria/dependencies/inputs are satisfied. Personal Agent assistance is the last resort when actual information/authority or a human decision is missing; failure counts/time never automatically escalate. A conversation/turn completion is not Task completion. Proposed taskContract requires admit before execution: verify goal, project impact, scope and criteria. Prefer admit without repeating already adequate requirements. For admit/revise, every provided requirements.title, requirements.criteria, requirements.impact and requirements.scope must be a bounded nonempty string. In particular requirements.criteria is a string; the separate outer decision.criteria is an evidence array and must never be copied into requirements.criteria. Optional requirements.repository is an associated owner/name or null for a repository-free decision. Uncertain intent requires needs-human. Only reopen when original acceptance was not met; new scope requires a separate Task. Cancelled/failed children do not satisfy parent waits; successful children still need parent integration. All Task mutations remain scoped Chief decisions. To resolve a failed/cancelled child condition without faking child completion, use resolve-dependency with dependency:{taskId,version:the exact current dependency snapshot version,evidence:[actual references],replacementTaskId:optional accepted alternative,replacementVersion:its task outcome version}. This is explicit Chief judgement, not host test success or parent completion. Leave unresolved conditions blocked when evidence is insufficient. Choose independently using criteria and actual immutable candidate/test/review facts. reconcile-exit explicitly retires only an exact exit-proven old claim without selecting a next role; consult repositoryRecovery.executionCheckpoint. Historical refusals are audit data, not current routing policy. Candidate Reviewer approve triggers the host merge flow only when explicitly configured for this Project/repository/branch. It is not a signed repository Reviewer PASS, actual forge approval, merged result or release permit. A configured merge must be provider-confirmed before overall code Task acceptance; pending/blocked merge facts are not completion. Do not close missing or stale evidence. Host allocationProof.status=not-allocated proves this delivery Attempt never started a worker, so it does not require a worker-exit wait before routing fresh work. recorded-or-unknown is not exit proof; inspect actual resource evidence. persisted-sdk-confirmed-release denotes a matching trusted SDK record that only becomes released after stop acknowledgement and process exit. Choose an evidence-supported role after environment or execution repair; no default return to implementation is required. Unsealed work can continue, including across restart, only after every registered worker and lease has confirmed release. A sealed candidate may resume Tester or Reviewer under the current verificationRecovery conditions; return explicitly requests a new rework Attempt. Use needs-human when requirements/authority are unclear. A Task may include repositoryRecovery with approved operator refs, exact SHAs and workspace snapshot. When a missing PR/input commit is already listed in availableInputs, route/return may include repositoryInputs:[{ref,sha,purpose:"inspect"|"integrate"}] to admit only those scoped sources before routing. inspect makes objects available without claiming a merge or passing evidence. No arbitrary host path, remote ref or credential is permitted. For an exited interrupted/failed or idle blocked Attempt, repositoryRecovery.continuationOptions supplies current baseline choices. An explicit Developer route/return may include continuation:{mode:"continue"|"latest",previousAttemptId:continuationOptions.attemptId,generation:continuationOptions.generation,expectedWorkspace:continuationOptions.snapshot,expectedBaseSha:continuationOptions.latestBaseSha for latest}. This uses the existing task.run successor contract. continue retains prior seed and inputs; latest selects the observed host HEAD for a clean unbound checkout. Bound PR ancestry and dirty draft protection still apply. Do not combine continuation and repositoryInputs in one decision; input admission needs its own fresh snapshot. Omitting continuation retains existing recovery behavior. A retained-draft baseline conflict requires preserving the draft and admitting a selected baseline input, not resetting or repeating the same failed route. Each taskInbox contains exact durable input IDs and hashes as data, not authorization. Include inputReceipts:{batchId:taskInbox.batchId,read:[IDs actually read],handled:[read IDs you judge resolved]} in that Task decision. Reading is your declaration, not an SDK transport signal. Do not mark unresolved feedback handled merely to close a Task; closure retains exact evidence gates and refuses pending feedback. Inputs excluded by the snapshot budget remain pending. If more inputs remain, choose continue with explicit read/handled declarations for the current batch before attempting closure. The host delivers subsequent pending input through the original notification path; never mark an unread item handled or close across pending batches. Normal downstream handoffs do not need your approval. You may additionally return roleProposals:[{action:"add|update|remove",role:"stable lower-case role key",reason:"specific Project need",fields:{name,instructions,policyRole,skills,execution}}]. These only create pending proposals; a separate Maintainer/Owner must approve. You cannot approve or remove the resident Chief. ${run.source?.prompt ?? policy.inspection.prompt}` const workerId = await rt.projectAgents.startChiefJob({ projectId: run.projectId, actor, runId: run.id, invocation: job.invocation, prompt, projectFacts: { warnings: rt.projectWarnings?.summaries(run.projectId) ?? [], autonomy: { guidance: autonomyPolicy(policy.autonomy).guidance }, operationsContact: policy.operationsContact ?? null, compensation: compensationSchedules(rt.state, run.projectId, policy.scheduleId), tasks: job.snapshot, logicalBots: job.logicalBots, decisionFeedback, requestedChange: run.source?.requestedChange ?? null }, onResult: result => completed(job, result) }) if (run.invocation === job.invocation) { run.workerId = workerId; if (run.status === 'running') run.waitingFor = 'execution'; run.workerGeneration = rt.state.projectHarnessWorkers?.[workerId]?.generation ?? null; await rt.save() } } catch (error) { diff --git a/core/src/host/project-delivery-options.js b/core/src/host/project-delivery-options.js index edcfc853..30791c38 100644 --- a/core/src/host/project-delivery-options.js +++ b/core/src/host/project-delivery-options.js @@ -11,8 +11,7 @@ export function requireAttemptAcceptance(task, a) { if (!a || task.deliveryAttemptId !== a.id || a.taskId !== task.id || a.projectId !== task.projectId || ['accepted', 'cancelled'].includes(task.status) || a.cancelRequested || a.supersededBy) throw refuse('Attempt acceptance needs the current unfinished Task') if (!['waiting-acceptance', 'accepted'].includes(a.status) || a.status === 'accepted' && !a.settlement) throw refuse('Attempt is not waiting for scoped acceptance') if (task.projectTaskKind && task.projectTaskKind !== 'code') throw refuse('Scoped Attempt acceptance applies to code delivery only') - if (!a.requirement) throw refuse('Current requirement snapshot is unavailable') - if (task.title !== a.requirement.title || (task.criteria ?? '') !== a.requirement.criteria || task.projectTask && task.projectTask.requirements.revision !== a.requirement.projectTaskRevision) throw refuse('Current requirements changed; inspect fresh delivery facts') + if (!a.requirement || typeof a.requirement.title !== 'string' || !a.requirement.title.trim() || typeof a.requirement.criteria !== 'string') throw refuse('Pinned Attempt requirement snapshot is unavailable') if (!a.pr || !a.candidateSha || !a.evidence?.tests?.passed || a.evidence.tests.sha !== a.candidateSha || a.evidence.review?.sha !== a.candidateSha || a.evidence.review.verdict !== 'approve') throw refuse('Current candidate, passing host tests and independent Review are required') } export function requireNextAttempt(task, a) { @@ -25,6 +24,14 @@ export function chiefActionRefusals(state, task) { } export function deliveryChoices(state, task, observation) { const a = state.projectAttempts?.[task.deliveryAttemptId], scope = deliveryScope(state, task), previous = state.projectChiefDecisions?.[task.chiefRecommendationId] + const original = a?.requirement, current = task.projectTask?.requirements + const before = [original?.projectTaskRevision ?? null, original?.title, original?.criteria ?? '', original?.impact ?? '', original?.scope ?? ''] + const after = [current?.revision ?? null, task.title, task.criteria ?? '', current?.impact ?? '', current?.scope ?? ''] + const changedFields = original ? ['revision', 'title', 'criteria', 'impact', 'scope'].filter((_, index) => before[index] !== after[index]) : [] + const requirements = original ? { basis: 'attempt.requirement', attemptRevision: before[0], taskRevision: after[0], changedFields, + priorValues: Object.fromEntries(changedFields.filter(field => field !== 'revision').map(field => [field, original[field] ?? ''])), + source: { query: 'attempt.context', attemptId: a.id, section: 'requirement' }, + notice: 'Judge this delivery against its pinned Attempt requirements; differences are advisory, not Task completion. Current requirements apply to the next delivery and whole-Task closure.' } : null const option = (action, check, note) => { try { check(); return { action, status: 'ready', reason: note } } catch (error) { return { action, status: 'blocked', reason: error.message } } @@ -32,7 +39,7 @@ export function deliveryChoices(state, task, observation) { const merged = observation?.merged === true || a?.evidence?.merge?.scope === 'provider-confirmed-merge-observation' && a.evidence.merge.sha === a.candidateSha const acceptance = option('accept-attempt', () => requireAttemptAcceptance(task, a), 'Chief must judge delivered scope and remaining work; execution rechecks all evidence') if (acceptance.status === 'ready' && (observation?.status === 'unknown' || !merged)) Object.assign(acceptance, { status: observation?.status === 'unknown' ? 'unknown' : 'blocked', reason: observation?.status === 'unknown' ? 'Query the current provider merge result; no merge outcome is inferred' : 'Provider-confirmed merge is required for scoped acceptance' }) - return { expectedScope: scope, observedAt: Date.now(), admission: admissions(state, task).slice(-4), observation: observation ?? null, recommendation: previous?.taskId === task.id && previous.status === 'applied' ? { decisionId: previous.id, action: previous.action, reason: previous.reason, criteria: previous.criteria, expectedScope: previous.expectedScope, alreadyApplied: true } : null, recommendationSource: 'Chief decision action/reason/evidence in the original inference turn', actions: [acceptance, + return { requirements, expectedScope: scope, observedAt: Date.now(), admission: admissions(state, task).slice(-4), observation: observation ?? null, recommendation: previous?.taskId === task.id && previous.status === 'applied' ? { decisionId: previous.id, action: previous.action, reason: previous.reason, criteria: previous.criteria, expectedScope: previous.expectedScope, alreadyApplied: true } : null, recommendationSource: 'Chief decision action/reason/evidence in the original inference turn', actions: [acceptance, option('start-next', () => requireNextAttempt(task, a), 'Read fresh continuation-state; pin verified target HEAD, choose an implementation role and use task.run mode next'), merged ? { action: 'return', status: 'blocked', reason: 'A provider-confirmed merged PR cannot be reworked or reused for publication' } : { action: 'return', status: observation?.status === 'unknown' ? 'unknown' : 'ready', reason: 'Existing route/rework, exit, binding and current authority gates still apply' }, { action: 'close', status: 'unknown', reason: 'Chief must establish every Task criterion, dependency and input; merged or accepted Attempt alone does not complete the Task' }, diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index cb6f964e..7c47a424 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -980,8 +980,8 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const task = taskOf(attempt.taskId) rt.projectInputs?.requireHandled(task.id, actor) if (task.status !== 'result-ready') throw reject('Task is not waiting for delivery acceptance') - if (task.projectTask && attempt.requirement.projectTaskRevision !== task.projectTask.requirements.revision) throw reject('Requirements version changed; delivery evidence is stale') - if (task.title !== attempt.requirement.title || (task.criteria ?? '') !== attempt.requirement.criteria) throw reject('Requirements changed; delivery evidence is stale') + if (!partial && task.projectTask && attempt.requirement.projectTaskRevision !== task.projectTask.requirements.revision) throw reject('Requirements version changed; delivery evidence is stale') + if (!partial && (task.title !== attempt.requirement.title || (task.criteria ?? '') !== attempt.requirement.criteria)) throw reject('Requirements changed; delivery evidence is stale') if (!attempt.runId || !ownEntry(state.projectExecutions, attempt.runId) || !partial && ownEntry(state.projectExecutions, attempt.runId)?.status !== 'done') throw reject('Delivery Run has not finished successfully') if (!attempt.pr || !attempt.evidence.tests?.passed || attempt.evidence.tests.sha !== attempt.candidateSha || attempt.evidence.review?.sha !== attempt.candidateSha || attempt.evidence.review.verdict !== 'approve') throw reject('Delivery evidence is missing or stale') for (const role of DELIVERY_ROLES) await verifyExecution(attempt, deliveryRole(attempt, role)) @@ -1004,7 +1004,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const before = { status: attempt.status, chiefDecision: attempt.chiefDecision, runStatus: run.status, claimStatus: claim?.status, collaborationStatus: c?.status } const token = rt.projectEffects.stage({ kind: 'attempt.accept', key: `${attempt.id}:${attempt.candidateSha}`, projectId: task.projectId, taskId: task.id, payload: { attemptId: attempt.id, candidateSha: attempt.candidateSha, decisionId: partial.id, delivery: partial.delivery } }) attempt.status = 'accepted'; attempt.chiefDecision = { id: partial.id, verdict: 'approve', scope: 'attempt', candidateSha: attempt.candidateSha, by: 'chief', runId: actor.chiefJobId } - attempt.settlement = { ...structuredClone(partial.delivery), decisionId: partial.id, effect: token, candidateSha: attempt.candidateSha, mergeCommitSha: pr.mergeCommitSha, baseBranch: pr.baseRef, requirementsRevision: attempt.requirement.projectTaskRevision ?? null, acceptedBy: actor.memberId, at: Date.now() } + attempt.settlement = { ...structuredClone(partial.delivery), decisionId: partial.id, effect: token, candidateSha: attempt.candidateSha, mergeCommitSha: pr.mergeCommitSha, baseBranch: pr.baseRef, requirementsRevision: attempt.requirement.projectTaskRevision ?? null, taskRequirementsRevision: task.projectTask?.requirements.revision ?? null, acceptedBy: actor.memberId, at: Date.now() } run.status = 'done' if (claim?.chiefRunId === actor.chiefJobId) { claim.status = 'completed'; c.status = 'arbitration' } try { await rt.projectEffects.persist(token) } diff --git a/core/test/project-chief-control.test.js b/core/test/project-chief-control.test.js index 64b956ae..4275be7d 100644 --- a/core/test/project-chief-control.test.js +++ b/core/test/project-chief-control.test.js @@ -865,3 +865,17 @@ test('failed queued next delivery reuses the original recovery notification pass assert.equal(f.rt.state.projectQueue.q.status, 'failed') assert.equal(f.task.chiefQuestion, undefined) }) + +test('requirement changes after scoped acceptance judgement request fresh facts without blocking the Task', async () => { + const f = await controlFixture({ modern: true }) + ++f.task.projectTask.requirements.revision + await f.request.onResult(f.result({ action: 'accept-attempt', delivery: { completed: 'Original slice', remaining: 'Current goal', evidence: ['exact candidate'] } })) + const run = f.rt.state.projectExecutions[f.request.runId] + assert.equal(run.status, 'correction-pending') + assert.equal(run.correction.feedback.at(-1).recovery.kind, 'stale-snapshot') + assert.equal(f.accepted(), 0); assert.notEqual(f.task.status, 'blocked'); assert.equal(f.task.chiefQuestion, undefined) + await f.rt.projectEffects.drain(); await f.control.pump() + await f.request.onResult(f.result({ action: 'continue', reason: 'Inspect the changed requirement before selecting an action' })) + assert.equal(run.status, 'done'); assert.equal(f.accepted(), 0) + await f.rt.projectEffects.close() +}) diff --git a/core/test/project-delivery-options.test.js b/core/test/project-delivery-options.test.js index 17a53f8d..2568210e 100644 --- a/core/test/project-delivery-options.test.js +++ b/core/test/project-delivery-options.test.js @@ -33,7 +33,7 @@ for (const field of ['terminal', 'cancelled', 'superseded', 'foreign', 'status', if (field === 'superseded') a.supersededBy = 'b' if (field === 'foreign') a.projectId = 'other' if (field === 'status') a.status = 'failed' - if (field === 'requirement') task.criteria = 'Changed' + if (field === 'requirement') delete a.requirement if (field === 'review') a.evidence.review.sha = 'old' if (field === 'tests') a.evidence.tests.passed = false if (field === 'kind') task.projectTaskKind = 'release' @@ -46,7 +46,7 @@ test('scoped fingerprints ignore unrelated history but bind relevant revisions a a.evidence.review.summary = 'Changed evidence' assert.notEqual(deliveryScope(state, task), scope) task.projectTask = { requirements: { revision: 1 } } - assert.throws(() => requireAttemptAcceptance(task, a), /requirements/) + assert.doesNotThrow(() => requireAttemptAcceptance(task, a)) assert.throws(() => requireNextAttempt(task, a), /Accept the current/) }) @@ -84,3 +84,32 @@ test('queued next-admission facts are scoped and ordered, and state changes inva state.projectQueue.a.status = 'blocked' assert.notEqual(deliveryScope(state, task), choices.expectedScope) }) + +test('historical Attempt requirements are advisory differences, with bounded facts and the existing disclosure source', () => { + const { state, task, a } = fixture() + a.requirement.projectTaskRevision = 2; a.requirement.impact = 'Initial impact'; a.requirement.scope = 'Initial scope' + task.projectTask = { requirements: { revision: 4, impact: 'Expanded impact', scope: 'Remaining goal' } } + task.title = 'Remaining work'; task.criteria = 'New acceptance criteria' + const choices = deliveryChoices(state, task, { status: 'observed', merged: true }) + assert.equal(choices.actions[0].status, 'ready') + assert.deepEqual(choices.requirements.changedFields, ['revision', 'title', 'criteria', 'impact', 'scope']) + assert.equal(choices.requirements.attemptRevision, 2); assert.equal(choices.requirements.taskRevision, 4) + assert.equal(choices.requirements.priorValues.criteria, 'All requirements') + assert.equal(choices.requirements.source.query, 'attempt.context') + assert.match(choices.requirements.notice, /not.*Task completion/) + assert.doesNotThrow(() => requireAttemptAcceptance(task, a)) +}) + +test('legacy matching requirements keep the advisory compact and absent snapshots stay unknown', () => { + const { state, task, a } = fixture() + assert.deepEqual(deliveryChoices(state, task).requirements.changedFields, []) + assert.deepEqual(deliveryChoices(state, task).requirements.priorValues, {}) + delete a.requirement + assert.equal(deliveryChoices(state, task).requirements, null) +}) + +for (const field of ['title', 'criteria']) test('scoped acceptance still requires a valid pinned ' + field, () => { + const { task, a } = fixture() + delete a.requirement[field] + assert.throws(() => requireAttemptAcceptance(task, a), /snapshot is unavailable/) +}) diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index fe4b1821..b0915655 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -2447,8 +2447,14 @@ test('protected Chief exit retirement excludes only its exact current coordinati assert.equal(lease.status, 'running', 'The current Chief execution was not falsely released') }) -async function mergedDelivery(t) { - const f = await setup(t) +async function mergedDelivery(t, { modern = false } = {}) { + const f = await setup(t, undefined, { modern }) + if (modern) { + f.task = f.rt.state.tasks[f.task.id] + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + const decision = { id: 'initial', taskId: f.task.id, projectId: 'p1', executorRole: 'chief', runId: 'fixture-chief', initiatedBy: alice.memberId, reason: 'Confirm first deliverable' } + admitTask(f.task, decision); reviseTask(f.task, { scope: 'First delivery' }, { ...decision, id: 'revision-two' }) + } await f.start('merged-delivery'); await complete(f) const a = f.attempt() git(f.repository, ['merge', '--ff-only', a.candidateSha]) @@ -2499,7 +2505,7 @@ test('scoped Attempt acceptance rolls back a failed or no-op save and remains id for (const changed of ['candidate', 'requirements', 'provider', 'authority', 'exit']) test('scoped Attempt acceptance refuses changed ' + changed, async t => { const f = await mergedDelivery(t) if (changed === 'candidate') f.a.evidence.tests.sha = 'old' - if (changed === 'requirements') f.task.criteria += ' new requirement' + if (changed === 'requirements') { const read = f.publisher.read; f.publisher.read = async (...args) => { const result = await read(...args); f.task.criteria += ' new requirement'; return result } } if (changed === 'provider') f.publisher.read = async pr => ({ sha: pr.sha, state: 'open', merged: false, baseRef: 'main' }) if (changed === 'authority') f.rt.projectChiefControl.requireJobActor = () => { throw Error('authority revoked') } if (changed === 'exit') { f.rt.state.projectResourceLeases.late = { id: 'late', projectId: 'p1', attemptId: f.a.id, resourceId: 'unknown', generation: 1, role: 'developer', worker: { kind: 'sdk', handle: 'late-worker' }, status: 'unreachable' }; (f.rt.state.projectHarnessWorkers ??= {})['late-worker'] = { id: 'late-worker', leaseId: 'late', projectId: 'p1', attemptId: f.a.id, role: 'developer', resourceId: 'unknown', generation: 1, status: 'unreachable' } } @@ -2554,7 +2560,11 @@ test('pending external write and unknown native exit block scoped acceptance; cu }) test('Chief chooses acceptance then a fresh explicit next delivery through its original decision loop', async t => { - const f = await mergedDelivery(t), requests = [], rt = f.rt + const f = await mergedDelivery(t, { modern: true }), requests = [], rt = f.rt + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + const revision = { projectId: 'p1', taskId: f.task.id, executorRole: 'chief', runId: 'fixture-chief', initiatedBy: alice.memberId, reason: 'Record remaining work' } + reviseTask(f.task, { scope: 'Remaining MVP' }, { ...revision, id: 'next-scope' }) + reviseTask(f.task, { criteria: 'Finish remaining MVP' }, { ...revision, id: 'next-criteria' }) rt.projectChiefControl.requireJobActor = f.originalJobAuthority const carrier = { id: 'public-p1', projectId: 'p1', visibility: 'public', botId: 'a', sessionId: 'a', carrierGeneration: 1 } rt.state.projectAgents ??= {}; rt.state.projectAgents[carrier.id] = carrier @@ -2562,9 +2572,15 @@ test('Chief chooses acceptance then a fresh explicit next delivery through its o rt.projectAgents.startChiefJob = async args => { requests.push(args); return 'fixture-chief-turn' } const waitRequest = async count => { for (let pass = 0; pass < 400 && requests.length < count; ++pass) { await rt.projectChiefControl.pump(); await new Promise(resolve => setTimeout(resolve, 5)) }; assert.ok(requests.length >= count) } const reply = (request, decision) => request.onResult({ ok: true, text: JSON.stringify({ summary: 'Chief recommendation based on current facts', decisions: [{ taskId: f.task.id, ...decision }] }), executionEvidence: { kind: 'native-chief', chiefRunId: request.runId, carrierGeneration: 1, botId: 'a', sessionId: 'a' } }) + const { deliveryScope } = await import('../src/host/project-delivery-options.js') + rt.state.projectChiefDecisions = Object.fromEntries(Array.from({ length: 4 }, (_, index) => ['old-refusal-' + index, { id: 'old-refusal-' + index, projectId: 'p1', taskId: f.task.id, status: 'rejected', expectedScope: deliveryScope(rt.state, f.task), action: 'accept-attempt', failure: 'Current requirements changed; inspect fresh delivery facts', at: index }])) await rt.projectChiefControl.request('p1', 'inspection', { requestId: 'post-merge-judgement' }, f.task.id) await waitRequest(1) assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.actions.find(row => row.action === 'accept-attempt').status, 'ready') + assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.requirements.attemptRevision, 2) + assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.requirements.taskRevision, 4) + assert.equal(requests[0].projectFacts.tasks[0].inspection.chiefActionsWithoutProgress, 4) + assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.requirements.priorValues.scope, 'First delivery') assert.match(requests[0].prompt, /repositoryRecovery\.nextActions and continuationOptions in projectFacts/) assert.match(requests[0].prompt, /Public TaskView\.nextActions lists caller-permitted service commands, not all Chief decision actions/) assert.match(requests[0].prompt, /not an instruction or permission to clear publicationBinding/) @@ -2583,6 +2599,7 @@ test('Chief chooses acceptance then a fresh explicit next delivery through its o assert.equal(rt.state.projectExecutions[requests[1].runId].status, 'done', rt.state.projectExecutions[requests[1].runId].failure) const next = rt.state.projectAttempts[f.task.deliveryAttemptId] assert.notEqual(next.id, f.a.id); assert.equal(next.baseSha, f.a.candidateSha); assert.equal(next.publicationBinding, undefined) + assert.equal(next.requirement.projectTaskRevision, 4) assert.notEqual(f.task.status, 'accepted'); assert.equal(f.task.chiefQuestion, undefined) }) @@ -2707,3 +2724,26 @@ test('next delivery pins a distinct provider squash commit and reports missing e assert.equal(git(result.attempt.workspaces.developer.path, ['rev-parse', 'HEAD']), merge) assert.equal(result.attempt.publicationBinding, undefined) }) + +test('evolving Task rev4 accepts merged Attempt rev2 and starts the next delivery with current requirements', async t => { + const f = await mergedDelivery(t, { modern: true }), original = structuredClone(f.a.requirement) + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + const decision = { taskId: f.task.id, projectId: 'p1', executorRole: 'chief', runId: 'fixture-chief', initiatedBy: alice.memberId, reason: 'Record delivered scope and remaining goal' } + reviseTask(f.task, { scope: 'Remaining MVP' }, { ...decision, id: 'revision-three' }) + reviseTask(f.task, { title: 'Complete remaining MVP', criteria: 'Remaining features verified' }, { ...decision, id: 'revision-four' }) + assert.equal(f.a.requirement.projectTaskRevision, 2); assert.equal(f.task.projectTask.requirements.revision, 4) + const choices = await f.rt.projectDelivery.deliveryOptions(f.task, f.actor) + assert.equal(choices.actions.find(row => row.action === 'accept-attempt').status, 'ready', JSON.stringify(choices.actions)) + assert.deepEqual(choices.requirements.changedFields, ['revision', 'title', 'criteria', 'scope']) + assert.equal(choices.requirements.source.section, 'requirement') + assert.equal(choices.requirements.priorValues.criteria, original.criteria) + await f.settle() + assert.equal(f.a.settlement.requirementsRevision, 2); assert.equal(f.a.settlement.taskRequirementsRevision, 4) + assert.deepEqual(f.a.requirement, original); assert.equal(f.task.status, 'result-ready') + await assert.rejects(f.rt.projectDelivery.chiefAccept({ taskId: f.task.id, expectedSha: f.a.candidateSha, decision: { id: 'close-new-goal', action: 'close' } }, f.actor), /requirements version/i) + const facts = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { attemptId: f.a.id }, alice) + const next = await f.rt.projectEndpoint('project-task-run', { taskId: f.task.id, requestId: 'next-current-requirements', continuation: { mode: 'next', previousAttemptId: f.a.id, generation: facts.generation, expectedWorkspace: facts.snapshot, expectedBaseSha: facts.latestBaseSha } }, alice) + assert.equal(next.attempt.requirement.projectTaskRevision, 4) + assert.equal(next.attempt.requirement.criteria, f.task.criteria) + assert.equal(next.attempt.publicationBinding, undefined); assert.equal(f.a.pr.number, 1) +}) diff --git a/decisions/2026-11-05-historical-attempt-requirements.md b/decisions/2026-11-05-historical-attempt-requirements.md new file mode 100644 index 00000000..3dd3f745 --- /dev/null +++ b/decisions/2026-11-05-historical-attempt-requirements.md @@ -0,0 +1,15 @@ +# Settle historical Attempt scope after Task requirement revisions + +Date: 2026-11-05 (Asia/Shanghai). +Class: Owner-authorized follow-up to #2526. +Status: Accepted for source delivery/ai-centric-development-service; no live deployment. + +Owner confirmed that historical requirements may evolve while a verified delivery is awaiting judgement. Equality between Task current requirements and Attempt pinned requirements wrongly prevented partial settlement after any revise. Fresh-base pure and real Git/Runtime regressions reproduce the refusal. + +Scoped acceptance judges the immutable Attempt scope and exact candidate/test/Review/provider merge, recording completed/remaining evidence and both requirement revisions. The Task stays open; a chosen next delivery uses current requirements. Whole-Task closure remains independent. Snapshot changes after judgement need existing correction/re-read, not blind execution. Object identity, authority, execution generation, candidate evidence, unknown exit and replay guards remain hard constraints. + +The shared read projection supplies changed fields/prior values, versions and existing requirement source pages. Chief chooses the legal action; warnings do not choose roles or human escalation. No new scheduler, state store, planner or migration. Original scope/requirements and old PRs remain immutable. Mandatory context package cleanup stays explicitly deferred. + +The previous requirement-change test assumed all changes invalidate scoped settlement. Replace that assertion with successful evolution before judgement plus refusal/correction for changes during I/O or after snapshot. Do not weaken whole-Task or evidence checks. Tests, source delivery and live/vendor-model acceptance remain separate. + +Written-by: Codex diff --git a/docs/development-service-api.md b/docs/development-service-api.md index 4b6a1df2..efbaeca2 100644 --- a/docs/development-service-api.md +++ b/docs/development-service-api.md @@ -328,3 +328,9 @@ Chief 应读取当前交付选项再判断,Personal Agent 的文字输入也 无进展阈值。Chief 的 inspection facts / warning 摘要带最多五条当前失败动作、 理由和精确拒因;旧作用域、已成功及尚未判定的动作不计入。事实变化后旧提醒 标记 historical,不再作为当前阻塞;提醒不会自动派角色、停止 Task 或转人工。 + +### 需求演进后的本轮验收 + +Task 需求修订号与 Attempt 领取时的修订号允许不同。`accept-attempt` 验收原承诺的已合成果,保留 `delivery.completed/remaining/evidence`,不自动认可当前新需求完成。`deliveryChoices.requirements` / Chief 的 `repositoryRecovery.nextActions.requirements` 返回 `attemptRevision`、`taskRevision`、`changedFields` 和这些字段的 `priorValues`;当前值在 Task requirements 中。原始完整快照由已有 `attempt.context` 的 `section=requirement` 分页读取,不把整段历史反复加入上下文。 + +Settlement 的 `requirementsRevision` 是 Attempt 原版本;可选 `taskRequirementsRevision` 是验收时当前 Task 版本。旧 settlement 无需迁移。下一轮 Attempt 复制当前 Task 需求。Chief 读后需求再变化时,宿主通过既有 stale-snapshot 纠正反馈要求重新判断;候选/测试/Review/PR SHA、权限、代次、退出与幂等校验保持严格,整单 close 仍独立验证当前需求。 diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index c60dd642..ddc20ec2 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -6688,6 +6688,102 @@ }, "note": { "type": "string" + }, + "requirements": { + "anyOf": [ + { + "type": "object", + "properties": { + "basis": { + "const": "attempt.requirement", + "type": "string" + }, + "attemptRevision": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "taskRevision": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, + "changedFields": { + "type": "array", + "items": { + "type": "string", + "enum": [ + "revision", + "title", + "criteria", + "impact", + "scope" + ] + } + }, + "priorValues": { + "type": "object", + "additionalProperties": { + "type": "string", + "maxLength": 16000 + } + }, + "source": { + "type": "object", + "properties": { + "query": { + "const": "attempt.context", + "type": "string" + }, + "attemptId": { + "type": "string", + "minLength": 1, + "maxLength": 200, + "description": "Opaque server-issued ID; never infer authority from it." + }, + "section": { + "const": "requirement", + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "query", + "attemptId", + "section" + ] + }, + "notice": { + "type": "string" + } + }, + "additionalProperties": false, + "required": [ + "basis", + "attemptRevision", + "taskRevision", + "changedFields", + "priorValues", + "source", + "notice" + ] + }, + { + "type": "null" + } + ] } }, "additionalProperties": false, @@ -6747,6 +6843,17 @@ } ] }, + "taskRequirementsRevision": { + "anyOf": [ + { + "type": "integer", + "minimum": 0 + }, + { + "type": "null" + } + ] + }, "acceptedBy": { "type": "string", "minLength": 1, @@ -35440,7 +35547,7 @@ "notes": "Read continuation facts even after cancellation. Accepted scoped code delivery exposes only next mode: verify target HEAD contains its merged result and do not inherit its old PR binding. nextActions is advisory, not authorization. task.run selects a successor, never revives the cancelled Attempt.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -35522,7 +35629,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": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -35600,7 +35707,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": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -35761,7 +35868,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "cf65bc54b7e031cf787a10d7e66278b320b57a973cef2ab7b52d742fc0ecbf12" + "sha256": "7fdff3f88afff250f3c3588d587d2ea2b3c4db4267383101a462397d1fcbe8d1" } }, { @@ -37756,7 +37863,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": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -37864,7 +37971,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -37971,7 +38078,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -38065,7 +38172,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -38169,7 +38276,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": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -38279,7 +38386,7 @@ "notes": "Fetch attempt.integration-state again after retirement; attempt.stop remains cancellation.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -38388,7 +38495,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" } }, { @@ -38963,7 +39070,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "cf65bc54b7e031cf787a10d7e66278b320b57a973cef2ab7b52d742fc0ecbf12" + "sha256": "7fdff3f88afff250f3c3588d587d2ea2b3c4db4267383101a462397d1fcbe8d1" } }, { @@ -39068,7 +39175,7 @@ "notes": "operationsContact selects an explicit assistance recipient, never host privileges; null clears it, omission retains it. autonomy budgets/guidance inform Chief judgement, not a fixed escalation workflow. Use existing schedule.configure targeting project-chief for hourly compensation; chief.state.compensation reports original nextCheckAt. 30-minute inspection is a configurable schedule default, not a framework deadline. correctionBudget defaults to 2 (0–4); protocol correction reuses the Chief Session. Exhaustion is incomplete judgement, never automatic consultation or default routing. inspection.lifecycle configures queue/execution deadlines, bounded recovery delay/count and health overdue threshold; timeout is not exit proof.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "cf65bc54b7e031cf787a10d7e66278b320b57a973cef2ab7b52d742fc0ecbf12" + "sha256": "7fdff3f88afff250f3c3588d587d2ea2b3c4db4267383101a462397d1fcbe8d1" } }, { @@ -48898,7 +49005,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": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" }, "aliasOf": "development.command.task.accept" }, @@ -48991,7 +49098,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" }, "aliasOf": "development.command.task.reject" }, @@ -49083,7 +49190,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" }, "aliasOf": "development.command.attempt.stop" }, @@ -49175,7 +49282,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" }, "aliasOf": "development.command.attempt.cleanup" }, @@ -49564,7 +49671,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": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" }, "aliasOf": "development.command.attempt.publication-retry" }, @@ -49640,7 +49747,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": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008" + "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" }, "aliasOf": "development.query.attempt.log" }, @@ -49948,7 +50055,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "cf65bc54b7e031cf787a10d7e66278b320b57a973cef2ab7b52d742fc0ecbf12" + "sha256": "7fdff3f88afff250f3c3588d587d2ea2b3c4db4267383101a462397d1fcbe8d1" }, "aliasOf": "development.command.chief.inspect" }, @@ -50051,7 +50158,7 @@ "notes": "operationsContact selects an explicit assistance recipient, never host privileges; null clears it, omission retains it. autonomy budgets/guidance inform Chief judgement, not a fixed escalation workflow. Use existing schedule.configure targeting project-chief for hourly compensation; chief.state.compensation reports original nextCheckAt. 30-minute inspection is a configurable schedule default, not a framework deadline. correctionBudget defaults to 2 (0–4); protocol correction reuses the Chief Session. Exhaustion is incomplete judgement, never automatic consultation or default routing. inspection.lifecycle configures queue/execution deadlines, bounded recovery delay/count and health overdue threshold; timeout is not exit proof.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "cf65bc54b7e031cf787a10d7e66278b320b57a973cef2ab7b52d742fc0ecbf12" + "sha256": "7fdff3f88afff250f3c3588d587d2ea2b3c4db4267383101a462397d1fcbe8d1" }, "aliasOf": "development.command.chief.configure" }, @@ -50127,7 +50234,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-chief-control.js", - "sha256": "cf65bc54b7e031cf787a10d7e66278b320b57a973cef2ab7b52d742fc0ecbf12" + "sha256": "7fdff3f88afff250f3c3588d587d2ea2b3c4db4267383101a462397d1fcbe8d1" }, "aliasOf": "development.query.chief.state" }, @@ -51008,7 +51115,7 @@ "core/src/host/project-assistant.js": "4fa4309d4aa7abf78b6ba4e55ba754f320384f931e0e4a5c6620c859e1b50676", "core/src/host/project-board.js": "dfb0b4bc05ae42335632e3edbba883a03c5da2fb17899b23e8226320ea08aec4", "core/src/host/project-chief-assistance.js": "261f3767cf7fd4466f057c625b32e74b9c0a90c53c0fca3fcc835ac0f11c567b", - "core/src/host/project-chief-control.js": "cf65bc54b7e031cf787a10d7e66278b320b57a973cef2ab7b52d742fc0ecbf12", + "core/src/host/project-chief-control.js": "7fdff3f88afff250f3c3588d587d2ea2b3c4db4267383101a462397d1fcbe8d1", "core/src/host/project-chief-lifecycle.js": "ed85bb1be90198910b20ffd605895812dfa848fce1049f7bd27b32dbfae3a328", "core/src/host/project-chief-protocol.js": "75d97be3ff530e8cbd5bdf9c9a70b9c671cc7db9bd7ee2f926143a7d8f801ee9", "core/src/host/project-chief-recovery.js": "c80a9d791aab06d4b343e8740a25a471ddf93e0af52b59dd9b7e6fc835c0d824", @@ -51022,9 +51129,9 @@ "core/src/host/project-context-memory.js": "933893b3d6b7b2c7a79365991c1303fb03a665d9529f16ccf57429a753ad4cd0", "core/src/host/project-credentials.js": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea", "core/src/host/project-delegations.js": "d4d86192551994a3419e0d2ca0f64bc4d50a75960970d2b79ed9f97b82940903", - "core/src/host/project-delivery-options.js": "9a525fee3bcf7ce75d4d7dc1be966887480b6cb291870816a1e6563873622760", + "core/src/host/project-delivery-options.js": "591aff7f7a8f6b6938b0fcb02592444c5398d960be5f824cf80c31a76d4d38e4", "core/src/host/project-delivery-template.js": "f149d09786ac6db41a7ebeccb7620cce62b57f334d3ddd5e3c66ee673e5b66a8", - "core/src/host/project-delivery.js": "864b24a6af0a98c223523cb4d2e0bb2d88a6e6a7b84505097304aa2ae4a76008", + "core/src/host/project-delivery.js": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad", "core/src/host/project-development-service.js": "8d3ee6533b55c52fd5989a49db1d575effac9622f92070c32b39aa25c3e89691", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "dc02f89eb221d543805d115e48a9534882a344d233bf089299e43b1a1a4ede63", diff --git a/docs/subsystems/project-chief-control.md b/docs/subsystems/project-chief-control.md index 15caed3d..f28b37d2 100644 --- a/docs/subsystems/project-chief-control.md +++ b/docs/subsystems/project-chief-control.md @@ -405,3 +405,9 @@ even when a board offers only chief.inspect. Rework refusals never authorize cle publicationBinding, and Personal Agent prose cannot bypass execution preconditions. Inspection facts also expose chiefActionsWithoutProgress and up to five refusedChiefActions for the current deliveryScope. The existing configurable no-progress threshold emits a shared task-attention warning; exact errors remain evidence, not a new authorization or fixed recovery plan. A changed deliveryScope excludes old refusals from the current count. + +### Historical requirements versus stale judgement (#2526 follow-up) + +Scoped acceptance judges the immutable Attempt requirement, never equality with the latest Task revision. repositoryRecovery.nextActions.requirements supplies both revisions, changed field names and only their prior values; the Task snapshot carries current values. Complete pinned requirements use the existing attempt.context requirement page. This is a bounded read projection, not a planner or permission token. The host still verifies current candidate/test/Review/merge, identity, rights, generation and exit; execution rechecks the full local scope after I/O. + +Requirements that change after a Chief snapshot use existing protocol correction with stale-snapshot feedback, so Chief can reconsider the original intent. Earlier legitimate Task revisions do not permanently block historical settlement. Neither outcome satisfies new Task criteria or chooses a downstream role/Personal Agent automatically. start-next copies current requirements; whole-Task closure remains independently gated. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index cd7850dd..232617e3 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -498,7 +498,7 @@ SDK input preflights fail before inference if required input cannot fit. New Project Tasks require accepted Chief admission before allocation. New-contract completion rejects human acceptance before changing Attempt state; legacy persisted Tasks retain human acceptance. Parent split waits preserve the active Attempt/workspace and role binding for later integration. Child candidate references are not automatically approved integration sources or evidence that their code was merged. -Attempts pin the complete Project requirements revision, including impact/scope. A scope-only change invalidates old completion evidence even if title and criteria are unchanged. Rework after a revision creates a successor with the new requirements snapshot, retaining the prior version and logical Task-role Session. +Attempts pin the complete Project requirements revision, including impact/scope. A scope-only change invalidates old whole-Task completion evidence; scoped merged-Attempt settlement still judges its pinned scope. Rework after a revision creates a successor with the new requirements snapshot, retaining the prior version and logical Task-role Session. A failed Attempt leaves a new Project Task blocked with its source Attempt/reason; it does not close the commitment or erase Chief admission. A later active Attempt clears that execution block. Pre-contract legacy Task failure semantics remain unchanged. @@ -738,9 +738,11 @@ stop is inferred. Ordinary public task.run still refuses active Attempts. ## Scoped Attempt acceptance and next delivery (#2526) -Chief accept-attempt verifies current code/requirements/candidate, host tests, Review, +Chief accept-attempt verifies pinned requirements/candidate, host tests, Review, policy/SDK evidence, provider-confirmed merge and actual execution exit. Its bounded -completed/remaining/evidence record belongs to the Attempt. An attempt.accept Effect +completed/remaining/evidence record belongs to the Attempt. Task revisions are advisory; +requirementsRevision pins the Attempt and taskRequirementsRevision records the current Task. +An attempt.accept Effect commits status/settlement and canonical Run completion together; failed/no-op commits roll back provisional fields. Duplicate content returns the original committed result; changed settlement content refuses. Task remains result-ready and unfinished. Unhandled diff --git a/patches/ds-bot/files/src/host/project-chief-control.js b/patches/ds-bot/files/src/host/project-chief-control.js index e4527571..5ec6989b 100644 --- a/patches/ds-bot/files/src/host/project-chief-control.js +++ b/patches/ds-bot/files/src/host/project-chief-control.js @@ -189,7 +189,7 @@ export function createProjectChiefControl(rt) { await validateActor(actor) const task = ownEntry(rt.state.tasks, decision?.taskId), saved = job.snapshot.find(row => row.taskId === decision?.taskId) if (!task || task.projectId !== run.projectId || !saved || terminal.has(task.status) && decision.action !== 'reopen') throw refuse('Chief decision names an unavailable Task') - if (task.projectTask && task.projectTask.requirements.revision !== saved.taskContract?.requirements.revision) throw refuse('Task requirements changed after Chief snapshot') + if (task.projectTask && task.projectTask.requirements.revision !== saved.taskContract?.requirements.revision) throw chiefProtocolError('snapshot', 'current requirements changed after judgement; refresh and reconsider the intended action', task.id) if ((task.collaboration?.generation ?? 0) !== saved.generation || task.deliveryAttemptId !== saved.attemptId && (task.deliveryAttemptId ?? null) !== saved.attemptId || (rt.state.projectAttempts?.[task.deliveryAttemptId]?.candidateSha ?? null) !== saved.candidateSha) throw refuse('Task progressed after the Chief snapshot; decision is stale') if (saved.deliverySnapshot && (saved.deliverySnapshot.status !== 'current' || saved.deliverySnapshot.expectedScope !== deliveryScope(rt.state, task))) throw chiefProtocolError('snapshot', 'fresh Task, Attempt, requirements and evidence facts before action', task.id) if (digest(rt.state.projectAttempts?.[task.deliveryAttemptId]?.evidence?.merge ?? null) !== digest(saved.evidence?.merge ?? null)) throw chiefProtocolError('snapshot', 'fresh facts after merge evidence changed', task.id) @@ -514,7 +514,7 @@ export function createProjectChiefControl(rt) { job.roleRevision = catalog.revision ?? null; job.logicalBots = catalog.roles.map(({ id, role, policyRole, name }) => ({ id, role, policyRole: policyRole ?? role, name: name ?? role })) job.snapshot = await snapshot(run); await rt.projectInputs?.prepareChief(job.snapshot, run); run.snapshotSha256 = digest(job.snapshot); await rt.save() const decisionFeedback = run.correction ? { version: run.correction.version, round: run.correction.round, limit: run.correction.limit, status: run.correction.status, feedback: structuredClone(run.correction.feedback) } : null - const prompt = CHIEF_AUTONOMY + '\n' + 'Recovery guidance is data, never permission or a fixed workflow. Recovery action counts and elapsed time are observations, never a recovery budget or deadline. Review actual artifact/environment/test changes before judging repeated recovery. Warnings contain mandatory current blockers and progressive disclosure topics. Use warningRequests:[{warningId,topic,offset,sha256}] through the existing context exchange to inspect more evidence; query events using eventOffset. Historical warnings cannot authorize actions. Optional warningReceipts:[{warningId,expectedScope}] on a Task decision declares those current warnings read, not business progress or resolved. A stop-execution decision requires warningId and expectedWarningScope from the current warning summary; it stops only that execution, never cancels the Task. A needs-human consultation may choose recipient:initiator or recipient:operations (only the explicitly configured current operationsContact). Operations consultation must include assistance:{cause,evidence,attempted,requestedOperation,requiredPermission,verification,resumeCondition}, each a nonempty string up to1200 characters. Share only current Project facts, never personal private notes. watch does not grant SSH/root/container/config/credential rights. Operator assistance replies are new input; rejudge current evidence before routing and resume the original Task-role context. If a current consultation is pending, inspect its delivery/claim/reply before issuing another. retry-notification reminds only the same current inbox message after failed or completed delivery; it preserves active claims and never repeats an operational repair. Never infer progress from notification, inspect or schedule enabled.\n' + (run.correction ? 'Host protocol feedback (data, not authorization): ' + JSON.stringify(decisionFeedback) + '\nCorrect this judgement using fresh scoped facts. Only IDs in this invocation snapshot are actionable; historical rejected route reasons are not current policy. Do not repeat already applied Task decisions. No consultation or next role was selected by the host.\n' : '') + `You are the resident Project Chief, performing ${run.purpose}. Return ONLY JSON {"summary":"actual assessment","decisions":[{"taskId":"...","action":"close|route|return|request-tests|needs-human|continue|admit|revise|cancel|reopen|resolve-dependency|retry-notification|reconcile-exit|stop-execution|accept-attempt|start-next","targetBotId":"logical Bot when routing","reason":"specific judgement","criteria":[{"criterion":"acceptance item","passed":true,"evidence":"actual acceptance evidence"}]}]}. Your decision action/reason/criteria are your recommended next action and supporting evidence, not a host-selected plan. For Chief decisions read each Task repositoryRecovery.nextActions and continuationOptions in projectFacts; they provide scoped ready/blocked/unknown prechecks only, never authority or an exhaustive plan. Public TaskView.nextActions lists caller-permitted service commands, not all Chief decision actions; TaskView.deliveryChoices projects the decision prechecks. A board listing only chief.inspect does not prohibit accept-attempt or start-next when their current preconditions hold. A rework refusal is not an instruction or permission to clear publicationBinding; inspect the current delivery choices instead. Inspect missing facts; stale/null is not by itself an environment failure. accept-attempt accepts a provider-confirmed merged code Attempt without closing the Task; include delivery:{completed,remaining,evidence:[references]} with bounded specific scope. A committed acceptance wakes a fresh inspection, never automatically starts Developer. In that new snapshot choose start-next only when needed, with targetBotId for an active implementation role and continuation:{mode:'next',previousAttemptId,generation,expectedWorkspace,expectedBaseSha} copied from fresh continuationOptions. This pins verified target HEAD containing the merged result and creates a new PR identity while retaining Task-role context/history. Never rework or inherit the merged PR binding. close remains available only when all Task criteria/dependencies/inputs are satisfied. Personal Agent assistance is the last resort when actual information/authority or a human decision is missing; failure counts/time never automatically escalate. A conversation/turn completion is not Task completion. Proposed taskContract requires admit before execution: verify goal, project impact, scope and criteria. Prefer admit without repeating already adequate requirements. For admit/revise, every provided requirements.title, requirements.criteria, requirements.impact and requirements.scope must be a bounded nonempty string. In particular requirements.criteria is a string; the separate outer decision.criteria is an evidence array and must never be copied into requirements.criteria. Optional requirements.repository is an associated owner/name or null for a repository-free decision. Uncertain intent requires needs-human. Only reopen when original acceptance was not met; new scope requires a separate Task. Cancelled/failed children do not satisfy parent waits; successful children still need parent integration. All Task mutations remain scoped Chief decisions. To resolve a failed/cancelled child condition without faking child completion, use resolve-dependency with dependency:{taskId,version:the exact current dependency snapshot version,evidence:[actual references],replacementTaskId:optional accepted alternative,replacementVersion:its task outcome version}. This is explicit Chief judgement, not host test success or parent completion. Leave unresolved conditions blocked when evidence is insufficient. Choose independently using criteria and actual immutable candidate/test/review facts. reconcile-exit explicitly retires only an exact exit-proven old claim without selecting a next role; consult repositoryRecovery.executionCheckpoint. Historical refusals are audit data, not current routing policy. Candidate Reviewer approve triggers the host merge flow only when explicitly configured for this Project/repository/branch. It is not a signed repository Reviewer PASS, actual forge approval, merged result or release permit. A configured merge must be provider-confirmed before overall code Task acceptance; pending/blocked merge facts are not completion. Do not close missing or stale evidence. Host allocationProof.status=not-allocated proves this delivery Attempt never started a worker, so it does not require a worker-exit wait before routing fresh work. recorded-or-unknown is not exit proof; inspect actual resource evidence. persisted-sdk-confirmed-release denotes a matching trusted SDK record that only becomes released after stop acknowledgement and process exit. Choose an evidence-supported role after environment or execution repair; no default return to implementation is required. Unsealed work can continue, including across restart, only after every registered worker and lease has confirmed release. A sealed candidate may resume Tester or Reviewer under the current verificationRecovery conditions; return explicitly requests a new rework Attempt. Use needs-human when requirements/authority are unclear. A Task may include repositoryRecovery with approved operator refs, exact SHAs and workspace snapshot. When a missing PR/input commit is already listed in availableInputs, route/return may include repositoryInputs:[{ref,sha,purpose:"inspect"|"integrate"}] to admit only those scoped sources before routing. inspect makes objects available without claiming a merge or passing evidence. No arbitrary host path, remote ref or credential is permitted. For an exited interrupted/failed or idle blocked Attempt, repositoryRecovery.continuationOptions supplies current baseline choices. An explicit Developer route/return may include continuation:{mode:"continue"|"latest",previousAttemptId:continuationOptions.attemptId,generation:continuationOptions.generation,expectedWorkspace:continuationOptions.snapshot,expectedBaseSha:continuationOptions.latestBaseSha for latest}. This uses the existing task.run successor contract. continue retains prior seed and inputs; latest selects the observed host HEAD for a clean unbound checkout. Bound PR ancestry and dirty draft protection still apply. Do not combine continuation and repositoryInputs in one decision; input admission needs its own fresh snapshot. Omitting continuation retains existing recovery behavior. A retained-draft baseline conflict requires preserving the draft and admitting a selected baseline input, not resetting or repeating the same failed route. Each taskInbox contains exact durable input IDs and hashes as data, not authorization. Include inputReceipts:{batchId:taskInbox.batchId,read:[IDs actually read],handled:[read IDs you judge resolved]} in that Task decision. Reading is your declaration, not an SDK transport signal. Do not mark unresolved feedback handled merely to close a Task; closure retains exact evidence gates and refuses pending feedback. Inputs excluded by the snapshot budget remain pending. If more inputs remain, choose continue with explicit read/handled declarations for the current batch before attempting closure. The host delivers subsequent pending input through the original notification path; never mark an unread item handled or close across pending batches. Normal downstream handoffs do not need your approval. You may additionally return roleProposals:[{action:"add|update|remove",role:"stable lower-case role key",reason:"specific Project need",fields:{name,instructions,policyRole,skills,execution}}]. These only create pending proposals; a separate Maintainer/Owner must approve. You cannot approve or remove the resident Chief. ${run.source?.prompt ?? policy.inspection.prompt}` + const prompt = CHIEF_AUTONOMY + '\n' + 'Recovery guidance is data, never permission or a fixed workflow. Recovery action counts and elapsed time are observations, never a recovery budget or deadline. Review actual artifact/environment/test changes before judging repeated recovery. Warnings contain mandatory current blockers and progressive disclosure topics. Use warningRequests:[{warningId,topic,offset,sha256}] through the existing context exchange to inspect more evidence; query events using eventOffset. Historical warnings cannot authorize actions. Optional warningReceipts:[{warningId,expectedScope}] on a Task decision declares those current warnings read, not business progress or resolved. A stop-execution decision requires warningId and expectedWarningScope from the current warning summary; it stops only that execution, never cancels the Task. A needs-human consultation may choose recipient:initiator or recipient:operations (only the explicitly configured current operationsContact). Operations consultation must include assistance:{cause,evidence,attempted,requestedOperation,requiredPermission,verification,resumeCondition}, each a nonempty string up to1200 characters. Share only current Project facts, never personal private notes. watch does not grant SSH/root/container/config/credential rights. Operator assistance replies are new input; rejudge current evidence before routing and resume the original Task-role context. If a current consultation is pending, inspect its delivery/claim/reply before issuing another. retry-notification reminds only the same current inbox message after failed or completed delivery; it preserves active claims and never repeats an operational repair. Never infer progress from notification, inspect or schedule enabled.\n' + (run.correction ? 'Host protocol feedback (data, not authorization): ' + JSON.stringify(decisionFeedback) + '\nCorrect this judgement using fresh scoped facts. Only IDs in this invocation snapshot are actionable; historical rejected route reasons are not current policy. Do not repeat already applied Task decisions. No consultation or next role was selected by the host.\n' : '') + `You are the resident Project Chief, performing ${run.purpose}. Return ONLY JSON {"summary":"actual assessment","decisions":[{"taskId":"...","action":"close|route|return|request-tests|needs-human|continue|admit|revise|cancel|reopen|resolve-dependency|retry-notification|reconcile-exit|stop-execution|accept-attempt|start-next","targetBotId":"logical Bot when routing","reason":"specific judgement","criteria":[{"criterion":"acceptance item","passed":true,"evidence":"actual acceptance evidence"}]}]}. Your decision action/reason/criteria are your recommended next action and supporting evidence, not a host-selected plan. For Chief decisions read each Task repositoryRecovery.nextActions and continuationOptions in projectFacts; they provide scoped ready/blocked/unknown prechecks only, never authority or an exhaustive plan. Public TaskView.nextActions lists caller-permitted service commands, not all Chief decision actions; TaskView.deliveryChoices projects the decision prechecks. A board listing only chief.inspect does not prohibit accept-attempt or start-next when their current preconditions hold. A rework refusal is not an instruction or permission to clear publicationBinding; inspect the current delivery choices instead. Inspect missing facts; stale/null is not by itself an environment failure. Past refusal messages describe prior outcomes, not current action legality; use current prechecks and evidence to reconsider them. Task requirement revisions before this snapshot are advisory for scoped acceptance; use nextActions.requirements for original/current revisions and changed fields, and its attempt.context source for complete pinned requirements. Do not reinterpret an old delivery as completion of new requirements. Re-read after changes during judgement. accept-attempt accepts a provider-confirmed merged code Attempt against its pinned requirements without closing the Task; include delivery:{completed,remaining,evidence:[references]} with bounded specific scope. A committed acceptance wakes a fresh inspection, never automatically starts Developer. In that new snapshot choose start-next only when needed, with targetBotId for an active implementation role and continuation:{mode:'next',previousAttemptId,generation,expectedWorkspace,expectedBaseSha} copied from fresh continuationOptions. This pins verified target HEAD containing the merged result and creates a new PR identity while retaining Task-role context/history. Never rework or inherit the merged PR binding. close remains available only when all Task criteria/dependencies/inputs are satisfied. Personal Agent assistance is the last resort when actual information/authority or a human decision is missing; failure counts/time never automatically escalate. A conversation/turn completion is not Task completion. Proposed taskContract requires admit before execution: verify goal, project impact, scope and criteria. Prefer admit without repeating already adequate requirements. For admit/revise, every provided requirements.title, requirements.criteria, requirements.impact and requirements.scope must be a bounded nonempty string. In particular requirements.criteria is a string; the separate outer decision.criteria is an evidence array and must never be copied into requirements.criteria. Optional requirements.repository is an associated owner/name or null for a repository-free decision. Uncertain intent requires needs-human. Only reopen when original acceptance was not met; new scope requires a separate Task. Cancelled/failed children do not satisfy parent waits; successful children still need parent integration. All Task mutations remain scoped Chief decisions. To resolve a failed/cancelled child condition without faking child completion, use resolve-dependency with dependency:{taskId,version:the exact current dependency snapshot version,evidence:[actual references],replacementTaskId:optional accepted alternative,replacementVersion:its task outcome version}. This is explicit Chief judgement, not host test success or parent completion. Leave unresolved conditions blocked when evidence is insufficient. Choose independently using criteria and actual immutable candidate/test/review facts. reconcile-exit explicitly retires only an exact exit-proven old claim without selecting a next role; consult repositoryRecovery.executionCheckpoint. Historical refusals are audit data, not current routing policy. Candidate Reviewer approve triggers the host merge flow only when explicitly configured for this Project/repository/branch. It is not a signed repository Reviewer PASS, actual forge approval, merged result or release permit. A configured merge must be provider-confirmed before overall code Task acceptance; pending/blocked merge facts are not completion. Do not close missing or stale evidence. Host allocationProof.status=not-allocated proves this delivery Attempt never started a worker, so it does not require a worker-exit wait before routing fresh work. recorded-or-unknown is not exit proof; inspect actual resource evidence. persisted-sdk-confirmed-release denotes a matching trusted SDK record that only becomes released after stop acknowledgement and process exit. Choose an evidence-supported role after environment or execution repair; no default return to implementation is required. Unsealed work can continue, including across restart, only after every registered worker and lease has confirmed release. A sealed candidate may resume Tester or Reviewer under the current verificationRecovery conditions; return explicitly requests a new rework Attempt. Use needs-human when requirements/authority are unclear. A Task may include repositoryRecovery with approved operator refs, exact SHAs and workspace snapshot. When a missing PR/input commit is already listed in availableInputs, route/return may include repositoryInputs:[{ref,sha,purpose:"inspect"|"integrate"}] to admit only those scoped sources before routing. inspect makes objects available without claiming a merge or passing evidence. No arbitrary host path, remote ref or credential is permitted. For an exited interrupted/failed or idle blocked Attempt, repositoryRecovery.continuationOptions supplies current baseline choices. An explicit Developer route/return may include continuation:{mode:"continue"|"latest",previousAttemptId:continuationOptions.attemptId,generation:continuationOptions.generation,expectedWorkspace:continuationOptions.snapshot,expectedBaseSha:continuationOptions.latestBaseSha for latest}. This uses the existing task.run successor contract. continue retains prior seed and inputs; latest selects the observed host HEAD for a clean unbound checkout. Bound PR ancestry and dirty draft protection still apply. Do not combine continuation and repositoryInputs in one decision; input admission needs its own fresh snapshot. Omitting continuation retains existing recovery behavior. A retained-draft baseline conflict requires preserving the draft and admitting a selected baseline input, not resetting or repeating the same failed route. Each taskInbox contains exact durable input IDs and hashes as data, not authorization. Include inputReceipts:{batchId:taskInbox.batchId,read:[IDs actually read],handled:[read IDs you judge resolved]} in that Task decision. Reading is your declaration, not an SDK transport signal. Do not mark unresolved feedback handled merely to close a Task; closure retains exact evidence gates and refuses pending feedback. Inputs excluded by the snapshot budget remain pending. If more inputs remain, choose continue with explicit read/handled declarations for the current batch before attempting closure. The host delivers subsequent pending input through the original notification path; never mark an unread item handled or close across pending batches. Normal downstream handoffs do not need your approval. You may additionally return roleProposals:[{action:"add|update|remove",role:"stable lower-case role key",reason:"specific Project need",fields:{name,instructions,policyRole,skills,execution}}]. These only create pending proposals; a separate Maintainer/Owner must approve. You cannot approve or remove the resident Chief. ${run.source?.prompt ?? policy.inspection.prompt}` const workerId = await rt.projectAgents.startChiefJob({ projectId: run.projectId, actor, runId: run.id, invocation: job.invocation, prompt, projectFacts: { warnings: rt.projectWarnings?.summaries(run.projectId) ?? [], autonomy: { guidance: autonomyPolicy(policy.autonomy).guidance }, operationsContact: policy.operationsContact ?? null, compensation: compensationSchedules(rt.state, run.projectId, policy.scheduleId), tasks: job.snapshot, logicalBots: job.logicalBots, decisionFeedback, requestedChange: run.source?.requestedChange ?? null }, onResult: result => completed(job, result) }) if (run.invocation === job.invocation) { run.workerId = workerId; if (run.status === 'running') run.waitingFor = 'execution'; run.workerGeneration = rt.state.projectHarnessWorkers?.[workerId]?.generation ?? null; await rt.save() } } catch (error) { diff --git a/patches/ds-bot/files/src/host/project-delivery-options.js b/patches/ds-bot/files/src/host/project-delivery-options.js index edcfc853..30791c38 100644 --- a/patches/ds-bot/files/src/host/project-delivery-options.js +++ b/patches/ds-bot/files/src/host/project-delivery-options.js @@ -11,8 +11,7 @@ export function requireAttemptAcceptance(task, a) { if (!a || task.deliveryAttemptId !== a.id || a.taskId !== task.id || a.projectId !== task.projectId || ['accepted', 'cancelled'].includes(task.status) || a.cancelRequested || a.supersededBy) throw refuse('Attempt acceptance needs the current unfinished Task') if (!['waiting-acceptance', 'accepted'].includes(a.status) || a.status === 'accepted' && !a.settlement) throw refuse('Attempt is not waiting for scoped acceptance') if (task.projectTaskKind && task.projectTaskKind !== 'code') throw refuse('Scoped Attempt acceptance applies to code delivery only') - if (!a.requirement) throw refuse('Current requirement snapshot is unavailable') - if (task.title !== a.requirement.title || (task.criteria ?? '') !== a.requirement.criteria || task.projectTask && task.projectTask.requirements.revision !== a.requirement.projectTaskRevision) throw refuse('Current requirements changed; inspect fresh delivery facts') + if (!a.requirement || typeof a.requirement.title !== 'string' || !a.requirement.title.trim() || typeof a.requirement.criteria !== 'string') throw refuse('Pinned Attempt requirement snapshot is unavailable') if (!a.pr || !a.candidateSha || !a.evidence?.tests?.passed || a.evidence.tests.sha !== a.candidateSha || a.evidence.review?.sha !== a.candidateSha || a.evidence.review.verdict !== 'approve') throw refuse('Current candidate, passing host tests and independent Review are required') } export function requireNextAttempt(task, a) { @@ -25,6 +24,14 @@ export function chiefActionRefusals(state, task) { } export function deliveryChoices(state, task, observation) { const a = state.projectAttempts?.[task.deliveryAttemptId], scope = deliveryScope(state, task), previous = state.projectChiefDecisions?.[task.chiefRecommendationId] + const original = a?.requirement, current = task.projectTask?.requirements + const before = [original?.projectTaskRevision ?? null, original?.title, original?.criteria ?? '', original?.impact ?? '', original?.scope ?? ''] + const after = [current?.revision ?? null, task.title, task.criteria ?? '', current?.impact ?? '', current?.scope ?? ''] + const changedFields = original ? ['revision', 'title', 'criteria', 'impact', 'scope'].filter((_, index) => before[index] !== after[index]) : [] + const requirements = original ? { basis: 'attempt.requirement', attemptRevision: before[0], taskRevision: after[0], changedFields, + priorValues: Object.fromEntries(changedFields.filter(field => field !== 'revision').map(field => [field, original[field] ?? ''])), + source: { query: 'attempt.context', attemptId: a.id, section: 'requirement' }, + notice: 'Judge this delivery against its pinned Attempt requirements; differences are advisory, not Task completion. Current requirements apply to the next delivery and whole-Task closure.' } : null const option = (action, check, note) => { try { check(); return { action, status: 'ready', reason: note } } catch (error) { return { action, status: 'blocked', reason: error.message } } @@ -32,7 +39,7 @@ export function deliveryChoices(state, task, observation) { const merged = observation?.merged === true || a?.evidence?.merge?.scope === 'provider-confirmed-merge-observation' && a.evidence.merge.sha === a.candidateSha const acceptance = option('accept-attempt', () => requireAttemptAcceptance(task, a), 'Chief must judge delivered scope and remaining work; execution rechecks all evidence') if (acceptance.status === 'ready' && (observation?.status === 'unknown' || !merged)) Object.assign(acceptance, { status: observation?.status === 'unknown' ? 'unknown' : 'blocked', reason: observation?.status === 'unknown' ? 'Query the current provider merge result; no merge outcome is inferred' : 'Provider-confirmed merge is required for scoped acceptance' }) - return { expectedScope: scope, observedAt: Date.now(), admission: admissions(state, task).slice(-4), observation: observation ?? null, recommendation: previous?.taskId === task.id && previous.status === 'applied' ? { decisionId: previous.id, action: previous.action, reason: previous.reason, criteria: previous.criteria, expectedScope: previous.expectedScope, alreadyApplied: true } : null, recommendationSource: 'Chief decision action/reason/evidence in the original inference turn', actions: [acceptance, + return { requirements, expectedScope: scope, observedAt: Date.now(), admission: admissions(state, task).slice(-4), observation: observation ?? null, recommendation: previous?.taskId === task.id && previous.status === 'applied' ? { decisionId: previous.id, action: previous.action, reason: previous.reason, criteria: previous.criteria, expectedScope: previous.expectedScope, alreadyApplied: true } : null, recommendationSource: 'Chief decision action/reason/evidence in the original inference turn', actions: [acceptance, option('start-next', () => requireNextAttempt(task, a), 'Read fresh continuation-state; pin verified target HEAD, choose an implementation role and use task.run mode next'), merged ? { action: 'return', status: 'blocked', reason: 'A provider-confirmed merged PR cannot be reworked or reused for publication' } : { action: 'return', status: observation?.status === 'unknown' ? 'unknown' : 'ready', reason: 'Existing route/rework, exit, binding and current authority gates still apply' }, { action: 'close', status: 'unknown', reason: 'Chief must establish every Task criterion, dependency and input; merged or accepted Attempt alone does not complete the Task' }, diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index cb6f964e..7c47a424 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -980,8 +980,8 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const task = taskOf(attempt.taskId) rt.projectInputs?.requireHandled(task.id, actor) if (task.status !== 'result-ready') throw reject('Task is not waiting for delivery acceptance') - if (task.projectTask && attempt.requirement.projectTaskRevision !== task.projectTask.requirements.revision) throw reject('Requirements version changed; delivery evidence is stale') - if (task.title !== attempt.requirement.title || (task.criteria ?? '') !== attempt.requirement.criteria) throw reject('Requirements changed; delivery evidence is stale') + if (!partial && task.projectTask && attempt.requirement.projectTaskRevision !== task.projectTask.requirements.revision) throw reject('Requirements version changed; delivery evidence is stale') + if (!partial && (task.title !== attempt.requirement.title || (task.criteria ?? '') !== attempt.requirement.criteria)) throw reject('Requirements changed; delivery evidence is stale') if (!attempt.runId || !ownEntry(state.projectExecutions, attempt.runId) || !partial && ownEntry(state.projectExecutions, attempt.runId)?.status !== 'done') throw reject('Delivery Run has not finished successfully') if (!attempt.pr || !attempt.evidence.tests?.passed || attempt.evidence.tests.sha !== attempt.candidateSha || attempt.evidence.review?.sha !== attempt.candidateSha || attempt.evidence.review.verdict !== 'approve') throw reject('Delivery evidence is missing or stale') for (const role of DELIVERY_ROLES) await verifyExecution(attempt, deliveryRole(attempt, role)) @@ -1004,7 +1004,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const before = { status: attempt.status, chiefDecision: attempt.chiefDecision, runStatus: run.status, claimStatus: claim?.status, collaborationStatus: c?.status } const token = rt.projectEffects.stage({ kind: 'attempt.accept', key: `${attempt.id}:${attempt.candidateSha}`, projectId: task.projectId, taskId: task.id, payload: { attemptId: attempt.id, candidateSha: attempt.candidateSha, decisionId: partial.id, delivery: partial.delivery } }) attempt.status = 'accepted'; attempt.chiefDecision = { id: partial.id, verdict: 'approve', scope: 'attempt', candidateSha: attempt.candidateSha, by: 'chief', runId: actor.chiefJobId } - attempt.settlement = { ...structuredClone(partial.delivery), decisionId: partial.id, effect: token, candidateSha: attempt.candidateSha, mergeCommitSha: pr.mergeCommitSha, baseBranch: pr.baseRef, requirementsRevision: attempt.requirement.projectTaskRevision ?? null, acceptedBy: actor.memberId, at: Date.now() } + attempt.settlement = { ...structuredClone(partial.delivery), decisionId: partial.id, effect: token, candidateSha: attempt.candidateSha, mergeCommitSha: pr.mergeCommitSha, baseBranch: pr.baseRef, requirementsRevision: attempt.requirement.projectTaskRevision ?? null, taskRequirementsRevision: task.projectTask?.requirements.revision ?? null, acceptedBy: actor.memberId, at: Date.now() } run.status = 'done' if (claim?.chiefRunId === actor.chiefJobId) { claim.status = 'completed'; c.status = 'arbitration' } try { await rt.projectEffects.persist(token) } diff --git a/patches/ds-bot/files/test/project-chief-control.test.js b/patches/ds-bot/files/test/project-chief-control.test.js index 64b956ae..4275be7d 100644 --- a/patches/ds-bot/files/test/project-chief-control.test.js +++ b/patches/ds-bot/files/test/project-chief-control.test.js @@ -865,3 +865,17 @@ test('failed queued next delivery reuses the original recovery notification pass assert.equal(f.rt.state.projectQueue.q.status, 'failed') assert.equal(f.task.chiefQuestion, undefined) }) + +test('requirement changes after scoped acceptance judgement request fresh facts without blocking the Task', async () => { + const f = await controlFixture({ modern: true }) + ++f.task.projectTask.requirements.revision + await f.request.onResult(f.result({ action: 'accept-attempt', delivery: { completed: 'Original slice', remaining: 'Current goal', evidence: ['exact candidate'] } })) + const run = f.rt.state.projectExecutions[f.request.runId] + assert.equal(run.status, 'correction-pending') + assert.equal(run.correction.feedback.at(-1).recovery.kind, 'stale-snapshot') + assert.equal(f.accepted(), 0); assert.notEqual(f.task.status, 'blocked'); assert.equal(f.task.chiefQuestion, undefined) + await f.rt.projectEffects.drain(); await f.control.pump() + await f.request.onResult(f.result({ action: 'continue', reason: 'Inspect the changed requirement before selecting an action' })) + assert.equal(run.status, 'done'); assert.equal(f.accepted(), 0) + await f.rt.projectEffects.close() +}) diff --git a/patches/ds-bot/files/test/project-delivery-options.test.js b/patches/ds-bot/files/test/project-delivery-options.test.js index 17a53f8d..2568210e 100644 --- a/patches/ds-bot/files/test/project-delivery-options.test.js +++ b/patches/ds-bot/files/test/project-delivery-options.test.js @@ -33,7 +33,7 @@ for (const field of ['terminal', 'cancelled', 'superseded', 'foreign', 'status', if (field === 'superseded') a.supersededBy = 'b' if (field === 'foreign') a.projectId = 'other' if (field === 'status') a.status = 'failed' - if (field === 'requirement') task.criteria = 'Changed' + if (field === 'requirement') delete a.requirement if (field === 'review') a.evidence.review.sha = 'old' if (field === 'tests') a.evidence.tests.passed = false if (field === 'kind') task.projectTaskKind = 'release' @@ -46,7 +46,7 @@ test('scoped fingerprints ignore unrelated history but bind relevant revisions a a.evidence.review.summary = 'Changed evidence' assert.notEqual(deliveryScope(state, task), scope) task.projectTask = { requirements: { revision: 1 } } - assert.throws(() => requireAttemptAcceptance(task, a), /requirements/) + assert.doesNotThrow(() => requireAttemptAcceptance(task, a)) assert.throws(() => requireNextAttempt(task, a), /Accept the current/) }) @@ -84,3 +84,32 @@ test('queued next-admission facts are scoped and ordered, and state changes inva state.projectQueue.a.status = 'blocked' assert.notEqual(deliveryScope(state, task), choices.expectedScope) }) + +test('historical Attempt requirements are advisory differences, with bounded facts and the existing disclosure source', () => { + const { state, task, a } = fixture() + a.requirement.projectTaskRevision = 2; a.requirement.impact = 'Initial impact'; a.requirement.scope = 'Initial scope' + task.projectTask = { requirements: { revision: 4, impact: 'Expanded impact', scope: 'Remaining goal' } } + task.title = 'Remaining work'; task.criteria = 'New acceptance criteria' + const choices = deliveryChoices(state, task, { status: 'observed', merged: true }) + assert.equal(choices.actions[0].status, 'ready') + assert.deepEqual(choices.requirements.changedFields, ['revision', 'title', 'criteria', 'impact', 'scope']) + assert.equal(choices.requirements.attemptRevision, 2); assert.equal(choices.requirements.taskRevision, 4) + assert.equal(choices.requirements.priorValues.criteria, 'All requirements') + assert.equal(choices.requirements.source.query, 'attempt.context') + assert.match(choices.requirements.notice, /not.*Task completion/) + assert.doesNotThrow(() => requireAttemptAcceptance(task, a)) +}) + +test('legacy matching requirements keep the advisory compact and absent snapshots stay unknown', () => { + const { state, task, a } = fixture() + assert.deepEqual(deliveryChoices(state, task).requirements.changedFields, []) + assert.deepEqual(deliveryChoices(state, task).requirements.priorValues, {}) + delete a.requirement + assert.equal(deliveryChoices(state, task).requirements, null) +}) + +for (const field of ['title', 'criteria']) test('scoped acceptance still requires a valid pinned ' + field, () => { + const { task, a } = fixture() + delete a.requirement[field] + assert.throws(() => requireAttemptAcceptance(task, a), /snapshot is unavailable/) +}) diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index fe4b1821..b0915655 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -2447,8 +2447,14 @@ test('protected Chief exit retirement excludes only its exact current coordinati assert.equal(lease.status, 'running', 'The current Chief execution was not falsely released') }) -async function mergedDelivery(t) { - const f = await setup(t) +async function mergedDelivery(t, { modern = false } = {}) { + const f = await setup(t, undefined, { modern }) + if (modern) { + f.task = f.rt.state.tasks[f.task.id] + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + const decision = { id: 'initial', taskId: f.task.id, projectId: 'p1', executorRole: 'chief', runId: 'fixture-chief', initiatedBy: alice.memberId, reason: 'Confirm first deliverable' } + admitTask(f.task, decision); reviseTask(f.task, { scope: 'First delivery' }, { ...decision, id: 'revision-two' }) + } await f.start('merged-delivery'); await complete(f) const a = f.attempt() git(f.repository, ['merge', '--ff-only', a.candidateSha]) @@ -2499,7 +2505,7 @@ test('scoped Attempt acceptance rolls back a failed or no-op save and remains id for (const changed of ['candidate', 'requirements', 'provider', 'authority', 'exit']) test('scoped Attempt acceptance refuses changed ' + changed, async t => { const f = await mergedDelivery(t) if (changed === 'candidate') f.a.evidence.tests.sha = 'old' - if (changed === 'requirements') f.task.criteria += ' new requirement' + if (changed === 'requirements') { const read = f.publisher.read; f.publisher.read = async (...args) => { const result = await read(...args); f.task.criteria += ' new requirement'; return result } } if (changed === 'provider') f.publisher.read = async pr => ({ sha: pr.sha, state: 'open', merged: false, baseRef: 'main' }) if (changed === 'authority') f.rt.projectChiefControl.requireJobActor = () => { throw Error('authority revoked') } if (changed === 'exit') { f.rt.state.projectResourceLeases.late = { id: 'late', projectId: 'p1', attemptId: f.a.id, resourceId: 'unknown', generation: 1, role: 'developer', worker: { kind: 'sdk', handle: 'late-worker' }, status: 'unreachable' }; (f.rt.state.projectHarnessWorkers ??= {})['late-worker'] = { id: 'late-worker', leaseId: 'late', projectId: 'p1', attemptId: f.a.id, role: 'developer', resourceId: 'unknown', generation: 1, status: 'unreachable' } } @@ -2554,7 +2560,11 @@ test('pending external write and unknown native exit block scoped acceptance; cu }) test('Chief chooses acceptance then a fresh explicit next delivery through its original decision loop', async t => { - const f = await mergedDelivery(t), requests = [], rt = f.rt + const f = await mergedDelivery(t, { modern: true }), requests = [], rt = f.rt + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + const revision = { projectId: 'p1', taskId: f.task.id, executorRole: 'chief', runId: 'fixture-chief', initiatedBy: alice.memberId, reason: 'Record remaining work' } + reviseTask(f.task, { scope: 'Remaining MVP' }, { ...revision, id: 'next-scope' }) + reviseTask(f.task, { criteria: 'Finish remaining MVP' }, { ...revision, id: 'next-criteria' }) rt.projectChiefControl.requireJobActor = f.originalJobAuthority const carrier = { id: 'public-p1', projectId: 'p1', visibility: 'public', botId: 'a', sessionId: 'a', carrierGeneration: 1 } rt.state.projectAgents ??= {}; rt.state.projectAgents[carrier.id] = carrier @@ -2562,9 +2572,15 @@ test('Chief chooses acceptance then a fresh explicit next delivery through its o rt.projectAgents.startChiefJob = async args => { requests.push(args); return 'fixture-chief-turn' } const waitRequest = async count => { for (let pass = 0; pass < 400 && requests.length < count; ++pass) { await rt.projectChiefControl.pump(); await new Promise(resolve => setTimeout(resolve, 5)) }; assert.ok(requests.length >= count) } const reply = (request, decision) => request.onResult({ ok: true, text: JSON.stringify({ summary: 'Chief recommendation based on current facts', decisions: [{ taskId: f.task.id, ...decision }] }), executionEvidence: { kind: 'native-chief', chiefRunId: request.runId, carrierGeneration: 1, botId: 'a', sessionId: 'a' } }) + const { deliveryScope } = await import('../src/host/project-delivery-options.js') + rt.state.projectChiefDecisions = Object.fromEntries(Array.from({ length: 4 }, (_, index) => ['old-refusal-' + index, { id: 'old-refusal-' + index, projectId: 'p1', taskId: f.task.id, status: 'rejected', expectedScope: deliveryScope(rt.state, f.task), action: 'accept-attempt', failure: 'Current requirements changed; inspect fresh delivery facts', at: index }])) await rt.projectChiefControl.request('p1', 'inspection', { requestId: 'post-merge-judgement' }, f.task.id) await waitRequest(1) assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.actions.find(row => row.action === 'accept-attempt').status, 'ready') + assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.requirements.attemptRevision, 2) + assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.requirements.taskRevision, 4) + assert.equal(requests[0].projectFacts.tasks[0].inspection.chiefActionsWithoutProgress, 4) + assert.equal(requests[0].projectFacts.tasks[0].repositoryRecovery.nextActions.requirements.priorValues.scope, 'First delivery') assert.match(requests[0].prompt, /repositoryRecovery\.nextActions and continuationOptions in projectFacts/) assert.match(requests[0].prompt, /Public TaskView\.nextActions lists caller-permitted service commands, not all Chief decision actions/) assert.match(requests[0].prompt, /not an instruction or permission to clear publicationBinding/) @@ -2583,6 +2599,7 @@ test('Chief chooses acceptance then a fresh explicit next delivery through its o assert.equal(rt.state.projectExecutions[requests[1].runId].status, 'done', rt.state.projectExecutions[requests[1].runId].failure) const next = rt.state.projectAttempts[f.task.deliveryAttemptId] assert.notEqual(next.id, f.a.id); assert.equal(next.baseSha, f.a.candidateSha); assert.equal(next.publicationBinding, undefined) + assert.equal(next.requirement.projectTaskRevision, 4) assert.notEqual(f.task.status, 'accepted'); assert.equal(f.task.chiefQuestion, undefined) }) @@ -2707,3 +2724,26 @@ test('next delivery pins a distinct provider squash commit and reports missing e assert.equal(git(result.attempt.workspaces.developer.path, ['rev-parse', 'HEAD']), merge) assert.equal(result.attempt.publicationBinding, undefined) }) + +test('evolving Task rev4 accepts merged Attempt rev2 and starts the next delivery with current requirements', async t => { + const f = await mergedDelivery(t, { modern: true }), original = structuredClone(f.a.requirement) + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + const decision = { taskId: f.task.id, projectId: 'p1', executorRole: 'chief', runId: 'fixture-chief', initiatedBy: alice.memberId, reason: 'Record delivered scope and remaining goal' } + reviseTask(f.task, { scope: 'Remaining MVP' }, { ...decision, id: 'revision-three' }) + reviseTask(f.task, { title: 'Complete remaining MVP', criteria: 'Remaining features verified' }, { ...decision, id: 'revision-four' }) + assert.equal(f.a.requirement.projectTaskRevision, 2); assert.equal(f.task.projectTask.requirements.revision, 4) + const choices = await f.rt.projectDelivery.deliveryOptions(f.task, f.actor) + assert.equal(choices.actions.find(row => row.action === 'accept-attempt').status, 'ready', JSON.stringify(choices.actions)) + assert.deepEqual(choices.requirements.changedFields, ['revision', 'title', 'criteria', 'scope']) + assert.equal(choices.requirements.source.section, 'requirement') + assert.equal(choices.requirements.priorValues.criteria, original.criteria) + await f.settle() + assert.equal(f.a.settlement.requirementsRevision, 2); assert.equal(f.a.settlement.taskRequirementsRevision, 4) + assert.deepEqual(f.a.requirement, original); assert.equal(f.task.status, 'result-ready') + await assert.rejects(f.rt.projectDelivery.chiefAccept({ taskId: f.task.id, expectedSha: f.a.candidateSha, decision: { id: 'close-new-goal', action: 'close' } }, f.actor), /requirements version/i) + const facts = await f.rt.projectDelivery.endpoint('project-attempt-continuation-state', { attemptId: f.a.id }, alice) + const next = await f.rt.projectEndpoint('project-task-run', { taskId: f.task.id, requestId: 'next-current-requirements', continuation: { mode: 'next', previousAttemptId: f.a.id, generation: facts.generation, expectedWorkspace: facts.snapshot, expectedBaseSha: facts.latestBaseSha } }, alice) + assert.equal(next.attempt.requirement.projectTaskRevision, 4) + assert.equal(next.attempt.requirement.criteria, f.task.criteria) + assert.equal(next.attempt.publicationBinding, undefined); assert.equal(f.a.pr.number, 1) +}) diff --git a/scripts/api-contracts.mjs b/scripts/api-contracts.mjs index 9ebd8c44..c554e350 100644 --- a/scripts/api-contracts.mjs +++ b/scripts/api-contracts.mjs @@ -427,8 +427,9 @@ native.get('project-terminal-open').notes += ' Returns the actual HOME/configDir commands.get('task.run').request.properties.continuation = obj({ mode: choice('continue', 'latest', 'next'), expectedBaseSha: sha, previousAttemptId: id, generation: int(), expectedWorkspace: json }, ['mode', 'previousAttemptId', 'generation', 'expectedWorkspace']) commands.get('task.run').request.properties.continuation.allOf = [{ if: { properties: { mode: { enum: ['latest', 'next'] } }, required: ['mode'] }, then: { required: ['expectedBaseSha'] } }] schemas.DeliveryChoices = obj({ expectedScope: str(), observedAt: int(), admission: array(json), observation: nullable(json), recommendation: nullable(obj({ decisionId: id, action: str(), reason: str(), criteria: array(json), expectedScope: nullable(str()), alreadyApplied: lit(true) })), recommendationSource: str(), actions: array(obj({ action: str(), status: choice('ready', 'blocked', 'unknown'), reason: str() }, ['action', 'status', 'reason'])), note: str() }, ['expectedScope', 'observedAt', 'actions', 'note']) +schemas.DeliveryChoices.properties.requirements = nullable(obj({ basis: lit('attempt.requirement'), attemptRevision: nullable(int()), taskRevision: nullable(int()), changedFields: array(choice('revision', 'title', 'criteria', 'impact', 'scope')), priorValues: map(str(16000)), source: obj({ query: lit('attempt.context'), attemptId: id, section: lit('requirement') }, ['query', 'attemptId', 'section']), notice: str() }, ['basis', 'attemptRevision', 'taskRevision', 'changedFields', 'priorValues', 'source', 'notice'])) schemas.TaskView.properties.deliveryChoices = nullable(ref('DeliveryChoices')) -schemas.AttemptSettlement = obj({ completed: str(2000), remaining: str(2000), evidence: array(str(1200)), decisionId: id, effect: json, candidateSha: sha, mergeCommitSha: sha, baseBranch: str(), requirementsRevision: nullable(int()), acceptedBy: id, at: int() }, ['completed', 'remaining', 'evidence', 'decisionId', 'effect', 'candidateSha', 'mergeCommitSha', 'baseBranch', 'acceptedBy', 'at']) +schemas.AttemptSettlement = obj({ completed: str(2000), remaining: str(2000), evidence: array(str(1200)), decisionId: id, effect: json, candidateSha: sha, mergeCommitSha: sha, baseBranch: str(), requirementsRevision: nullable(int()), taskRequirementsRevision: nullable(int()), acceptedBy: id, at: int() }, ['completed', 'remaining', 'evidence', 'decisionId', 'effect', 'candidateSha', 'mergeCommitSha', 'baseBranch', 'acceptedBy', 'at']) schemas.Attempt.properties.settlement = ref('AttemptSettlement') schemas.Continuation = obj({ version: lit('project-continuation/v1'), sourceScope: hash, mode: choice('continue', 'latest', 'next'), 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') From 40bc55e25346e2cf2d6da6c992cd73bd37fd6d8b Mon Sep 17 00:00:00 2001 From: zy95-12 <135135297+zy95-12@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:08:27 +0800 Subject: [PATCH 2/3] Document requirement comparison and settlement revision fields --- docs/subsystems/backend-service.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/docs/subsystems/backend-service.md b/docs/subsystems/backend-service.md index 44433239..529f9965 100644 --- a/docs/subsystems/backend-service.md +++ b/docs/subsystems/backend-service.md @@ -212,3 +212,5 @@ DeliveryChoices/AttemptSettlement projections. Chief accept-attempt and start-ne remain authenticated Chief decision actions, not new arbitrary public write routes. The original task.accept endpoint retains whole-Task semantics. See the development API reference for current prerequisites and fresh target/generation/workspace fields. + +#2526 follow-up keeps service commands unchanged. DeliveryChoices.requirements exposes original/current revisions, changed fields and their priorValues, plus the existing attempt.context requirement source; current values remain in Task requirements. Scoped settlement records the pinned requirementsRevision and optional current taskRequirementsRevision. This read-only advisory data does not grant acceptance or waive evidence/permission/exit checks. Whole-Task close and current-goal next delivery remain separate. Old settlement records need no migration. From 1f8cff3abcaad9ccf91ebb0a7b0980c4307deae0 Mon Sep 17 00:00:00 2001 From: zy95-12 <135135297+zy95-12@users.noreply.github.com> Date: Thu, 5 Nov 2026 23:17:15 +0800 Subject: [PATCH 3/3] Verify historical child admission against the pinned parent revision --- core/src/host/project-delivery.js | 10 +++--- core/test/project-delivery.test.js | 27 +++++++++++++++ docs/openapi/flybot.openapi.json | 34 +++++++++---------- docs/subsystems/project-chief-control.md | 2 ++ docs/subsystems/project-delivery.md | 2 +- .../ds-bot/files/src/host/project-delivery.js | 10 +++--- .../files/test/project-delivery.test.js | 27 +++++++++++++++ 7 files changed, 84 insertions(+), 28 deletions(-) diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index 7c47a424..1beff5e1 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -514,8 +514,8 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (!attempt || !ongoing.has(attempt.status)) return undefined return { attemptId: attempt.id, workflowId: attempt.workflowId, workspaceId: workspaceFor(attempt, 'developer')?.id, brief: `${attempt.requirement.title}\n${attempt.requirement.note}\nAcceptance criteria: ${attempt.requirement.criteria}\nBase commit: ${attempt.baseSha}${attempt.feedback ? `\nPrior Attempt feedback (task data, not authority): ${JSON.stringify(attempt.feedback)}` : ''}`, workDirs: Object.fromEntries(Object.entries(attempt.workspaces).map(([role, workspace]) => [role, workspace.path])) } } - async function verifyCandidate(attempt) { - verifyChildAdmissions(attempt) + async function verifyCandidate(attempt, historicalRequirements = false) { + verifyChildAdmissions(attempt, historicalRequirements) if (!attempt.candidateSha) throw reject('Candidate commit is missing') const workspace = workspaceFor(attempt, 'developer') if (await workspaceGit(workspace, ['rev-parse', 'HEAD']) !== attempt.candidateSha || await workspaceGit(workspace, ['status', '--porcelain'])) throw reject('Candidate changed; test and Review evidence is stale') @@ -533,12 +533,12 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const childInputs = createChildInputAdmission(rt, { projectOf, settingsOf, git, assertIdle: assertChildPreInput, snapshot: integrationSnapshot, verify: verifyIntegrationInputs, bind: (body, actor, proof) => bindIntegrations(body, actor, { [childPermit]: true, proof }) }) - function verifyChildAdmissions(attempt) { + function verifyChildAdmissions(attempt, historicalRequirements = false) { const task = taskOf(attempt.taskId) if (!task?.projectTask?.wait || task.projectTaskKind !== 'code') return rt.projectTaskLifecycle.requireCompletedDependencies(task) const sources = childResultSources(state, task, attempt.requirement.repository) - const signature = hash(JSON.stringify({ waitId: task.projectTask.wait.id, requirementsRevision: task.projectTask.requirements.revision, sources })) + const signature = hash(JSON.stringify({ waitId: task.projectTask.wait.id, requirementsRevision: historicalRequirements ? attempt.requirement.projectTaskRevision : task.projectTask.requirements.revision, sources })) const required = sources.filter(row => row.integration === 'required') if (!required.length) return const admission = attempt.integrationAdmissions?.findLast(row => row.origin === 'project-child-results' && row.sourceWaitId === task.projectTask.wait.id && row.sourcesFingerprint === signature) @@ -976,7 +976,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti } if (!partial && attempt.protocol === 'project-handoff/v1' && attempt.chiefDecision?.verdict !== 'approve') throw reject('Chief arbitration is required before overall completion') if (attempt.status !== 'waiting-acceptance' && !(attempt.status === 'accepted' && attempt.settlement)) throw reject('Attempt has not produced delivery evidence') - await verifyCandidate(attempt) + await verifyCandidate(attempt, Boolean(partial)) const task = taskOf(attempt.taskId) rt.projectInputs?.requireHandled(task.id, actor) if (task.status !== 'result-ready') throw reject('Task is not waiting for delivery acceptance') diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index b0915655..620f407f 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -2747,3 +2747,30 @@ test('evolving Task rev4 accepts merged Attempt rev2 and starts the next deliver assert.equal(next.attempt.requirement.criteria, f.task.criteria) assert.equal(next.attempt.publicationBinding, undefined); assert.equal(f.a.pr.number, 1) }) + +test('historical parent settlement retains the original durable child admission revision', async t => { + const f = await childIntegrationFixture(t), a = f.attempt, task = f.task + await f.rt.projectDelivery.admitChildInputs(task.id, f.claim) + git(f.path, ['merge', '--no-commit', '--no-ff', ...f.sources.map(row => row.sha)]) + await f.rt.projectDelivery.completePoolRole({ task, claim: f.claim, role: 'developer', outputs: {} }) + const roles = f.rt.projectAgents.deliveryDefinitions('p1').roles + await prepareRole(f.rt, task, { id: 'test-parent', attemptId: a.id, generation: 2 }, roles.find(r => r.role === 'tester')) + await f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: a.id }, role: 'tester', outputs: {} }) + await prepareRole(f.rt, task, { id: 'review-parent', attemptId: a.id, generation: 3 }, roles.find(r => r.role === 'reviewer')) + await f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: a.id }, role: 'reviewer', outputs: { review: JSON.stringify({ commit: a.candidateSha, verdict: 'approve', summary: 'Original parent and child results verified' }) } }) + git(f.repository, ['merge', '--ff-only', a.candidateSha]) + f.publisher.read = async pr => ({ sha: pr.sha, state: 'closed', merged: true, mergeCommitSha: a.candidateSha, baseRef: 'main', baseSha: a.candidateSha }) + task.collaboration.claim.status = 'completed'; task.collaboration.claim.exitConfirmed = true + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + reviseTask(task, { scope: 'Additional parent goal' }, { id: 'parent-next-goal', projectId: 'p1', taskId: task.id, executorRole: 'chief', runId: 'parent-chief', initiatedBy: alice.memberId, reason: 'Plan remaining goal after merged child integration' }) + const accepted = () => f.rt.projectDelivery.chiefAccept({ taskId: task.id, expectedSha: a.candidateSha, decision: { id: 'settle-parent', action: 'accept-attempt', delivery: { completed: 'Parent integrated the original accepted children', remaining: 'Additional parent goal', evidence: ['original durable child admission, tested candidate and merged PR'] } } }, { ...alice, chiefJobId: 'parent-chief' }) + f.rt.projectChiefControl.requireJobActor = actor => { assert.equal(actor.chiefJobId, 'parent-chief') } + const admission = a.integrationAdmissions.find(row => row.origin === 'project-child-results'), originalFingerprint = admission.sourcesFingerprint + const effect = f.rt.state.projectEffects[admission.effect.id], beforeEffect = structuredClone(effect) + delete f.rt.state.projectEffects[admission.effect.id] + await assert.rejects(accepted(), /durable source admission/) + f.rt.state.projectEffects[admission.effect.id] = beforeEffect + await accepted() + assert.equal(a.status, 'accepted'); assert.equal(a.settlement.requirementsRevision, 1); assert.equal(a.settlement.taskRequirementsRevision, 2) + assert.equal(admission.sourcesFingerprint, originalFingerprint); assert.notEqual(task.status, 'accepted') +}) diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index ddc20ec2..ac479a8b 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -35547,7 +35547,7 @@ "notes": "Read continuation facts even after cancellation. Accepted scoped code delivery exposes only next mode: verify target HEAD contains its merged result and do not inherit its old PR binding. nextActions is advisory, not authorization. task.run selects a successor, never revives the cancelled Attempt.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -35629,7 +35629,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": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -35707,7 +35707,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": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -37863,7 +37863,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": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -37971,7 +37971,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -38078,7 +38078,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -38172,7 +38172,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -38276,7 +38276,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": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -38386,7 +38386,7 @@ "notes": "Fetch attempt.integration-state again after retirement; attempt.stop remains cancellation.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -38495,7 +38495,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" } }, { @@ -49005,7 +49005,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": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" }, "aliasOf": "development.command.task.accept" }, @@ -49098,7 +49098,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" }, "aliasOf": "development.command.task.reject" }, @@ -49190,7 +49190,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" }, "aliasOf": "development.command.attempt.stop" }, @@ -49282,7 +49282,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" }, "aliasOf": "development.command.attempt.cleanup" }, @@ -49671,7 +49671,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": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" }, "aliasOf": "development.command.attempt.publication-retry" }, @@ -49747,7 +49747,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": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad" + "sha256": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500" }, "aliasOf": "development.query.attempt.log" }, @@ -51131,7 +51131,7 @@ "core/src/host/project-delegations.js": "d4d86192551994a3419e0d2ca0f64bc4d50a75960970d2b79ed9f97b82940903", "core/src/host/project-delivery-options.js": "591aff7f7a8f6b6938b0fcb02592444c5398d960be5f824cf80c31a76d4d38e4", "core/src/host/project-delivery-template.js": "f149d09786ac6db41a7ebeccb7620cce62b57f334d3ddd5e3c66ee673e5b66a8", - "core/src/host/project-delivery.js": "7ef336b7a83625ddbb43522f883422dd09bf258317bead517fef69d1b84ec9ad", + "core/src/host/project-delivery.js": "17d976ca26336000ebed6dbf328aed4a016754b87be410a1c5aa8f5bf34ea500", "core/src/host/project-development-service.js": "8d3ee6533b55c52fd5989a49db1d575effac9622f92070c32b39aa25c3e89691", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "dc02f89eb221d543805d115e48a9534882a344d233bf089299e43b1a1a4ede63", diff --git a/docs/subsystems/project-chief-control.md b/docs/subsystems/project-chief-control.md index f28b37d2..b79da50f 100644 --- a/docs/subsystems/project-chief-control.md +++ b/docs/subsystems/project-chief-control.md @@ -411,3 +411,5 @@ Inspection facts also expose chiefActionsWithoutProgress and up to five refusedC Scoped acceptance judges the immutable Attempt requirement, never equality with the latest Task revision. repositoryRecovery.nextActions.requirements supplies both revisions, changed field names and only their prior values; the Task snapshot carries current values. Complete pinned requirements use the existing attempt.context requirement page. This is a bounded read projection, not a planner or permission token. The host still verifies current candidate/test/Review/merge, identity, rights, generation and exit; execution rechecks the full local scope after I/O. Requirements that change after a Chief snapshot use existing protocol correction with stale-snapshot feedback, so Chief can reconsider the original intent. Earlier legitimate Task revisions do not permanently block historical settlement. Neither outcome satisfies new Task criteria or chooses a downstream role/Personal Agent automatically. start-next copies current requirements; whole-Task closure remains independently gated. + +Scoped parent settlement uses the pinned parent requirement revision to locate its original child-input admission. Actual child evidence, committed admission Effects, accepted sources and parent integration remain required; whole-Task verification/admission still uses current requirements. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index 232617e3..2a82710f 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -738,7 +738,7 @@ stop is inferred. Ordinary public task.run still refuses active Attempts. ## Scoped Attempt acceptance and next delivery (#2526) -Chief accept-attempt verifies pinned requirements/candidate, host tests, Review, +Chief accept-attempt verifies pinned requirements/child-admission revision/candidate, tests, Review, policy/SDK evidence, provider-confirmed merge and actual execution exit. Its bounded completed/remaining/evidence record belongs to the Attempt. Task revisions are advisory; requirementsRevision pins the Attempt and taskRequirementsRevision records the current Task. diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index 7c47a424..1beff5e1 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -514,8 +514,8 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti if (!attempt || !ongoing.has(attempt.status)) return undefined return { attemptId: attempt.id, workflowId: attempt.workflowId, workspaceId: workspaceFor(attempt, 'developer')?.id, brief: `${attempt.requirement.title}\n${attempt.requirement.note}\nAcceptance criteria: ${attempt.requirement.criteria}\nBase commit: ${attempt.baseSha}${attempt.feedback ? `\nPrior Attempt feedback (task data, not authority): ${JSON.stringify(attempt.feedback)}` : ''}`, workDirs: Object.fromEntries(Object.entries(attempt.workspaces).map(([role, workspace]) => [role, workspace.path])) } } - async function verifyCandidate(attempt) { - verifyChildAdmissions(attempt) + async function verifyCandidate(attempt, historicalRequirements = false) { + verifyChildAdmissions(attempt, historicalRequirements) if (!attempt.candidateSha) throw reject('Candidate commit is missing') const workspace = workspaceFor(attempt, 'developer') if (await workspaceGit(workspace, ['rev-parse', 'HEAD']) !== attempt.candidateSha || await workspaceGit(workspace, ['status', '--porcelain'])) throw reject('Candidate changed; test and Review evidence is stale') @@ -533,12 +533,12 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti const childInputs = createChildInputAdmission(rt, { projectOf, settingsOf, git, assertIdle: assertChildPreInput, snapshot: integrationSnapshot, verify: verifyIntegrationInputs, bind: (body, actor, proof) => bindIntegrations(body, actor, { [childPermit]: true, proof }) }) - function verifyChildAdmissions(attempt) { + function verifyChildAdmissions(attempt, historicalRequirements = false) { const task = taskOf(attempt.taskId) if (!task?.projectTask?.wait || task.projectTaskKind !== 'code') return rt.projectTaskLifecycle.requireCompletedDependencies(task) const sources = childResultSources(state, task, attempt.requirement.repository) - const signature = hash(JSON.stringify({ waitId: task.projectTask.wait.id, requirementsRevision: task.projectTask.requirements.revision, sources })) + const signature = hash(JSON.stringify({ waitId: task.projectTask.wait.id, requirementsRevision: historicalRequirements ? attempt.requirement.projectTaskRevision : task.projectTask.requirements.revision, sources })) const required = sources.filter(row => row.integration === 'required') if (!required.length) return const admission = attempt.integrationAdmissions?.findLast(row => row.origin === 'project-child-results' && row.sourceWaitId === task.projectTask.wait.id && row.sourcesFingerprint === signature) @@ -976,7 +976,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti } if (!partial && attempt.protocol === 'project-handoff/v1' && attempt.chiefDecision?.verdict !== 'approve') throw reject('Chief arbitration is required before overall completion') if (attempt.status !== 'waiting-acceptance' && !(attempt.status === 'accepted' && attempt.settlement)) throw reject('Attempt has not produced delivery evidence') - await verifyCandidate(attempt) + await verifyCandidate(attempt, Boolean(partial)) const task = taskOf(attempt.taskId) rt.projectInputs?.requireHandled(task.id, actor) if (task.status !== 'result-ready') throw reject('Task is not waiting for delivery acceptance') diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index b0915655..620f407f 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -2747,3 +2747,30 @@ test('evolving Task rev4 accepts merged Attempt rev2 and starts the next deliver assert.equal(next.attempt.requirement.criteria, f.task.criteria) assert.equal(next.attempt.publicationBinding, undefined); assert.equal(f.a.pr.number, 1) }) + +test('historical parent settlement retains the original durable child admission revision', async t => { + const f = await childIntegrationFixture(t), a = f.attempt, task = f.task + await f.rt.projectDelivery.admitChildInputs(task.id, f.claim) + git(f.path, ['merge', '--no-commit', '--no-ff', ...f.sources.map(row => row.sha)]) + await f.rt.projectDelivery.completePoolRole({ task, claim: f.claim, role: 'developer', outputs: {} }) + const roles = f.rt.projectAgents.deliveryDefinitions('p1').roles + await prepareRole(f.rt, task, { id: 'test-parent', attemptId: a.id, generation: 2 }, roles.find(r => r.role === 'tester')) + await f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: a.id }, role: 'tester', outputs: {} }) + await prepareRole(f.rt, task, { id: 'review-parent', attemptId: a.id, generation: 3 }, roles.find(r => r.role === 'reviewer')) + await f.rt.projectDelivery.completePoolRole({ task, claim: { attemptId: a.id }, role: 'reviewer', outputs: { review: JSON.stringify({ commit: a.candidateSha, verdict: 'approve', summary: 'Original parent and child results verified' }) } }) + git(f.repository, ['merge', '--ff-only', a.candidateSha]) + f.publisher.read = async pr => ({ sha: pr.sha, state: 'closed', merged: true, mergeCommitSha: a.candidateSha, baseRef: 'main', baseSha: a.candidateSha }) + task.collaboration.claim.status = 'completed'; task.collaboration.claim.exitConfirmed = true + const { reviseTask } = await import('../src/host/project-task-lifecycle.js') + reviseTask(task, { scope: 'Additional parent goal' }, { id: 'parent-next-goal', projectId: 'p1', taskId: task.id, executorRole: 'chief', runId: 'parent-chief', initiatedBy: alice.memberId, reason: 'Plan remaining goal after merged child integration' }) + const accepted = () => f.rt.projectDelivery.chiefAccept({ taskId: task.id, expectedSha: a.candidateSha, decision: { id: 'settle-parent', action: 'accept-attempt', delivery: { completed: 'Parent integrated the original accepted children', remaining: 'Additional parent goal', evidence: ['original durable child admission, tested candidate and merged PR'] } } }, { ...alice, chiefJobId: 'parent-chief' }) + f.rt.projectChiefControl.requireJobActor = actor => { assert.equal(actor.chiefJobId, 'parent-chief') } + const admission = a.integrationAdmissions.find(row => row.origin === 'project-child-results'), originalFingerprint = admission.sourcesFingerprint + const effect = f.rt.state.projectEffects[admission.effect.id], beforeEffect = structuredClone(effect) + delete f.rt.state.projectEffects[admission.effect.id] + await assert.rejects(accepted(), /durable source admission/) + f.rt.state.projectEffects[admission.effect.id] = beforeEffect + await accepted() + assert.equal(a.status, 'accepted'); assert.equal(a.settlement.requirementsRevision, 1); assert.equal(a.settlement.taskRequirementsRevision, 2) + assert.equal(admission.sourcesFingerprint, originalFingerprint); assert.notEqual(task.status, 'accepted') +})