From 191f38876a8c62da3a6e3e8b9b7bd9da2d3fd937 Mon Sep 17 00:00:00 2001 From: zy95-12 <135135297+zy95-12@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:33:54 +0800 Subject: [PATCH 1/2] Preserve approved initial role choices across Task admission and queue recovery --- PROJECT.md | 4 +++ ROADMAP.md | 2 ++ changelog.d/2292-role-admission.fixed.md | 1 + core/src/host/project-delivery.js | 5 +++- core/src/host/project-pools.js | 10 ++++--- core/src/host/project-role-admission.js | 17 ++++++++++++ core/src/host/project-scheduler.js | 27 +++++++++++++------ core/src/host/project-task-lifecycle.js | 13 +++++---- core/test/project-pools.test.js | 16 +++++++++++ core/test/project-role-admission.test.js | 26 ++++++++++++++++++ core/test/project-scheduler.test.js | 27 +++++++++++++++++++ core/test/project-task-lifecycle.test.js | 12 +++++++++ docs/development-service-api.md | 4 +-- docs/subsystems/project-delivery.md | 2 ++ docs/subsystems/project-pools.md | 6 +++++ docs/subsystems/project-task-lifecycle.md | 2 ++ docs/subsystems/project-workbench.md | 2 ++ .../ds-bot/files/src/host/project-delivery.js | 5 +++- .../ds-bot/files/src/host/project-pools.js | 10 ++++--- .../files/src/host/project-role-admission.js | 17 ++++++++++++ .../files/src/host/project-scheduler.js | 27 +++++++++++++------ .../files/src/host/project-task-lifecycle.js | 13 +++++---- .../ds-bot/files/test/project-pools.test.js | 16 +++++++++++ .../files/test/project-role-admission.test.js | 26 ++++++++++++++++++ .../files/test/project-scheduler.test.js | 27 +++++++++++++++++++ .../files/test/project-task-lifecycle.test.js | 12 +++++++++ 26 files changed, 293 insertions(+), 36 deletions(-) create mode 100644 changelog.d/2292-role-admission.fixed.md create mode 100644 core/src/host/project-role-admission.js create mode 100644 core/test/project-role-admission.test.js create mode 100644 patches/ds-bot/files/src/host/project-role-admission.js create mode 100644 patches/ds-bot/files/test/project-role-admission.test.js diff --git a/PROJECT.md b/PROJECT.md index 0aefd8276..86ed907fb 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -292,3 +292,7 @@ Keep personal Chief/Bots, groups, private memories and original Task/Workflow ID ### 2026-11-03 — Persistent deployment and final API docs (#2189) Use scripts/backend.mjs to initialize/start the original DSH/FlyBot host plus existing restricted HTTP/SSE gateway in a private persistent home. Login and CLI calls reuse native/platform identity; no mock model, sample repository, parallel executor or frontend replacement is packaged. Original guides move to README.native variants. Auth-mode confirms Store writability before readiness. External safe proposal impact/scope must survive publication into canonical requirements (actual production-entry regression). Deployment/restart, deterministic HTTP/DSH tests and real-provider acceptance remain separate evidence. + +### Initial role routing — #2292 + +Initial execution pins an approved active logical role/capability, catalog revision and Task requirements revision. Default developer remains compatible; a unique custom developer can replace it, while ambiguity needs an explicit target. Chief start Effects, capacity queues and Attempts retain the choice. Catalog/requirements changes block pending work for explicit recovery; active Session conventions remain frozen. diff --git a/ROADMAP.md b/ROADMAP.md index f1417830a..e499cd209 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -459,3 +459,5 @@ Canonical ongoing role/resource authority checks now reuse native step/SDK input #2186 adds the original-capability inventory and committed legacy cutover on the existing Store, group/Chief channels and Workflow controller. Validate same-version JSON/SQLite rollback separately from real-provider and UI acceptance; final real backend packaging/README #2189 follows. #2189 supplies the persistent source init/start/login/API client and final object/API/operations documentation. Verify isolated fresh installation, retained auth/Task receipt across restart, consultation privacy, external safe/watch and SSE revoke; keep real-provider/remote/browser/production rollout evidence separate. Existing service/frontend flows are untouched. + +#2292 follow-up: initial-role admission now follows approved capabilities with durable queue/start bindings; repository selection, bounded legacy history, Release evidence and real-loop acceptance remain pending. diff --git a/changelog.d/2292-role-admission.fixed.md b/changelog.d/2292-role-admission.fixed.md new file mode 100644 index 000000000..8d852e5f3 --- /dev/null +++ b/changelog.d/2292-role-admission.fixed.md @@ -0,0 +1 @@ +Route initial Project execution through approved roles and retain the chosen role across queue waits, restart and explicit recovery. diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index b767a068a..cad18ea65 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -5,6 +5,7 @@ import { mkdir, readFile, realpath, rm, writeFile, lstat } from 'node:fs/promise import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' import { ownEntry, setEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { DELIVERY_ROLES, DELIVERY_VERSION, deliveryGraph } from './project-delivery-template.js' import { repositorySettings, preflightRepository } from './project-repository-policy.js' import { createResourceWorkspaces, fileDigest } from './project-resource-workspaces.js' @@ -239,14 +240,16 @@ export function createProjectDelivery(rt, { publisher } = {}) { if (prior) return { attempt: view(prior), already: true } if (Object.values(attempts()).some(attempt => attempt.taskId === task.id && (ongoing.has(attempt.status) || attempt.status === 'waiting-acceptance'))) throw reject('Task already has an active Attempt') if (task.status === 'accepted' || task.status === 'cancelled') throw reject('Terminal Task cannot start a delivery') + const initialTarget = initialRoleTarget(rt, task, body) const settings = await preflightRepository(rt, project) + if (initialTarget) initialRoleTarget(rt, task, { initialTarget }) const previous = current(task.id) const publicationBinding = await publicationOf(previous, project) const feedback = previous?.failure ? { previousAttemptId: previous.id, kind: previous.returned ? 'human-return' : 'execution-failure', reason: previous.failure.message, candidateSha: previous.candidateSha ?? null } : null const at = Date.now() const attempt = { id: `attempt-${randomUUID()}`, projectId: project.id, taskId: task.id, requestKey, workflowVersion: DELIVERY_VERSION, status: 'preparing', initiatedBy: actor.memberId, executionOwnerId: project.ownerUserId, - requirement: requirementsOf(task, project.repository), + requirement: requirementsOf(task, project.repository), ...(initialTarget ? { initialTarget } : {}), ...(feedback ? { feedback } : {}), ...(publicationBinding ? { publicationBinding, seedSha: previous.candidateSha ?? previous.seedSha ?? publicationBinding.previousSha, baseSha: previous.baseSha } : {}), workspaces: {}, evidence: {}, createdAt: at, updatedAt: at } setEntry(attempts(), attempt.id, attempt) task.deliveryAttemptId = attempt.id diff --git a/core/src/host/project-pools.js b/core/src/host/project-pools.js index 26dc21170..51aa605d9 100644 --- a/core/src/host/project-pools.js +++ b/core/src/host/project-pools.js @@ -1,6 +1,7 @@ // Per-logical-Bot routing on existing Task records. Execution remains a host adapter. import { randomUUID, createHash } from 'node:crypto' import { ownEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { taskSessionId } from './project-task-sessions.js' import { stageRoleSummary, rollbackRoleSummary, stageExecutionMemory, rollbackExecutionMemory, roleMemoryView } from './project-context-memory.js' import { EffectDeferred, EffectCancelled } from './project-effects.js' @@ -117,7 +118,7 @@ export function createProjectPools(rt) { if (!checked.ok) throw refuse(`Handoff refused (${checked.code}): ${checked.reason}`) return { fromRole, trustedEvidence } } - async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary, experience }) { + async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary, experience, initialTarget }) { if (!adapter) throw refuse('Handoff execution adapter is unavailable') reason = reasonOf(reason) const c = task.collaboration, target = targetOf(task.projectId, to) @@ -129,6 +130,7 @@ export function createProjectPools(rt) { } await authority(task) const { fromRole, trustedEvidence } = await checkAdmission(task, { from, to, attemptId, requestId, purpose }) + if (initialTarget) initialRoleTarget(rt, task, { initialTarget }) if (expectedGeneration !== undefined && c.generation !== expectedGeneration) throw refuse('Claim generation changed before handoff admission') const row = { id: `handoff-${randomUUID()}`, version: POOL_PROTOCOL, policyVersion: 'project-role-policy/v1', definitionRevision: definitions(task.projectId).revision, projectId: task.projectId, taskId: task.id, attemptId, initiatedBy: c.initiatedBy, from, to, reason, requestId, purpose, evidence: clone(trustedEvidence), claims: clone(evidence), requirements: { title: task.title, criteria: task.criteria ?? '' }, status: 'pending', generation: c.generation + 1, createdAt: Date.now() } const sourceClaim = c.claim?.botId === from ? c.claim : null @@ -183,12 +185,14 @@ export function createProjectPools(rt) { rt.projectScheduler?.wake() return clone(row) } - async function admit(attempt, actor, { targetRole = 'developer', kind } = {}) { + async function admit(attempt, actor, { targetRole, initialTarget, kind } = {}) { const task = taskOf(attempt.taskId) rt.projectTaskLifecycle?.requireExecutable(task) if (kind && task.projectTaskKind && kind !== task.projectTaskKind) throw refuse('Task kind cannot change at execution admission') kind ??= task.projectTaskKind ?? 'code' rt.requireProjectAccess(actor, task.projectId, 'task-write', task) + const selected = initialRoleTarget(rt, task, { targetRole, initialTarget: initialTarget ?? attempt.initialTarget }) + targetRole = selected?.role ?? targetRole ?? 'developer' const target = definitions(task.projectId).roles.find(row => row.role === targetRole) if (!target || !['code', 'decision', 'release'].includes(kind)) throw refuse('Unsupported Task target or kind') task.collaboration ??= { version: POOL_PROTOCOL, initiatedBy: actor.memberId, generation: 0, history: [] } @@ -197,7 +201,7 @@ export function createProjectPools(rt) { c.sourceSessionId = actor.sessionId ?? null c.initiatedBy = actor.memberId c.attemptId = attempt.id; c.kind = kind - return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id }) + return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id, initialTarget: selected }) } async function claim(taskId, botId, handoffId, requestId) { const task = taskOf(taskId), c = task.collaboration diff --git a/core/src/host/project-role-admission.js b/core/src/host/project-role-admission.js new file mode 100644 index 000000000..d7ebce338 --- /dev/null +++ b/core/src/host/project-role-admission.js @@ -0,0 +1,17 @@ +// Initial routing is a durable choice, separate from an active role Session. +const fail = message => Object.assign(new Error(message), { code: 'bot/handoff' }) +export function initialRoleTarget(rt, task, body = {}) { + if (rt.config.projectDispatch === 'workflow' || !rt.projectAgents?.deliveryDefinitions) return undefined + const catalog = rt.projectAgents.deliveryDefinitions(task.projectId) + const revision = task.projectTask?.requirements.revision ?? null + const pinned = body.initialTarget + if (pinned && (pinned.definitionRevision !== catalog.revision || pinned.requirementsRevision !== revision)) throw fail('Initial role catalog or Task requirements changed; explicitly select a target again') + const requested = body.targetRole ?? pinned?.role + if (requested !== undefined && (typeof requested !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(requested))) throw fail('Invalid initial targetRole') + const candidates = catalog.roles.filter(row => (row.policyRole ?? row.role) === 'developer') + const target = requested ? catalog.roles.find(row => row.role === requested) : candidates.find(row => row.role === 'developer') ?? (candidates.length === 1 ? candidates[0] : null) + if (!target || target.role === 'chief') throw fail('An active initial role is required; provide an explicit targetRole when developer capabilities are ambiguous') + const result = { role: target.role, botId: target.id, policyRole: target.policyRole ?? target.role, definitionRevision: catalog.revision, requirementsRevision: revision } + if (pinned && Object.keys(result).some(key => result[key] !== pinned[key])) throw fail('Initial role selection changed; explicitly select a target again') + return result +} diff --git a/core/src/host/project-scheduler.js b/core/src/host/project-scheduler.js index 5cc8649a6..cb24fa06b 100644 --- a/core/src/host/project-scheduler.js +++ b/core/src/host/project-scheduler.js @@ -1,6 +1,7 @@ // Persisted Project admission queue. The existing Workflow service still dispatches Runs. import { createHash, randomUUID } from 'node:crypto' import { ownEntry, setEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { preflightRepository } from './project-repository-policy.js' const ACTIVE = new Set(['queued', 'running', 'working', 'paused', 'legacy']) const error = message => Object.assign(new Error(message), { code: 'bot/invalid' }) @@ -46,8 +47,8 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = } return changed } - function resourceBlock(projectId) { - try { const definitions = rt.projectAgents?.deliveryDefinitions(projectId); rt.projectResources?.plan(projectId, rt.config.projectDispatch === 'workflow' ? definitions : { ...definitions, roles: definitions?.roles.filter(row => row.role === 'developer') }); return null } + function resourceBlock(projectId, target) { + try { const definitions = rt.projectAgents?.deliveryDefinitions(projectId); rt.projectResources?.plan(projectId, rt.config.projectDispatch === 'workflow' ? definitions : { ...definitions, roles: definitions?.roles.filter(row => row.role === (target?.role ?? 'developer')) }); return null } catch (failure) { return String(failure.message).slice(0, 300) } } async function request(body, actor) { @@ -67,13 +68,17 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = const prior = Object.values(state.projectAttempts ?? {}).find(attempt => attempt.requestKey === requestKey) if (prior) return { attempt: structuredClone(prior), already: true } const queued = Object.values(entries()).find(entry => entry.requestKey === requestKey || entry.taskId === task.id && entry.status === 'waiting') - if (queued) return { queue: structuredClone(queued), already: true } + if (queued) { + if (body.targetRole !== undefined && body.targetRole !== queued.initialTarget?.role) throw error('Queued request target cannot change on replay') + return { queue: structuredClone(queued), already: true } + } if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended') if (Object.values(state.projectAttempts ?? {}).some(attempt => attempt.taskId === task.id && ['preparing', 'running', 'waiting-acceptance'].includes(attempt.status))) throw error('Task already has an active Attempt') - const resourceReason = resourceBlock(project.id) - if (slots(project.id) < quota(project) && !resourceReason) return rt.projectDelivery.endpoint('project-task-run', body, actor) + const initialTarget = initialRoleTarget(rt, task, body) + const resourceReason = resourceBlock(project.id, initialTarget) + if (slots(project.id) < quota(project) && !resourceReason) return rt.projectDelivery.endpoint('project-task-run', { ...body, ...(initialTarget ? { initialTarget } : {}) }, actor) const entry = { id: `queue-${randomUUID()}`, projectId: project.id, taskId: task.id, requestId: body.requestId, requestKey, - memberId: actor.memberId, status: 'waiting', ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } + memberId: actor.memberId, ...(initialTarget ? { initialTarget } : {}), status: 'waiting', ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } setEntry(entries(), entry.id, entry) await rt.save() return { queue: structuredClone(entry) } @@ -89,13 +94,17 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = if (slots(project.id) >= quota(project)) continue try { await preflight(rt, project) } catch (failure) { entry.status = 'blocked'; entry.reason = failure.message; entry.updatedAt = Date.now(); changed = true; continue } - const blocked = resourceBlock(project.id) + let initialTarget + try { initialTarget = initialRoleTarget(rt, state.tasks[entry.taskId], { initialTarget: entry.initialTarget }) } + catch (failure) { entry.status = 'blocked'; entry.reason = failure.message; entry.updatedAt = Date.now(); changed = true; continue } + if (initialTarget && !entry.initialTarget) { entry.initialTarget = initialTarget; await rt.save() } + const blocked = resourceBlock(project.id, initialTarget) if (blocked) { entry.reason = blocked; changed = true; continue } entry.status = 'starting' entry.updatedAt = Date.now() await rt.save() try { - const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId }, { memberId: entry.memberId }) + const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId, initialTarget: entry.initialTarget }, { memberId: entry.memberId }) entry.status = result.attempt?.status === 'failed' ? 'failed' : 'started' entry.attemptId = result.attempt?.id ?? null if (result.attempt?.failure) entry.reason = result.attempt.failure.message @@ -132,7 +141,9 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended') if (!rt.projectCan(requester, entry.projectId, 'task-write', task)) throw error('Original requester no longer has Project authority') if (Object.values(state.projectAttempts ?? {}).some(attempt => attempt.taskId === task.id && ['preparing', 'running', 'waiting-acceptance'].includes(attempt.status))) throw error('Task already has an active Attempt') + const initialTarget = initialRoleTarget(rt, task, { targetRole: body.targetRole ?? entry.initialTarget?.role }) ;(entry.recoveries ??= []).push({ by: actor.memberId, previousReason: entry.reason ?? null, at: Date.now() }) + if (initialTarget) entry.initialTarget = initialTarget entry.status = 'waiting'; delete entry.reason; entry.updatedAt = Date.now() await rt.save() return { queue: structuredClone(entry) } diff --git a/core/src/host/project-task-lifecycle.js b/core/src/host/project-task-lifecycle.js index 6522d3ab6..a0cb8441a 100644 --- a/core/src/host/project-task-lifecycle.js +++ b/core/src/host/project-task-lifecycle.js @@ -1,5 +1,6 @@ // Project semantics on the original Task records. No separate queue or store. import { ownEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { createHash } from 'node:crypto' import { stageRoleSummary, rollbackRoleSummary } from './project-context-memory.js' import { EffectCancelled, EffectDeferred } from './project-effects.js' @@ -219,9 +220,10 @@ export function createProjectTaskLifecycle(rt) { return rt.projectEffects.stage({ kind: 'task.intake', key: `${task.id}:${task.projectTask.requirements.revision}`, projectId: task.projectId, taskId: task.id, payload: { requirementsRevision: task.projectTask.requirements.revision } }) } - function stageStart(task, decision) { + function stageStart(task, decision, changes = {}) { + const initialTarget = initialRoleTarget(rt, task, { targetRole: changes.targetRole }) return rt.projectEffects.stage({ kind: 'task.start', key: decision.id, projectId: task.projectId, taskId: task.id, - payload: { decisionId: decision.id, requirementsRevision: task.projectTask.requirements.revision, memberId: task.requesterId, sessionId: task.projectTask.executionRequester?.memberId === task.requesterId ? task.projectTask.executionRequester.sessionId : null } }) + payload: { decisionId: decision.id, ...(initialTarget ? { initialTarget } : {}), requirementsRevision: task.projectTask.requirements.revision, memberId: task.requesterId, sessionId: task.projectTask.executionRequester?.memberId === task.requesterId ? task.projectTask.executionRequester.sessionId : null } }) } async function adjudicate(task, changes, audit, actor) { await rt.projectChiefControl.validateActor(actor) @@ -238,7 +240,7 @@ export function createProjectTaskLifecycle(rt) { } if (changes.requirements) reviseTask(task, changes.requirements, { ...audit, id: `${audit.id}-requirements` }) admitTask(task, audit) - token = stageStart(task, audit) + token = stageStart(task, audit, changes) } else if (audit.action === 'revise') { reviseTask(task, changes.requirements ?? {}, audit) const input = rt.projectInputs.stageRequirements(task, audit) @@ -248,7 +250,7 @@ export function createProjectTaskLifecycle(rt) { if (task.collaboration?.claim?.status === 'active') throw fail('Active execution must stop before reopening') reopenTask(task, audit) dependencyResult(rt.state, task) - token = stageStart(task, audit) + token = stageStart(task, audit, changes) } else if (audit.action === 'resolve-dependency') { resolveDependency(rt.state, task, changes.dependency, audit) token = stageOwnResume(task) ?? rt.projectEffects.stage({ kind: 'task.transition', key: audit.id, projectId: task.projectId, taskId: task.id, payload: { decisionId: audit.id, action: audit.action } }) @@ -364,10 +366,11 @@ export function install(rt) { const task = rt.projectTaskLifecycle.taskOf(effect.taskId) if (['accepted', 'cancelled'].includes(task.status)) throw new EffectCancelled('Task ended before execution') rt.projectTaskLifecycle.requireExecutable(task) + if (effect.payload.requirementsRevision !== task.projectTask.requirements.revision) throw new EffectCancelled('Task requirements changed before initial execution') const actor = { memberId: effect.payload.memberId, ...(effect.payload.sessionId ? { sessionId: effect.payload.sessionId } : {}) } await rt.projectDelegations.background(actor.memberId, actor.sessionId) rt.requireProjectAccess(actor, task.projectId, 'task-write', task) - return rt.projectScheduler.request({ taskId: task.id, requestId: `chief-${effect.payload.decisionId}` }, actor) + return rt.projectScheduler.request({ taskId: task.id, requestId: `chief-${effect.payload.decisionId}`, initialTarget: effect.payload.initialTarget }, actor) } }) rt.projectEffects.register('task.dependency-review', { async run(effect) { const task = rt.projectTaskLifecycle.taskOf(effect.taskId), wait = task.projectTask?.wait diff --git a/core/test/project-pools.test.js b/core/test/project-pools.test.js index dfbe700e8..6c7d19d9b 100644 --- a/core/test/project-pools.test.js +++ b/core/test/project-pools.test.js @@ -603,3 +603,19 @@ test('a completed turn from the old requirement version cannot transfer or split assert.equal(stops, 0) assert.equal(f.task.collaboration.history.length, 1) }) + + +test('initial admission resolves a unique approved developer capability after the default role retires', async () => { + const f = setup(), roles = [{ id: 'p:chief', role: 'chief' }, { id: 'p:builder', role: 'builder', policyRole: 'developer' }] + f.rt.projectAgents.deliveryDefinitions = () => ({ revision: 8, roles }) + await f.admit() + assert.equal(f.task.collaboration.botId, 'p:builder') + assert.equal((await f.pool.claim('task', 'p:builder')).policyRole, 'developer') +}) +test('ambiguous developer capabilities require an explicit initial role rather than list order', async () => { + const f = setup(), roles = ['builder', 'implementer'].map(role => ({ id: `p:${role}`, role, policyRole: 'developer' })) + f.rt.projectAgents.deliveryDefinitions = () => ({ revision: 8, roles }) + await assert.rejects(f.admit(), /explicit.*targetRole/) + await f.pool.admit({ id: 'attempt', taskId: 'task' }, { memberId: 'owner' }, { targetRole: 'builder' }) + assert.equal(f.task.collaboration.botId, 'p:builder') +}) diff --git a/core/test/project-role-admission.test.js b/core/test/project-role-admission.test.js new file mode 100644 index 000000000..d00b678f4 --- /dev/null +++ b/core/test/project-role-admission.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { initialRoleTarget } from '../src/host/project-role-admission.js' + +test('initial targets pin capability, catalog and requirements without silently changing roles', () => { + const task = { projectId: 'p', projectTask: { requirements: { revision: 3 } } } + const roles = [{ id: 'p:builder', role: 'builder', policyRole: 'developer' }, { id: 'p:chief', role: 'chief' }] + const catalog = { revision: 4, roles } + const rt = { config: {}, projectAgents: { deliveryDefinitions: () => catalog } } + const pinned = initialRoleTarget(rt, task) + assert.deepEqual(pinned, { role: 'builder', botId: 'p:builder', policyRole: 'developer', definitionRevision: 4, requirementsRevision: 3 }) + assert.deepEqual(initialRoleTarget(rt, task, { initialTarget: pinned }), pinned) + for (const targetRole of ['chief', 'retired', '', 12, '__proto__']) assert.throws(() => initialRoleTarget(rt, task, { targetRole })) + assert.throws(() => initialRoleTarget(rt, task, { initialTarget: { ...pinned, botId: 'other:builder' } }), /selection changed/) + task.projectTask.requirements.revision++ + assert.throws(() => initialRoleTarget(rt, task, { initialTarget: pinned }), /requirements changed/) + task.projectTask.requirements.revision-- + catalog.revision++ + assert.throws(() => initialRoleTarget(rt, task, { initialTarget: pinned }), /catalog/) + catalog.revision-- + roles.push({ id: 'p:developer', role: 'developer' }) + assert.equal(initialRoleTarget(rt, task).role, 'developer') + assert.equal(initialRoleTarget(rt, task, { targetRole: 'builder' }).role, 'builder') + rt.config.projectDispatch = 'workflow' + assert.equal(initialRoleTarget(rt, task), undefined) +}) diff --git a/core/test/project-scheduler.test.js b/core/test/project-scheduler.test.js index 45bea4881..4edda581e 100644 --- a/core/test/project-scheduler.test.js +++ b/core/test/project-scheduler.test.js @@ -195,3 +195,30 @@ test('the existing scheduler checks in-flight execution authority before resourc await scheduler.wake() assert.deepEqual(calls, ['authority', 'resources', 'dispatch']) }) + + +test('queue restart preserves the chosen custom role and fences catalog or requirements changes', async () => { + const f = fixture(), roles = [{ id: 'p1:builder', role: 'builder', policyRole: 'developer' }] + let revision = 9, dispatched + f.rt.projectAgents = { deliveryDefinitions: () => ({ revision, roles }) } + await f.scheduler.request({ taskId: 'one', requestId: 'one' }, { memberId: 'bob' }) + const queued = await f.scheduler.request({ taskId: 'two', requestId: 'two', targetRole: 'builder' }, { memberId: 'bob' }) + assert.equal(queued.queue.initialTarget.role, 'builder') + assert.equal(queued.queue.initialTarget.definitionRevision, 9) + f.state.projectAttempts = {}; f.state.projectExecutions = {} + const original = f.rt.projectDelivery.endpoint + f.rt.projectDelivery.endpoint = async (...args) => { dispatched = args[1]; return original(...args) } + const restarted = createProjectScheduler(f.rt, { preflight: async () => repositorySettings(f.rt, f.project) }) + await restarted.wake() + assert.equal(dispatched.initialTarget.role, 'builder') + assert.equal(dispatched.initialTarget.definitionRevision, 9) + f.state.projectAttempts = {}; f.state.projectExecutions = {} + const again = await restarted.request({ taskId: 'one', requestId: 'again', targetRole: 'builder' }, { memberId: 'bob' }) + const waiting = await restarted.request({ taskId: 'two', requestId: 'stale', targetRole: 'builder' }, { memberId: 'bob' }) + assert.ok(again.attempt) + revision++ + f.state.projectAttempts = {}; f.state.projectExecutions = {} + await restarted.wake() + assert.equal(f.state.projectQueue[waiting.queue.id].status, 'blocked') + assert.match(f.state.projectQueue[waiting.queue.id].reason, /catalog.*changed/) +}) diff --git a/core/test/project-task-lifecycle.test.js b/core/test/project-task-lifecycle.test.js index a15c2ee0b..e131a304c 100644 --- a/core/test/project-task-lifecycle.test.js +++ b/core/test/project-task-lifecycle.test.js @@ -202,3 +202,15 @@ test('dependency adjudication commits one resume intent and a failed commit leav assert.equal(f.parent.status, 'open') await f.rt.projectEffects.close() }) + + +test('Chief admission persists the selected initial role in the automatic start effect', async () => { + const f = await serviceFixture() + f.rt.projectAgents = { deliveryDefinitions: () => ({ revision: 11, roles: [{ id: 'p:builder', role: 'builder', policyRole: 'developer' }] }) } + await f.rt.projectTaskLifecycle.adjudicate(f.parent, { targetRole: 'builder' }, { ...proof('admit'), id: 'route-builder', action: 'admit' }, f.actor) + const effect = Object.values(f.rt.state.projectEffects).find(row => row.kind === 'task.start') + assert.equal(effect.payload.initialTarget.role, 'builder') + assert.equal(effect.payload.initialTarget.definitionRevision, 11) + assert.equal(effect.payload.initialTarget.requirementsRevision, 1) + await f.rt.projectEffects.close() +}) diff --git a/docs/development-service-api.md b/docs/development-service-api.md index 94dac249d..783976d60 100644 --- a/docs/development-service-api.md +++ b/docs/development-service-api.md @@ -142,7 +142,7 @@ Worker已登记不等于已运行;进程或Bot消失不等于退出;角色 | role.decide | proposalId、expectedSha256、decision:approve/reject | 管;精确批准Chief提案,不能由提案Chief借Owner身份自批 | | task.propose | title;criteria/impact接纳前必需;scope/projectTaskKind/botId? | 创建唯一Task并提交Chief intake;当前成员发布权限;kind仅code/decision/release | | task.change.request | taskId、action:revise/cancel/reopen、reason;revise可带requirements | 写;仅请求Chief判断,不立即改承诺/关闭Task | -| task.run | taskId | 写;未接纳则waitingFor:chief;接纳后原配额/资源队列,不是强制绕过Chief | +| task.run | taskId, requestId, targetRole? | 写;未接纳则waitingFor:chief;接纳后原配额/资源队列,不是强制绕过Chief | | task.accept | attemptId | 旧兼容交付的人工验收;管;新Task最终关闭仍由Chief | | task.reject | attemptId、reason等原返工参数 | 管;原证据/停止/新Attempt路径,新Task不改变Chief裁定权 | | attempt.stop | attemptId | 管;请求原worker停止,未证实退出继续持有租约 | @@ -150,7 +150,7 @@ Worker已登记不等于已运行;进程或Bot消失不等于退出;角色 | attempt.integration-bind | attemptId、generation、inputs:[{ref,sha}]、expectedWorkspace(取integration-state快照) | 管;实际引用、SHA/测试/Review绑定,非伪造“已合并” | | attempt.publication-retry | attemptId | 管;原PR绑定/幂等恢复,不另建重复PR | | queue.cancel | queueId | 写;取消原等待请求,不伪造worker退出或Chief关闭 | -| queue.retry | queueId | 管;重验原始发起人、要求和资源;不是换请求人借权 | +| queue.retry | queueId, targetRole? | 管;重验原始发起人、要求和资源;不是换请求人借权 | | chief.inspect | taskId? | 管;一次原Chief巡检/裁定工作,不增框架轮询器 | | chief.configure | enabled;inspection? | 管;配置原Chief授权和原生定时任务,默认巡检30分钟是配置值 | | schedule.configure | operation:save/delete等、id?及原schedule定义 | 管;复用Project定时任务,不创建另一调度器 | diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index 7ce8ab803..f6d9d74a1 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -546,3 +546,5 @@ Composed #2179 execution checks include the canonical Task requirement revision. ## Original Workflow compatibility (#2186) Explicit canonical Workflow delivery assigns only through the private exact-current-Attempt access port, preserving the original graph, quota and Run evidence. Public assign cannot create a parallel canonical execution. Pre-cutover Task IDs, parents, artifacts and active recovery remain authoritative. See [compatibility](project-compatibility.md). + +#2292: initial Attempts retain the approved initialTarget (logical role, Bot, capability, catalog and requirement revisions); preflight and pool admission revalidate it. Runtime code and frozen active role Sessions are unchanged by queue recovery. diff --git a/docs/subsystems/project-pools.md b/docs/subsystems/project-pools.md index c4efaa0d6..b7cf58d0e 100644 --- a/docs/subsystems/project-pools.md +++ b/docs/subsystems/project-pools.md @@ -1,6 +1,8 @@ --- subsystem: project-pools owned_paths: + - core/src/host/project-role-admission.js + - core/test/project-role-admission.test.js - core/src/host/project-pools.js - core/test/project-pools.test.js --- @@ -178,3 +180,7 @@ Project Tasks may split independent children after actual source exit, preservin Composition with #2184 rechecks the committed target role after asynchronous source-exit confirmation, before claiming. Retirement during inspection leaves the original Task blocked for Chief routing; it cannot acquire a new execution claim from the earlier catalog snapshot. Canonical claims retain the admitted requirement revision. Decision admission rechecks that revision before and after awaited authorization, including transfer/split decisions that do not seal an artifact. Older claims can use their actual Attempt snapshot revision. A late old-plan response cannot transfer initiative or create children for revised requirements. + +## Initial routing (#2292) + +`initialRoleTarget` pins role/id/capability, catalog revision and Task requirements revision before first dispatch. An explicit active non-Chief `targetRole` wins; otherwise prefer the active developer-capability role named developer, or the sole developer-capability role. Ambiguous/missing candidates refuse rather than choose by array order. Admission revalidates the binding after asynchronous authority/evidence checks. This selection is not a new Session or a DAG edge; subsequent handoffs retain existing Bot autonomy. diff --git a/docs/subsystems/project-task-lifecycle.md b/docs/subsystems/project-task-lifecycle.md index 5957b1601..cbf797e81 100644 --- a/docs/subsystems/project-task-lifecycle.md +++ b/docs/subsystems/project-task-lifecycle.md @@ -115,3 +115,5 @@ than pretending the changed wait has completed. ## Legacy admission boundary (#2186) requireExecutable accepts an untyped shared Project Task only when the compatibility service recognizes its committed original identity. Absence of projectTask is no longer implicit admission. Explicit Chief adoption of inactive legacy work remains the existing adjudication capability; active work cannot change its contract. + +#2292: Chief admit/reopen can choose targetRole. The task.start Effect persists the resolved initialTarget with its requirement version; replay cannot silently choose a new catalog target or execute a superseded requirement revision. diff --git a/docs/subsystems/project-workbench.md b/docs/subsystems/project-workbench.md index ca4a514f0..da5116367 100644 --- a/docs/subsystems/project-workbench.md +++ b/docs/subsystems/project-workbench.md @@ -481,3 +481,5 @@ The legacy Workflow gateway now also excludes Chief control jobs from execution ## Persistent service composition (#2189) The source backend launcher reuses this original gateway/UI and account/resource endpoints. auth-mode now waits for Store load and exposes only a writable boolean in addition to existing metadata; it reveals no private file paths. Startup refuses read-only/unsupported state rather than opening an apparently fresh writable service. No frontend assets/control-plane implementation changes. See [backend entry](backend-service.md). + +#2292 initial dispatch: `task.run` accepts optional `targetRole`. Waiting queue rows persist `initialTarget`, forward it after restart, and plan resources for that exact role. Changed catalog/requirements block instead of silently rerouting. `queue.retry` explicitly revalidates the selected role against current versions; optional targetRole selects another active role under existing manager authority and preserves the original requester. Legacy queue entries resolve and persist one initial choice before dispatch. diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index b767a068a..cad18ea65 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -5,6 +5,7 @@ import { mkdir, readFile, realpath, rm, writeFile, lstat } from 'node:fs/promise import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { promisify } from 'node:util' import { ownEntry, setEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { DELIVERY_ROLES, DELIVERY_VERSION, deliveryGraph } from './project-delivery-template.js' import { repositorySettings, preflightRepository } from './project-repository-policy.js' import { createResourceWorkspaces, fileDigest } from './project-resource-workspaces.js' @@ -239,14 +240,16 @@ export function createProjectDelivery(rt, { publisher } = {}) { if (prior) return { attempt: view(prior), already: true } if (Object.values(attempts()).some(attempt => attempt.taskId === task.id && (ongoing.has(attempt.status) || attempt.status === 'waiting-acceptance'))) throw reject('Task already has an active Attempt') if (task.status === 'accepted' || task.status === 'cancelled') throw reject('Terminal Task cannot start a delivery') + const initialTarget = initialRoleTarget(rt, task, body) const settings = await preflightRepository(rt, project) + if (initialTarget) initialRoleTarget(rt, task, { initialTarget }) const previous = current(task.id) const publicationBinding = await publicationOf(previous, project) const feedback = previous?.failure ? { previousAttemptId: previous.id, kind: previous.returned ? 'human-return' : 'execution-failure', reason: previous.failure.message, candidateSha: previous.candidateSha ?? null } : null const at = Date.now() const attempt = { id: `attempt-${randomUUID()}`, projectId: project.id, taskId: task.id, requestKey, workflowVersion: DELIVERY_VERSION, status: 'preparing', initiatedBy: actor.memberId, executionOwnerId: project.ownerUserId, - requirement: requirementsOf(task, project.repository), + requirement: requirementsOf(task, project.repository), ...(initialTarget ? { initialTarget } : {}), ...(feedback ? { feedback } : {}), ...(publicationBinding ? { publicationBinding, seedSha: previous.candidateSha ?? previous.seedSha ?? publicationBinding.previousSha, baseSha: previous.baseSha } : {}), workspaces: {}, evidence: {}, createdAt: at, updatedAt: at } setEntry(attempts(), attempt.id, attempt) task.deliveryAttemptId = attempt.id diff --git a/patches/ds-bot/files/src/host/project-pools.js b/patches/ds-bot/files/src/host/project-pools.js index 26dc21170..51aa605d9 100644 --- a/patches/ds-bot/files/src/host/project-pools.js +++ b/patches/ds-bot/files/src/host/project-pools.js @@ -1,6 +1,7 @@ // Per-logical-Bot routing on existing Task records. Execution remains a host adapter. import { randomUUID, createHash } from 'node:crypto' import { ownEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { taskSessionId } from './project-task-sessions.js' import { stageRoleSummary, rollbackRoleSummary, stageExecutionMemory, rollbackExecutionMemory, roleMemoryView } from './project-context-memory.js' import { EffectDeferred, EffectCancelled } from './project-effects.js' @@ -117,7 +118,7 @@ export function createProjectPools(rt) { if (!checked.ok) throw refuse(`Handoff refused (${checked.code}): ${checked.reason}`) return { fromRole, trustedEvidence } } - async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary, experience }) { + async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary, experience, initialTarget }) { if (!adapter) throw refuse('Handoff execution adapter is unavailable') reason = reasonOf(reason) const c = task.collaboration, target = targetOf(task.projectId, to) @@ -129,6 +130,7 @@ export function createProjectPools(rt) { } await authority(task) const { fromRole, trustedEvidence } = await checkAdmission(task, { from, to, attemptId, requestId, purpose }) + if (initialTarget) initialRoleTarget(rt, task, { initialTarget }) if (expectedGeneration !== undefined && c.generation !== expectedGeneration) throw refuse('Claim generation changed before handoff admission') const row = { id: `handoff-${randomUUID()}`, version: POOL_PROTOCOL, policyVersion: 'project-role-policy/v1', definitionRevision: definitions(task.projectId).revision, projectId: task.projectId, taskId: task.id, attemptId, initiatedBy: c.initiatedBy, from, to, reason, requestId, purpose, evidence: clone(trustedEvidence), claims: clone(evidence), requirements: { title: task.title, criteria: task.criteria ?? '' }, status: 'pending', generation: c.generation + 1, createdAt: Date.now() } const sourceClaim = c.claim?.botId === from ? c.claim : null @@ -183,12 +185,14 @@ export function createProjectPools(rt) { rt.projectScheduler?.wake() return clone(row) } - async function admit(attempt, actor, { targetRole = 'developer', kind } = {}) { + async function admit(attempt, actor, { targetRole, initialTarget, kind } = {}) { const task = taskOf(attempt.taskId) rt.projectTaskLifecycle?.requireExecutable(task) if (kind && task.projectTaskKind && kind !== task.projectTaskKind) throw refuse('Task kind cannot change at execution admission') kind ??= task.projectTaskKind ?? 'code' rt.requireProjectAccess(actor, task.projectId, 'task-write', task) + const selected = initialRoleTarget(rt, task, { targetRole, initialTarget: initialTarget ?? attempt.initialTarget }) + targetRole = selected?.role ?? targetRole ?? 'developer' const target = definitions(task.projectId).roles.find(row => row.role === targetRole) if (!target || !['code', 'decision', 'release'].includes(kind)) throw refuse('Unsupported Task target or kind') task.collaboration ??= { version: POOL_PROTOCOL, initiatedBy: actor.memberId, generation: 0, history: [] } @@ -197,7 +201,7 @@ export function createProjectPools(rt) { c.sourceSessionId = actor.sessionId ?? null c.initiatedBy = actor.memberId c.attemptId = attempt.id; c.kind = kind - return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id }) + return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id, initialTarget: selected }) } async function claim(taskId, botId, handoffId, requestId) { const task = taskOf(taskId), c = task.collaboration diff --git a/patches/ds-bot/files/src/host/project-role-admission.js b/patches/ds-bot/files/src/host/project-role-admission.js new file mode 100644 index 000000000..d7ebce338 --- /dev/null +++ b/patches/ds-bot/files/src/host/project-role-admission.js @@ -0,0 +1,17 @@ +// Initial routing is a durable choice, separate from an active role Session. +const fail = message => Object.assign(new Error(message), { code: 'bot/handoff' }) +export function initialRoleTarget(rt, task, body = {}) { + if (rt.config.projectDispatch === 'workflow' || !rt.projectAgents?.deliveryDefinitions) return undefined + const catalog = rt.projectAgents.deliveryDefinitions(task.projectId) + const revision = task.projectTask?.requirements.revision ?? null + const pinned = body.initialTarget + if (pinned && (pinned.definitionRevision !== catalog.revision || pinned.requirementsRevision !== revision)) throw fail('Initial role catalog or Task requirements changed; explicitly select a target again') + const requested = body.targetRole ?? pinned?.role + if (requested !== undefined && (typeof requested !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(requested))) throw fail('Invalid initial targetRole') + const candidates = catalog.roles.filter(row => (row.policyRole ?? row.role) === 'developer') + const target = requested ? catalog.roles.find(row => row.role === requested) : candidates.find(row => row.role === 'developer') ?? (candidates.length === 1 ? candidates[0] : null) + if (!target || target.role === 'chief') throw fail('An active initial role is required; provide an explicit targetRole when developer capabilities are ambiguous') + const result = { role: target.role, botId: target.id, policyRole: target.policyRole ?? target.role, definitionRevision: catalog.revision, requirementsRevision: revision } + if (pinned && Object.keys(result).some(key => result[key] !== pinned[key])) throw fail('Initial role selection changed; explicitly select a target again') + return result +} diff --git a/patches/ds-bot/files/src/host/project-scheduler.js b/patches/ds-bot/files/src/host/project-scheduler.js index 5cc8649a6..cb24fa06b 100644 --- a/patches/ds-bot/files/src/host/project-scheduler.js +++ b/patches/ds-bot/files/src/host/project-scheduler.js @@ -1,6 +1,7 @@ // Persisted Project admission queue. The existing Workflow service still dispatches Runs. import { createHash, randomUUID } from 'node:crypto' import { ownEntry, setEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { preflightRepository } from './project-repository-policy.js' const ACTIVE = new Set(['queued', 'running', 'working', 'paused', 'legacy']) const error = message => Object.assign(new Error(message), { code: 'bot/invalid' }) @@ -46,8 +47,8 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = } return changed } - function resourceBlock(projectId) { - try { const definitions = rt.projectAgents?.deliveryDefinitions(projectId); rt.projectResources?.plan(projectId, rt.config.projectDispatch === 'workflow' ? definitions : { ...definitions, roles: definitions?.roles.filter(row => row.role === 'developer') }); return null } + function resourceBlock(projectId, target) { + try { const definitions = rt.projectAgents?.deliveryDefinitions(projectId); rt.projectResources?.plan(projectId, rt.config.projectDispatch === 'workflow' ? definitions : { ...definitions, roles: definitions?.roles.filter(row => row.role === (target?.role ?? 'developer')) }); return null } catch (failure) { return String(failure.message).slice(0, 300) } } async function request(body, actor) { @@ -67,13 +68,17 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = const prior = Object.values(state.projectAttempts ?? {}).find(attempt => attempt.requestKey === requestKey) if (prior) return { attempt: structuredClone(prior), already: true } const queued = Object.values(entries()).find(entry => entry.requestKey === requestKey || entry.taskId === task.id && entry.status === 'waiting') - if (queued) return { queue: structuredClone(queued), already: true } + if (queued) { + if (body.targetRole !== undefined && body.targetRole !== queued.initialTarget?.role) throw error('Queued request target cannot change on replay') + return { queue: structuredClone(queued), already: true } + } if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended') if (Object.values(state.projectAttempts ?? {}).some(attempt => attempt.taskId === task.id && ['preparing', 'running', 'waiting-acceptance'].includes(attempt.status))) throw error('Task already has an active Attempt') - const resourceReason = resourceBlock(project.id) - if (slots(project.id) < quota(project) && !resourceReason) return rt.projectDelivery.endpoint('project-task-run', body, actor) + const initialTarget = initialRoleTarget(rt, task, body) + const resourceReason = resourceBlock(project.id, initialTarget) + if (slots(project.id) < quota(project) && !resourceReason) return rt.projectDelivery.endpoint('project-task-run', { ...body, ...(initialTarget ? { initialTarget } : {}) }, actor) const entry = { id: `queue-${randomUUID()}`, projectId: project.id, taskId: task.id, requestId: body.requestId, requestKey, - memberId: actor.memberId, status: 'waiting', ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } + memberId: actor.memberId, ...(initialTarget ? { initialTarget } : {}), status: 'waiting', ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() } setEntry(entries(), entry.id, entry) await rt.save() return { queue: structuredClone(entry) } @@ -89,13 +94,17 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = if (slots(project.id) >= quota(project)) continue try { await preflight(rt, project) } catch (failure) { entry.status = 'blocked'; entry.reason = failure.message; entry.updatedAt = Date.now(); changed = true; continue } - const blocked = resourceBlock(project.id) + let initialTarget + try { initialTarget = initialRoleTarget(rt, state.tasks[entry.taskId], { initialTarget: entry.initialTarget }) } + catch (failure) { entry.status = 'blocked'; entry.reason = failure.message; entry.updatedAt = Date.now(); changed = true; continue } + if (initialTarget && !entry.initialTarget) { entry.initialTarget = initialTarget; await rt.save() } + const blocked = resourceBlock(project.id, initialTarget) if (blocked) { entry.reason = blocked; changed = true; continue } entry.status = 'starting' entry.updatedAt = Date.now() await rt.save() try { - const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId }, { memberId: entry.memberId }) + const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId, initialTarget: entry.initialTarget }, { memberId: entry.memberId }) entry.status = result.attempt?.status === 'failed' ? 'failed' : 'started' entry.attemptId = result.attempt?.id ?? null if (result.attempt?.failure) entry.reason = result.attempt.failure.message @@ -132,7 +141,9 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } = if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended') if (!rt.projectCan(requester, entry.projectId, 'task-write', task)) throw error('Original requester no longer has Project authority') if (Object.values(state.projectAttempts ?? {}).some(attempt => attempt.taskId === task.id && ['preparing', 'running', 'waiting-acceptance'].includes(attempt.status))) throw error('Task already has an active Attempt') + const initialTarget = initialRoleTarget(rt, task, { targetRole: body.targetRole ?? entry.initialTarget?.role }) ;(entry.recoveries ??= []).push({ by: actor.memberId, previousReason: entry.reason ?? null, at: Date.now() }) + if (initialTarget) entry.initialTarget = initialTarget entry.status = 'waiting'; delete entry.reason; entry.updatedAt = Date.now() await rt.save() return { queue: structuredClone(entry) } diff --git a/patches/ds-bot/files/src/host/project-task-lifecycle.js b/patches/ds-bot/files/src/host/project-task-lifecycle.js index 6522d3ab6..a0cb8441a 100644 --- a/patches/ds-bot/files/src/host/project-task-lifecycle.js +++ b/patches/ds-bot/files/src/host/project-task-lifecycle.js @@ -1,5 +1,6 @@ // Project semantics on the original Task records. No separate queue or store. import { ownEntry } from './store.js' +import { initialRoleTarget } from './project-role-admission.js' import { createHash } from 'node:crypto' import { stageRoleSummary, rollbackRoleSummary } from './project-context-memory.js' import { EffectCancelled, EffectDeferred } from './project-effects.js' @@ -219,9 +220,10 @@ export function createProjectTaskLifecycle(rt) { return rt.projectEffects.stage({ kind: 'task.intake', key: `${task.id}:${task.projectTask.requirements.revision}`, projectId: task.projectId, taskId: task.id, payload: { requirementsRevision: task.projectTask.requirements.revision } }) } - function stageStart(task, decision) { + function stageStart(task, decision, changes = {}) { + const initialTarget = initialRoleTarget(rt, task, { targetRole: changes.targetRole }) return rt.projectEffects.stage({ kind: 'task.start', key: decision.id, projectId: task.projectId, taskId: task.id, - payload: { decisionId: decision.id, requirementsRevision: task.projectTask.requirements.revision, memberId: task.requesterId, sessionId: task.projectTask.executionRequester?.memberId === task.requesterId ? task.projectTask.executionRequester.sessionId : null } }) + payload: { decisionId: decision.id, ...(initialTarget ? { initialTarget } : {}), requirementsRevision: task.projectTask.requirements.revision, memberId: task.requesterId, sessionId: task.projectTask.executionRequester?.memberId === task.requesterId ? task.projectTask.executionRequester.sessionId : null } }) } async function adjudicate(task, changes, audit, actor) { await rt.projectChiefControl.validateActor(actor) @@ -238,7 +240,7 @@ export function createProjectTaskLifecycle(rt) { } if (changes.requirements) reviseTask(task, changes.requirements, { ...audit, id: `${audit.id}-requirements` }) admitTask(task, audit) - token = stageStart(task, audit) + token = stageStart(task, audit, changes) } else if (audit.action === 'revise') { reviseTask(task, changes.requirements ?? {}, audit) const input = rt.projectInputs.stageRequirements(task, audit) @@ -248,7 +250,7 @@ export function createProjectTaskLifecycle(rt) { if (task.collaboration?.claim?.status === 'active') throw fail('Active execution must stop before reopening') reopenTask(task, audit) dependencyResult(rt.state, task) - token = stageStart(task, audit) + token = stageStart(task, audit, changes) } else if (audit.action === 'resolve-dependency') { resolveDependency(rt.state, task, changes.dependency, audit) token = stageOwnResume(task) ?? rt.projectEffects.stage({ kind: 'task.transition', key: audit.id, projectId: task.projectId, taskId: task.id, payload: { decisionId: audit.id, action: audit.action } }) @@ -364,10 +366,11 @@ export function install(rt) { const task = rt.projectTaskLifecycle.taskOf(effect.taskId) if (['accepted', 'cancelled'].includes(task.status)) throw new EffectCancelled('Task ended before execution') rt.projectTaskLifecycle.requireExecutable(task) + if (effect.payload.requirementsRevision !== task.projectTask.requirements.revision) throw new EffectCancelled('Task requirements changed before initial execution') const actor = { memberId: effect.payload.memberId, ...(effect.payload.sessionId ? { sessionId: effect.payload.sessionId } : {}) } await rt.projectDelegations.background(actor.memberId, actor.sessionId) rt.requireProjectAccess(actor, task.projectId, 'task-write', task) - return rt.projectScheduler.request({ taskId: task.id, requestId: `chief-${effect.payload.decisionId}` }, actor) + return rt.projectScheduler.request({ taskId: task.id, requestId: `chief-${effect.payload.decisionId}`, initialTarget: effect.payload.initialTarget }, actor) } }) rt.projectEffects.register('task.dependency-review', { async run(effect) { const task = rt.projectTaskLifecycle.taskOf(effect.taskId), wait = task.projectTask?.wait diff --git a/patches/ds-bot/files/test/project-pools.test.js b/patches/ds-bot/files/test/project-pools.test.js index dfbe700e8..6c7d19d9b 100644 --- a/patches/ds-bot/files/test/project-pools.test.js +++ b/patches/ds-bot/files/test/project-pools.test.js @@ -603,3 +603,19 @@ test('a completed turn from the old requirement version cannot transfer or split assert.equal(stops, 0) assert.equal(f.task.collaboration.history.length, 1) }) + + +test('initial admission resolves a unique approved developer capability after the default role retires', async () => { + const f = setup(), roles = [{ id: 'p:chief', role: 'chief' }, { id: 'p:builder', role: 'builder', policyRole: 'developer' }] + f.rt.projectAgents.deliveryDefinitions = () => ({ revision: 8, roles }) + await f.admit() + assert.equal(f.task.collaboration.botId, 'p:builder') + assert.equal((await f.pool.claim('task', 'p:builder')).policyRole, 'developer') +}) +test('ambiguous developer capabilities require an explicit initial role rather than list order', async () => { + const f = setup(), roles = ['builder', 'implementer'].map(role => ({ id: `p:${role}`, role, policyRole: 'developer' })) + f.rt.projectAgents.deliveryDefinitions = () => ({ revision: 8, roles }) + await assert.rejects(f.admit(), /explicit.*targetRole/) + await f.pool.admit({ id: 'attempt', taskId: 'task' }, { memberId: 'owner' }, { targetRole: 'builder' }) + assert.equal(f.task.collaboration.botId, 'p:builder') +}) diff --git a/patches/ds-bot/files/test/project-role-admission.test.js b/patches/ds-bot/files/test/project-role-admission.test.js new file mode 100644 index 000000000..d00b678f4 --- /dev/null +++ b/patches/ds-bot/files/test/project-role-admission.test.js @@ -0,0 +1,26 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { initialRoleTarget } from '../src/host/project-role-admission.js' + +test('initial targets pin capability, catalog and requirements without silently changing roles', () => { + const task = { projectId: 'p', projectTask: { requirements: { revision: 3 } } } + const roles = [{ id: 'p:builder', role: 'builder', policyRole: 'developer' }, { id: 'p:chief', role: 'chief' }] + const catalog = { revision: 4, roles } + const rt = { config: {}, projectAgents: { deliveryDefinitions: () => catalog } } + const pinned = initialRoleTarget(rt, task) + assert.deepEqual(pinned, { role: 'builder', botId: 'p:builder', policyRole: 'developer', definitionRevision: 4, requirementsRevision: 3 }) + assert.deepEqual(initialRoleTarget(rt, task, { initialTarget: pinned }), pinned) + for (const targetRole of ['chief', 'retired', '', 12, '__proto__']) assert.throws(() => initialRoleTarget(rt, task, { targetRole })) + assert.throws(() => initialRoleTarget(rt, task, { initialTarget: { ...pinned, botId: 'other:builder' } }), /selection changed/) + task.projectTask.requirements.revision++ + assert.throws(() => initialRoleTarget(rt, task, { initialTarget: pinned }), /requirements changed/) + task.projectTask.requirements.revision-- + catalog.revision++ + assert.throws(() => initialRoleTarget(rt, task, { initialTarget: pinned }), /catalog/) + catalog.revision-- + roles.push({ id: 'p:developer', role: 'developer' }) + assert.equal(initialRoleTarget(rt, task).role, 'developer') + assert.equal(initialRoleTarget(rt, task, { targetRole: 'builder' }).role, 'builder') + rt.config.projectDispatch = 'workflow' + assert.equal(initialRoleTarget(rt, task), undefined) +}) diff --git a/patches/ds-bot/files/test/project-scheduler.test.js b/patches/ds-bot/files/test/project-scheduler.test.js index 45bea4881..4edda581e 100644 --- a/patches/ds-bot/files/test/project-scheduler.test.js +++ b/patches/ds-bot/files/test/project-scheduler.test.js @@ -195,3 +195,30 @@ test('the existing scheduler checks in-flight execution authority before resourc await scheduler.wake() assert.deepEqual(calls, ['authority', 'resources', 'dispatch']) }) + + +test('queue restart preserves the chosen custom role and fences catalog or requirements changes', async () => { + const f = fixture(), roles = [{ id: 'p1:builder', role: 'builder', policyRole: 'developer' }] + let revision = 9, dispatched + f.rt.projectAgents = { deliveryDefinitions: () => ({ revision, roles }) } + await f.scheduler.request({ taskId: 'one', requestId: 'one' }, { memberId: 'bob' }) + const queued = await f.scheduler.request({ taskId: 'two', requestId: 'two', targetRole: 'builder' }, { memberId: 'bob' }) + assert.equal(queued.queue.initialTarget.role, 'builder') + assert.equal(queued.queue.initialTarget.definitionRevision, 9) + f.state.projectAttempts = {}; f.state.projectExecutions = {} + const original = f.rt.projectDelivery.endpoint + f.rt.projectDelivery.endpoint = async (...args) => { dispatched = args[1]; return original(...args) } + const restarted = createProjectScheduler(f.rt, { preflight: async () => repositorySettings(f.rt, f.project) }) + await restarted.wake() + assert.equal(dispatched.initialTarget.role, 'builder') + assert.equal(dispatched.initialTarget.definitionRevision, 9) + f.state.projectAttempts = {}; f.state.projectExecutions = {} + const again = await restarted.request({ taskId: 'one', requestId: 'again', targetRole: 'builder' }, { memberId: 'bob' }) + const waiting = await restarted.request({ taskId: 'two', requestId: 'stale', targetRole: 'builder' }, { memberId: 'bob' }) + assert.ok(again.attempt) + revision++ + f.state.projectAttempts = {}; f.state.projectExecutions = {} + await restarted.wake() + assert.equal(f.state.projectQueue[waiting.queue.id].status, 'blocked') + assert.match(f.state.projectQueue[waiting.queue.id].reason, /catalog.*changed/) +}) diff --git a/patches/ds-bot/files/test/project-task-lifecycle.test.js b/patches/ds-bot/files/test/project-task-lifecycle.test.js index a15c2ee0b..e131a304c 100644 --- a/patches/ds-bot/files/test/project-task-lifecycle.test.js +++ b/patches/ds-bot/files/test/project-task-lifecycle.test.js @@ -202,3 +202,15 @@ test('dependency adjudication commits one resume intent and a failed commit leav assert.equal(f.parent.status, 'open') await f.rt.projectEffects.close() }) + + +test('Chief admission persists the selected initial role in the automatic start effect', async () => { + const f = await serviceFixture() + f.rt.projectAgents = { deliveryDefinitions: () => ({ revision: 11, roles: [{ id: 'p:builder', role: 'builder', policyRole: 'developer' }] }) } + await f.rt.projectTaskLifecycle.adjudicate(f.parent, { targetRole: 'builder' }, { ...proof('admit'), id: 'route-builder', action: 'admit' }, f.actor) + const effect = Object.values(f.rt.state.projectEffects).find(row => row.kind === 'task.start') + assert.equal(effect.payload.initialTarget.role, 'builder') + assert.equal(effect.payload.initialTarget.definitionRevision, 11) + assert.equal(effect.payload.initialTarget.requirementsRevision, 1) + await f.rt.projectEffects.close() +}) From 3c9f619ec44ffc852537d8d339f7ed77106f27f7 Mon Sep 17 00:00:00 2001 From: zy95-12 <135135297+zy95-12@users.noreply.github.com> Date: Tue, 3 Nov 2026 17:36:44 +0800 Subject: [PATCH 2/2] Archive completed setup checkpoints without weakening role policy limits --- PROJECT.md | 9 +-------- docs/history/2026-11-03-completed-m7-checkpoints.md | 13 +++++++++++++ 2 files changed, 14 insertions(+), 8 deletions(-) diff --git a/PROJECT.md b/PROJECT.md index 86ed907fb..07862dac9 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -126,14 +126,7 @@ Earlier M7.2/M7.3 implementation-status checkpoints are retained verbatim in [the historical record](docs/history/2026-11-03-completed-m7-checkpoints.md). Current resource/Harness contracts and authorized acceptance boundaries remain below. -### M7.4 — 2026-10-31 - -M7.4 adds Owner role/persona/Skills/memory/Harness/model/multiple-resource configuration, bounded connection checks and explicitly granted private target-resource PTYs. Credentials stay in private profiles; generated profiles separate Project/principal/Harness. Actual local and new Docker PTYs, hidden setup, current-session revocation and background cleanup are tested. Real authenticated three-Harness acceptance remains pending dedicated credential/profile selection. - - -### M7.5 — 2026-10-31 - -M7.5 implements local/Docker Git workspace handoffs, SHA256 manifests, one final sealed candidate without intermediate worker history, independent host-verifier tests, same-SHA review and sealed SDK event evidence. Browser integration fixes runtime/terminal/input channel routing; callback-aware stop acknowledgement prevents release deadlock. See docs/m7-acceptance.md: positive authenticated three-Harness/two-resource PR acceptance still needs dedicated profiles, so M7 is not declared fully accepted. +M7.4/M7.5 implementation checkpoints are [archived verbatim](docs/history/2026-11-03-completed-m7-checkpoints.md). Private credentials, revocation, exit proof, same-SHA verification and outstanding mixed-Harness/resource acceptance remain required. ### M7 Codex-only acceptance authorization — 2026-11-01 diff --git a/docs/history/2026-11-03-completed-m7-checkpoints.md b/docs/history/2026-11-03-completed-m7-checkpoints.md index 5a331f2a2..95287f5c4 100644 --- a/docs/history/2026-11-03-completed-m7-checkpoints.md +++ b/docs/history/2026-11-03-completed-m7-checkpoints.md @@ -35,3 +35,16 @@ M7.3 work in progress adds pinned official Codex/Claude Agent SDK temporary work End of verbatim checkpoint excerpts. + +## Additional original PROJECT.md checkpoints + +### M7.4 — 2026-10-31 + +M7.4 adds Owner role/persona/Skills/memory/Harness/model/multiple-resource configuration, bounded connection checks and explicitly granted private target-resource PTYs. Credentials stay in private profiles; generated profiles separate Project/principal/Harness. Actual local and new Docker PTYs, hidden setup, current-session revocation and background cleanup are tested. Real authenticated three-Harness acceptance remains pending dedicated credential/profile selection. + + +### M7.5 — 2026-10-31 + +M7.5 implements local/Docker Git workspace handoffs, SHA256 manifests, one final sealed candidate without intermediate worker history, independent host-verifier tests, same-SHA review and sealed SDK event evidence. Browser integration fixes runtime/terminal/input channel routing; callback-aware stop acknowledgement prevents release deadlock. See docs/m7-acceptance.md: positive authenticated three-Harness/two-resource PR acceptance still needs dedicated profiles, so M7 is not declared fully accepted. + +End of additional verbatim excerpts.