From 343c8e784fc1b5aa629952e8420aa6bb74ce706f Mon Sep 17 00:00:00 2001 From: zy95-12 <135135297+zy95-12@users.noreply.github.com> Date: Thu, 5 Nov 2026 12:18:43 +0800 Subject: [PATCH] Notify Chief of persisted role interruptions and new exit evidence --- PROJECT.md | 2 + ROADMAP.md | 2 + changelog.d/2483-restart.fixed.md | 1 + core/src/host/project-chief-control.js | 12 +++ core/src/host/project-chief-protocol.js | 2 +- core/src/host/project-delivery.js | 2 +- core/test/project-chief-control.test.js | 84 +++++++++++++++++++ core/test/project-delivery.test.js | 77 +++++++++++++++++ core/test/project-runtime-setup.test.js | 24 ++++++ ...-05-interrupted-role-chief-notification.md | 34 ++++++++ docs/backend-operations.md | 18 ++++ docs/openapi/flybot.openapi.json | 50 +++++------ docs/subsystems/backend-service.md | 5 ++ docs/subsystems/project-chief-control.md | 17 ++++ docs/subsystems/project-delivery.md | 8 ++ docs/subsystems/project-runtime-setup.md | 6 ++ .../files/src/host/project-chief-control.js | 12 +++ .../files/src/host/project-chief-protocol.js | 2 +- .../ds-bot/files/src/host/project-delivery.js | 2 +- .../files/test/project-chief-control.test.js | 84 +++++++++++++++++++ .../files/test/project-delivery.test.js | 77 +++++++++++++++++ .../files/test/project-runtime-setup.test.js | 24 ++++++ 22 files changed, 516 insertions(+), 29 deletions(-) create mode 100644 changelog.d/2483-restart.fixed.md create mode 100644 decisions/2026-11-05-interrupted-role-chief-notification.md diff --git a/PROJECT.md b/PROJECT.md index 7865fb266..3d0870d32 100644 --- a/PROJECT.md +++ b/PROJECT.md @@ -1,5 +1,7 @@ # FlyBot — Project State +#2483 restart recovery notifies the resident Chief through existing durable inspection requests. Current exit/retirement facts wake judgement; Chief chooses recovery and unknown exits remain held. Endpoint tests alone do not prove this notification chain. + #2483: Developer Git mutation rights follow its bound isolated resource clone, independently of App credentials. Shared host Git metadata and read-only role policies keep their existing boundaries. #2483 dispatch/input follow-up keeps waits attributable and declarations explicit: no acceptance across unhandled batches, no stale notification/decision replay, no automatic role or human escalation. diff --git a/ROADMAP.md b/ROADMAP.md index a0f74963a..03f9f96ec 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -1,5 +1,7 @@ # FlyBot — Roadmap +#2483 restart: cover saved state reload through Chief choice and actual pool re-dispatch, including historical blocked claims, preserved drafts and exit uncertainty. No fixed recovery role, automatic Task acceptance or live deployment. + #2483 resource Git parity: verify current Project/Attempt/resource/cwd and private Git metadata before granting Dev its clone `.git`; validate actual SDK mutations and read-only denial separately from live resource acceptance. #2483 adds typed dispatch waits and bounded Chief input continuation through existing notifications; source/fixture completion remains distinct from the current OpenToday instance. diff --git a/changelog.d/2483-restart.fixed.md b/changelog.d/2483-restart.fixed.md new file mode 100644 index 000000000..3e3cbc38c --- /dev/null +++ b/changelog.d/2483-restart.fixed.md @@ -0,0 +1 @@ +Notify the resident Chief when role execution is interrupted by restart, and deliver new exit evidence through the existing recovery chain while preserving drafts and unknown-worker fences. diff --git a/core/src/host/project-chief-control.js b/core/src/host/project-chief-control.js index 9d1856a7a..951aeffbd 100644 --- a/core/src/host/project-chief-control.js +++ b/core/src/host/project-chief-control.js @@ -127,6 +127,13 @@ export function createProjectChiefControl(rt) { return { runId: run.id, pending: ['queued', 'running', 'correction-pending'].includes(run.status) } } const scheduledState = ({ row, delivery }) => scheduledInspectionState(rt.state, row, delivery) + function interruptedSource(task) { + const c = task.collaboration, claim = c?.claim, attempt = rt.state.projectAttempts?.[task.deliveryAttemptId], policy = policyOf(task.projectId) + if (terminal.has(task.status) || c?.status !== 'blocked' || !claim || !policy?.enabled || claim.chiefRunId || claim.botId === `${task.projectId}:chief`) return null + if (!attempt || attempt.projectId !== task.projectId || attempt.taskId !== task.id || attempt.id !== c.attemptId || claim.attemptId !== attempt.id) return null + if (attempt.cancelRequested || attempt.supersededBy || !['running', 'interrupted', 'failed'].includes(attempt.status) || !['interrupted', 'intervened'].includes(claim.status) || attempt.status !== 'interrupted' && claim.status !== 'interrupted') return null + return { blockedTaskId: task.id, interruptedClaimId: claim.id, attemptId: attempt.id, claimGeneration: claim.generation, policyRevision: policy.revision, retirementCommitted: Boolean(claim.retirement && rt.projectEffects?.intentCommitted(claim.retirement.effect)), exitConfirmed: claim.workerId ? rt.projectDelivery.exitConfirmed?.(claim.workerId) ?? false : allocationProof(rt.state, task).status === 'not-allocated', allocationSha256: digest(inspectionResources(rt.state, task).resources.filter(row => row.attemptId === attempt.id).map(row => JSON.stringify(row)).sort()) } + } async function snapshot(run) { if (run.source?.mergeEffectId) { const source = run.source, attempt = rt.state.projectAttempts?.[source.attemptId], task = rt.state.tasks[run.taskId ?? attempt?.taskId] @@ -506,7 +513,12 @@ export function createProjectChiefControl(rt) { try { await rt.projectPools.retryChief(task.id, { memberId: policy.authorityMemberId }); run.recovery = { status: 'requeued', at: Date.now() }; await rt.save() } catch (error) { run.recovery = { status: 'blocked', reason: error.message, at: Date.now() } } } for (const task of Object.values(rt.state.tasks)) if (task.collaboration?.status === 'arbitration' && !terminal.has(task.status) && policyOf(task.projectId)?.enabled) await request(task.projectId, 'arbitration', { handoffId: task.collaboration.currentHandoffId }, task.id) + for (const task of Object.values(rt.state.tasks)) { + const source = interruptedSource(task) + if (source) await request(task.projectId, 'inspection', source) + } for (const run of Object.values(rt.state.projectExecutions ?? {}).filter(row => row.kind === 'chief-control' && row.status === 'queued')) { + if (run.source?.interruptedClaimId && digest(interruptedSource(rt.state.tasks[run.source.blockedTaskId] ?? {})) !== digest(run.source)) { run.status = 'cancelled'; run.failure = 'Interrupted execution facts changed before Chief notification'; run.finishedAt = Date.now(); await rt.save(); continue } if ([...active.values()].some(job => job.run.projectId === run.projectId) || rt.projectAgents.chiefBusy(run.projectId)) { run.waitingFor = 'chief-busy'; continue } if (await inspectionBarrier(run)) { run.waitingFor = 'worker-exit'; continue } const policy = policyOf(run.projectId) diff --git a/core/src/host/project-chief-protocol.js b/core/src/host/project-chief-protocol.js index 8d3d5b7db..9893d7bc9 100644 --- a/core/src/host/project-chief-protocol.js +++ b/core/src/host/project-chief-protocol.js @@ -1,6 +1,6 @@ import { assistanceRequest } from './project-chief-assistance.js' // Protocol feedback is data for Chief judgement, never a workflow or permission grant. -export const CHIEF_AUTONOMY = 'Within the explicitly authorized Project goals, your first priority is autonomous progress supported by verifiable results. Diagnose errors using current host facts and approved recovery tools; correct malformed decisions or choose another lawful path. Do not repeat the same return/route without new facts. Consult the appropriate personal project agent only when needed information, authority or a human-only decision is missing. The host never selects that escalation for you. Never invent evidence or bypass exit, permission or acceptance gates.' +export const CHIEF_AUTONOMY = 'Within the explicitly authorized Project goals, your first priority is autonomous progress supported by verifiable results. Diagnose errors using current host facts and approved recovery tools; correct malformed decisions or choose another lawful path. After host restart, assess current exit and checkpoint evidence; an old restart message alone does not require a human decision. Do not repeat the same return/route without new facts. Consult the appropriate personal project agent only when needed information, authority or a human-only decision is missing. The host never selects that escalation for you. Never invent evidence or bypass exit, permission or acceptance gates.' const actions = new Set(['close', 'route', 'return', 'request-tests', 'needs-human', 'continue', 'admit', 'revise', 'cancel', 'reopen', 'resolve-dependency', 'retry-notification', 'reconcile-exit']) export function chiefProtocolError(field, expected, taskId = null) { return Object.assign(new Error(`Chief protocol: ${field} needs ${expected}`), { code: 'bot/chief-protocol', fields: [{ field, expected }], taskId: typeof taskId === 'string' ? taskId.slice(0, 100) : null }) diff --git a/core/src/host/project-delivery.js b/core/src/host/project-delivery.js index 4baf0ceac..512708948 100644 --- a/core/src/host/project-delivery.js +++ b/core/src/host/project-delivery.js @@ -1058,7 +1058,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti changed = true; continue } attempt.status = 'interrupted' - attempt.failure = { message: 'Host restarted before delivery completed; start a new Attempt', at: Date.now() } + attempt.failure = { message: 'Host restarted before delivery completed; preserve the checkpoint for Chief recovery judgement', at: Date.now() } rt.taskDeliveryState?.(attempt.taskId, attempt.id, 'failed', attempt.failure.message) if (attempt.runId && ownEntry(state.projectExecutions, attempt.runId)) state.projectExecutions[attempt.runId].status = 'interrupted' changed = true diff --git a/core/test/project-chief-control.test.js b/core/test/project-chief-control.test.js index 654f4e83a..e559962bf 100644 --- a/core/test/project-chief-control.test.js +++ b/core/test/project-chief-control.test.js @@ -101,6 +101,90 @@ test('a new Chief request wakes execution and duplicate receipts do not create a assert.equal(first.id, repeated.id) assert.equal(wakes, 1) }) + +test('a persisted interrupted role claim reaches Chief without a manual or scheduled inspect', async () => { + const f = await controlFixture() + await f.request.onResult(f.result({ action: 'continue', reason: 'Finish the original judgement' })) + const before = f.request.runId, c = f.task.collaboration + c.botId = 'p:developer'; c.status = 'claimed'; c.claim = { id: 'old-dev-claim', botId: c.botId, attemptId: 'a', status: 'active', workerId: 'old-worker', generation: c.generation, createdAt: Date.now() } + f.rt.state.projectAttempts.a.status = 'interrupted' + assert.equal(f.rt.projectPools.recover(), true) + const restored = createProjectChiefControl(f.rt); f.rt.projectChiefControl = restored + await restored.pump() + for (let pass = 0; pass < 100 && f.request.runId === before; ++pass) await new Promise(resolve => setTimeout(resolve, 5)) + assert.notEqual(f.request.runId, before, 'Host-restart block must notify the resident Chief through its existing controller') + const run = f.rt.state.projectExecutions[f.request.runId] + assert.equal(run.purpose, 'inspection'); assert.equal(run.source.blockedTaskId, 't') + assert.equal(f.request.projectFacts.tasks.length, 1) + assert.equal(f.request.projectFacts.tasks[0].execution.claimId, 'old-dev-claim') + assert.equal(c.status, 'blocked', 'Notification never selects a role or clears the block') + const count = Object.keys(f.rt.state.projectExecutions).length + await restored.pump(); await restored.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count, 'Repeated pump must not create duplicate restart judgements') +}) + +async function interruptedRoleFixture() { + const f = await controlFixture() + await f.request.onResult(f.result({ action: 'continue', reason: 'Finish the original judgement' })) + const c = f.task.collaboration + c.botId = 'p:developer'; c.status = 'claimed'; c.claim = { id: 'old-dev', botId: c.botId, attemptId: 'a', generation: c.generation, status: 'active', workerId: 'old-worker', createdAt: Date.now() } + f.rt.state.projectAttempts.a.status = 'interrupted'; f.rt.projectPools.recover() + return f +} + +test('interrupted notifications deduplicate across controller restart and wake again only for new exit evidence', async () => { + const f = await interruptedRoleFixture(); let exited = false + const first = { attemptId: 'a', role: 'developer', status: 'released', releasedAt: 1 }, second = { attemptId: 'a', role: 'tester', status: 'released', releasedAt: 2 } + f.rt.state.projectResourceLeases = { first, second } + f.rt.projectDelivery.exitConfirmed = () => exited + await f.control.pump() + assert.equal(f.rt.state.projectExecutions[f.request.runId].source.exitConfirmed, false) + await f.request.onResult(f.result({ action: 'continue', reason: 'Keep waiting for actual allocation exit evidence' })) + const count = Object.keys(f.rt.state.projectExecutions).length + f.rt.state.projectResourceLeases = { second, first } + const restored = createProjectChiefControl(f.rt); f.rt.projectChiefControl = restored + f.task.collaboration.claim.status = 'intervened'; ++f.task.collaboration.generation + await restored.pump(); await restored.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count, 'Failed routing or process recreation alone is not new allocation evidence') + exited = true + await restored.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count + 1) + assert.equal(Object.values(f.rt.state.projectExecutions).at(-1).source.exitConfirmed, true) + assert.equal(f.task.collaboration.status, 'blocked', 'Exit evidence delivers a fact, not an automatic routing decision') +}) + +test('interrupted notifications respect disabled policy, terminal Task and current claim binding', async () => { + for (const condition of ['disabled', 'accepted', 'cancelled', 'foreign-attempt', 'chief-claim', 'waiting-human', 'attempt-cancelled', 'superseded', 'read-only']) { + const f = await interruptedRoleFixture(), count = Object.keys(f.rt.state.projectExecutions).length + if (condition === 'disabled') f.rt.state.projectChiefPolicies.p.enabled = false + else if (['accepted', 'cancelled'].includes(condition)) f.task.status = condition + else if (condition === 'foreign-attempt') f.task.collaboration.claim.attemptId = 'other' + else if (condition === 'chief-claim') f.task.collaboration.claim.chiefRunId = 'other-chief' + else if (condition === 'attempt-cancelled') f.rt.state.projectAttempts.a.cancelRequested = true + else if (condition === 'superseded') f.rt.state.projectAttempts.a.supersededBy = 'other' + else if (condition === 'read-only') f.rt.readOnly = () => true + else f.task.collaboration.claim.status = 'waiting-human' + await f.control.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count, condition) + } + const f = await interruptedRoleFixture(); f.revokeRole() + await f.control.pump() + const run = Object.values(f.rt.state.projectExecutions).at(-1) + assert.equal(run.status, 'failed'); assert.match(run.dispatchFailure, /denied/) + assert.equal(f.task.collaboration.status, 'blocked') +}) + +test('queued interrupted notification is cancelled after responsibility progresses', async () => { + const f = await interruptedRoleFixture() + f.rt.projectAgents.chiefBusy = () => true + await f.control.pump() + const notification = Object.values(f.rt.state.projectExecutions).at(-1) + assert.equal(notification.status, 'queued') + f.task.collaboration.status = 'queued'; delete f.task.collaboration.claim + f.rt.projectAgents.chiefBusy = () => false + await f.control.pump() + assert.equal(notification.status, 'cancelled'); assert.match(notification.failure, /facts changed/) +}) test('Chief results cannot close a Task that progressed during reasoning or whose actor lost permission', async () => { const f = await controlFixture(); ++f.task.collaboration.generation await f.request.onResult(f.result()) diff --git a/core/test/project-delivery.test.js b/core/test/project-delivery.test.js index 29f279895..2e0fe23b8 100644 --- a/core/test/project-delivery.test.js +++ b/core/test/project-delivery.test.js @@ -10,6 +10,7 @@ import { createRuntime } from '../index.js' import { roleExecutionPrompt } from '../src/host/project-role-policy.js' import { githubCredentialBinding } from '../src/host/project-github-app.js' import { createProjectDelivery } from '../src/host/project-delivery.js' +import { taskView } from '../src/host/project-development-service.js' import { createTaskContract, admitTask, addTaskDependencies } from '../src/host/project-task-lifecycle.js' import { childResultSources } from '../src/host/project-child-inputs.js' import { fakeContext } from './fake-ctx.js' @@ -173,6 +174,82 @@ test('restart explicitly interrupts an unfinished Attempt and preserves its work await ctx.dispose() }) +for (const allocation of ['no-worker', 'already-blocked', 'released-worker', 'retire-then-route', 'unknown-worker']) test('persisted host restart reaches Chief and respects ' + allocation + ' when resuming the draft', async t => { + const f = await setup(t, undefined, { pool: true }) + f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) + await f.start('before-restart') + const previous = f.attempt(), task = f.rt.state.tasks[f.task.id] + const claim = await f.rt.projectPools.claim(task.id, 'p1:developer') + const definition = f.rt.projectAgents.deliveryDefinitions('p1').roles.find(row => row.role === 'developer') + await prepareRole(f.rt, task, claim, definition) + const workspace = previous.workspaces.developer, sessionId = previous.roleSessions.developer.logicalSessionId + writeFileSync(join(workspace.path, 'code.mjs'), 'export const add = (a,b) => a+b;\n') + if (['released-worker', 'retire-then-route', 'unknown-worker'].includes(allocation)) { + const exited = allocation !== 'unknown-worker', proof = { kind: 'sdk-stop-ack-and-process-exit', workerId: 'prior-sdk', generation: 1, at: Date.now() } + f.rt.state.projectHarnessWorkers ??= {}; f.rt.state.projectResourceLeases ??= {} + task.collaboration.claim.workerId = 'prior-sdk' + f.rt.state.projectHarnessWorkers['prior-sdk'] = { id: 'prior-sdk', projectId: 'p1', taskId: task.id, attemptId: previous.id, role: 'developer', sessionKey: previous.roleSessions.developer.id, resourceId: 'prior-resource', leaseId: 'prior-lease', generation: 1, status: exited ? 'released' : 'unreachable', ...(exited ? { releaseProof: proof } : {}) } + f.rt.state.projectResourceLeases['prior-lease'] = { id: 'prior-lease', projectId: 'p1', attemptId: previous.id, role: 'developer', resourceId: 'prior-resource', generation: 1, status: exited ? 'released' : 'unreachable', worker: { kind: 'sdk', handle: 'prior-sdk' }, ...(exited ? { releaseProof: proof } : {}) } + } + f.rt.state.projectAgents ??= {}; f.rt.state.projectChiefPolicies ??= {} + f.rt.state.projectAgents['public-p1'] = { id: 'public-p1', projectId: 'p1', visibility: 'public', botId: 'a', sessionId: 'a', carrierGeneration: 1 } + f.rt.state.projectChiefPolicies.p1 = { projectId: 'p1', enabled: true, authorityMemberId: alice.memberId, revision: 1, inspection: { thresholds: { maxHandoffsWithoutProgress: 3, longExecutionMs: 10000, missingHeartbeatMs: 5000, longWaitMs: 8000 }, prompt: 'Inspect interrupted work' } } + if (allocation === 'already-blocked') { f.rt.projectDelivery.recover(); f.rt.projectPools.recover() } + await f.rt.save(); await f.rt.ctx.dispose() + + const ctx = fakeContext() + Object.assign(ctx, { llm: { listProviders: () => [], listModels: async () => [] }, workspaceRegistry: { get: () => undefined, archiveSession: async () => {} }, sessionController: { resolveAgent: async () => ({ agent: undefined }) }, sessions: { flush: async () => {} } }) + const rt = createRuntime(ctx, { ...f.rt.config }) + rt.projectDelivery = createProjectDelivery(rt, { publisher: f.publisher }) + let chief, dispatched + rt.projectAgents.startChiefJob = async args => { chief = args; return 'fixture-chief' } + rt.projectPools.connect({ validate: () => ({ ok: true }), ready: () => true, rework: args => rt.projectDelivery.reworkPool(args), validateRecovery: args => rt.projectDelivery.validateRecovery(args), + execute: async ({ task, claim, definition }) => { await rt.projectDelivery.preparePoolRole(task, claim, definition); return 'fixture-resumed-dev' }, dispatch: async args => { dispatched = args }, release: async id => rt.projectDelivery.exitConfirmed(id) }) + try { + await rt.load() + const resumedTask = rt.state.tasks[task.id] + assert.equal(resumedTask.collaboration.status, 'blocked'); assert.match(resumedTask.collaboration.block.reason, /Host restarted/) + assert.equal(rt.state.projectAttempts[previous.id].status, 'interrupted') + await rt.projectScheduler.wake() + for (let pass = 0; pass < 200 && !chief; ++pass) await new Promise(resolve => setTimeout(resolve, 5)) + assert.ok(chief, 'No manual inspect or schedule delivery is needed') + assert.equal(chief.projectFacts.tasks[0].execution.claimId, claim.id) + if (allocation === 'retire-then-route') { + const retiring = chief + await retiring.onResult({ ok: true, text: JSON.stringify({ summary: 'Reconcile the exact old responsibility first', decisions: [{ taskId: task.id, action: 'reconcile-exit', reason: 'Matching SDK stop and lease release prove exit' }] }), executionEvidence: { kind: 'native-chief', chiefRunId: retiring.runId, carrierGeneration: 1, botId: 'a', sessionId: 'a' } }) + await rt.projectScheduler.wake() + for (let pass = 0; pass < 200 && chief.runId === retiring.runId; ++pass) await new Promise(resolve => setTimeout(resolve, 5)) + assert.notEqual(chief.runId, retiring.runId, JSON.stringify(rt.state.projectExecutions[retiring.runId])) + assert.equal(rt.state.projectEffects[resumedTask.collaboration.claim.retirement.effect.id].kind, 'pool.retire') + assert.equal(resumedTask.collaboration.status, 'blocked', 'Confirmed retirement still leaves the next role to Chief') + } + await chief.onResult({ ok: true, text: JSON.stringify({ summary: 'Assess the original allocation and resume the checkpoint', decisions: [{ taskId: task.id, action: 'route', targetBotId: 'p1:developer', reason: 'Continue the preserved draft when actual exit proof permits it' }] }), executionEvidence: { kind: 'native-chief', chiefRunId: chief.runId, carrierGeneration: 1, botId: 'a', sessionId: 'a' } }) + await rt.projectScheduler.wake() + if (allocation === 'unknown-worker') { + assert.equal(dispatched, undefined); assert.equal(resumedTask.collaboration.status, 'blocked') + assert.match(resumedTask.collaboration.block.reason, /confirmed worker exit/) + assert.equal(rt.state.projectResourceLeases['prior-lease'].status, 'unreachable') + const count = Object.keys(rt.state.projectExecutions).length + await rt.projectScheduler.wake(); await rt.projectScheduler.wake() + assert.equal(Object.keys(rt.state.projectExecutions).length, count, 'Unknown exit must not create a Chief notification hot loop') + assert.equal(readFileSync(join(workspace.path, 'code.mjs'), 'utf8'), 'export const add = (a,b) => a+b;\n') + return + } + assert.ok(dispatched, JSON.stringify(rt.state.projectExecutions[chief.runId])) + assert.equal(dispatched.task.id, task.id); assert.equal(dispatched.claim.attemptId, previous.id) + assert.equal(resumedTask.collaboration.status, 'claimed'); assert.equal(resumedTask.collaboration.block, undefined) + assert.equal(taskView(rt.state, resumedTask).reason, null) + assert.ok(['running', 'starting'].includes(taskView(rt.state, resumedTask).phase)) + assert.equal(resumedTask.collaboration.history.at(-1).resolvedBlock.reason.startsWith('Host restarted'), true) + const resumed = rt.state.projectAttempts[previous.id] + assert.equal(resumed.status, 'running'); assert.equal(resumed.workspaces.developer.path, workspace.path) + assert.equal(resumed.roleSessions.developer.logicalSessionId, sessionId) + assert.equal(readFileSync(join(workspace.path, 'code.mjs'), 'utf8'), 'export const add = (a,b) => a+b;\n') + assert.equal(Object.keys(rt.state.projectAttempts).length, 1) + assert.equal(resumedTask.status === 'accepted', false) + } finally { await ctx.dispose() } +}) + test('a code Task cannot be accepted through generic transitions before it has delivery evidence', async t => { const fixture = await setup(t) await assert.rejects(fixture.rt.taskEndpoint('task-update', { id: fixture.task.id, column: 'done' }, alice), /commit-specific/) diff --git a/core/test/project-runtime-setup.test.js b/core/test/project-runtime-setup.test.js index 0cfe0a5b4..587e47a65 100644 --- a/core/test/project-runtime-setup.test.js +++ b/core/test/project-runtime-setup.test.js @@ -161,3 +161,27 @@ test('SDK probe preserves typed refresh-token failure and does not reuse readine const again = await setup.check(body,owner) assert.equal(again.status,'authRequired'); assert.notEqual(again.profile.id,result.profile.id) }) + +test('legacy ready without profile cannot dispatch until an explicit fresh probe binds current identity', async t => { + const { setup, rt, body, root } = await fixture(t) + const { mkdir, writeFile } = await import('node:fs/promises') + const runtime = join(root, 'fixture-runtime'), observed = join(root, 'probe-observed') + await mkdir(join(runtime, 'src/host'), { recursive: true }) + rt.config.projectResources.local.runtimeDir = runtime + const definition = { role: 'developer', execution: { harness: 'codex', model: 'fixture-model', resourceIds: ['local'] } } + rt.projectAgents.deliveryDefinitions = () => ({ roles: [definition] }) + await writeFile(join(runtime, 'src/host/project-harness-worker.mjs'), `import{createInterface}from'node:readline';import{writeFileSync}from'node:fs';const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');send({type:'ready',sdk:{codex:'0.166.0'}});createInterface({input:process.stdin}).on('line',line=>{const m=JSON.parse(line);if(m.type==='start'){writeFileSync(${JSON.stringify(observed)},'fixture probe executed');send({type:'completed'})}if(m.type==='stop'){send({type:'stopped',released:true});process.exit(0)}});`) + rt.state.projectResourceHealth = { local: { status: 'ready', at: Date.now() } } + rt.state.projectHarnessHealth = { 'p:local:codex': { status: 'ready', at: Date.now() } } + assert.equal(rt.projectResources.occupied('local'), 0) + assert.equal(rt.projectResources.harnessHealth('p', 'local', 'codex').status, 'unconfigured') + assert.throws(() => rt.projectResources.plan('p', { roles: [definition] }), /capacity or readiness/) + let wakes = 0; rt.projectScheduler = { wake() { ++wakes } } + const result = await setup.endpoint('project-runtime-check', body, owner) + assert.equal(await readFile(observed, 'utf8'), 'fixture probe executed', 'Recent legacy ready must not bypass the selected profile check') + assert.equal(result.status, 'ready') + assert.equal(result.profile.id, (await rt.projectHarness.profile('p', 'local', 'codex')).profile.id) + assert.equal(rt.state.projectHarnessHealth['p:local:codex'].profile.id, result.profile.id) + assert.equal(rt.projectResources.plan('p', { roles: [definition] })[0].resourceId, 'local') + assert.equal(rt.projectResources.occupied('local'), 0); assert.ok(wakes > 0) +}) diff --git a/decisions/2026-11-05-interrupted-role-chief-notification.md b/decisions/2026-11-05-interrupted-role-chief-notification.md new file mode 100644 index 000000000..6114c86bd --- /dev/null +++ b/decisions/2026-11-05-interrupted-role-chief-notification.md @@ -0,0 +1,34 @@ +# Notify Chief about interrupted role responsibility + +Date: 2026-11-05. Status: accepted for the Owner's explicit continuation of #2483 +on `delivery/ai-centric-development-service`, developed directly in this session. + +Existing recovery APIs passed isolated tests, but a regression at f2dd09866 shows +that persisted role restart blocks do not automatically reach Chief: the pump +only discovers arbitration. Reuse its original inspection request and durable +Run identity to deliver current interrupted responsibility and exit facts. Repeated +unchanged facts reuse the notice; physical exit or committed retirement may notify +again. A failed route's collaboration generation alone is not new evidence. +Discard queued notices after responsibility progresses. Keep the existing scheduler, +policy authority, exit fences and role context; add no workflow or polling clock. + +Chief still decides whether to resume, rework, change role, wait or consult. An old +restart message alone is not a human-only decision. Neither notification nor exit +retirement selects a role, frees unknown workers, acknowledges inputs or accepts +a Task. The failure message preserves the checkpoint for judgement. + +Tests write disposable state, release the first Runtime and reload it, then apply +fixture Chief decisions through real pools/delivery and Git workspaces. They cover +historical blocks, preserved draft/cwd/logical context, trusted released SDK rows, +explicit retirement followed by another judgement and unknown exit without hot +loops. This proves the mechanical chain, not actual model choice, live Docker SDK +exit recovery, private App acceptance or deployment. See +[Chief](../docs/subsystems/project-chief-control.md) and +[Delivery](../docs/subsystems/project-delivery.md). + +Ops evidence6649441606 separately identifies Main's current resource wait: legacy +ready health lacks the selected profile ID. Retain the existing identity fence; +an explicit runtime check can refresh it and wake dispatch. A fixture probe test +covers this existing path without new product code or live/model-paid operations. +It is a distinct readiness gate, not evidence against the reproduced missing +interrupted-role notification. Both need independent acceptance. diff --git a/docs/backend-operations.md b/docs/backend-operations.md index 83c039bed..5a5b47a7d 100644 --- a/docs/backend-operations.md +++ b/docs/backend-operations.md @@ -246,6 +246,24 @@ Use GET /ready to detect read-only/unavailable backend state; GET /health only p ### Queued without a worker, or pending Chief inputs (#2483) +After restart, current blocked/interrupted role work now notifies the configured +Chief through its existing controller. `chief.state` Run.source identifies the +interrupted claim, policy and allocation fingerprint; repeated scheduler passes +reuse the request. New exit evidence or committed `pool.retire` can cause a fresh +judgement. Inspect actual applied decisions and TaskView phase: notification or +an enabled schedule is not recovery. Unknown worker exit remains held. No manual +SQL edit, blanket block clearing or forced Developer routing is required by this +path; genuine missing exit/authority evidence still needs to be established. + +Stored SDK `ready` without a matching current `profile.id` is not current Harness +readiness. With resource health ready and occupied=0, check `project-resources` +`harnessStatus` before blaming Project capacity or restart. An authorized Owner or +Maintainer can call existing `project-runtime-check` with Project/resource/Harness +to perform a new selected-profile probe; it may invoke paid minimal inference. +Success saves matching profile health and wakes the existing scheduler, without a +backend restart. Do not copy the computed ID into old ready state or bypass the +identity check. Readiness refresh and interrupted-Task recovery are distinct gates. + Use TaskView.dispatch/current pool.dispatch via normal queries to distinguish held logical Bot, resource readiness, Project capacity, missing adapter and refused admission. Its executions count is Effect retries, not model calls. Raw SQL lacking committed cannot prove an uncommitted intent; use runtime query proof. Input receipt accepted is not role acceptance; inspect collaboration history/current handoff separately. For closure, inspect `chief.state` lastResult.inputReceiptSummaries and correction taskFeedback counts. remaining means outside this injected batch. A valid continue decision can commit an explicitly handled batch and persist the next original input notification. Read-only/zero-progress declarations create no continuation loop; unresolved inputs still prevent close. The host never fakes handled status, drops criteria, chooses a role or escalates after a retry count. Diagnostic response/snapshot hashes are not necessarily state-payload file references. diff --git a/docs/openapi/flybot.openapi.json b/docs/openapi/flybot.openapi.json index b53a78b11..21f014a59 100644 --- a/docs/openapi/flybot.openapi.json +++ b/docs/openapi/flybot.openapi.json @@ -34132,7 +34132,7 @@ "notes": "Read continuation facts even after cancellation. task.run selects a successor, never revives the cancelled Attempt.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -34214,7 +34214,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": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -34292,7 +34292,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": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -34453,7 +34453,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": "fc5ace41bfe6d855d13fd2a8216f8c52d4000c5080653015cec884d158d8a358" + "sha256": "209299837eba2321043be5f9a7156466863e1ec2977ff1318f10ac7b8b586fdf" } }, { @@ -36341,7 +36341,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": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -36449,7 +36449,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -36556,7 +36556,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -36650,7 +36650,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -36754,7 +36754,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": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -36864,7 +36864,7 @@ "notes": "Fetch attempt.integration-state again after retirement; attempt.stop remains cancellation.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -36973,7 +36973,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" } }, { @@ -37548,7 +37548,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": "fc5ace41bfe6d855d13fd2a8216f8c52d4000c5080653015cec884d158d8a358" + "sha256": "209299837eba2321043be5f9a7156466863e1ec2977ff1318f10ac7b8b586fdf" } }, { @@ -37653,7 +37653,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": "fc5ace41bfe6d855d13fd2a8216f8c52d4000c5080653015cec884d158d8a358" + "sha256": "209299837eba2321043be5f9a7156466863e1ec2977ff1318f10ac7b8b586fdf" } }, { @@ -47395,7 +47395,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": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" }, "aliasOf": "development.command.task.accept" }, @@ -47488,7 +47488,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" }, "aliasOf": "development.command.task.reject" }, @@ -47580,7 +47580,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" }, "aliasOf": "development.command.attempt.stop" }, @@ -47672,7 +47672,7 @@ "notes": "All current authorization, Project/Task bindings and domain fences are rechecked server-side.", "implementation": { "path": "core/src/host/project-delivery.js", - "sha256": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" }, "aliasOf": "development.command.attempt.cleanup" }, @@ -48061,7 +48061,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": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" }, "aliasOf": "development.command.attempt.publication-retry" }, @@ -48137,7 +48137,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": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba" + "sha256": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f" }, "aliasOf": "development.query.attempt.log" }, @@ -48445,7 +48445,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": "fc5ace41bfe6d855d13fd2a8216f8c52d4000c5080653015cec884d158d8a358" + "sha256": "209299837eba2321043be5f9a7156466863e1ec2977ff1318f10ac7b8b586fdf" }, "aliasOf": "development.command.chief.inspect" }, @@ -48548,7 +48548,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": "fc5ace41bfe6d855d13fd2a8216f8c52d4000c5080653015cec884d158d8a358" + "sha256": "209299837eba2321043be5f9a7156466863e1ec2977ff1318f10ac7b8b586fdf" }, "aliasOf": "development.command.chief.configure" }, @@ -48624,7 +48624,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": "fc5ace41bfe6d855d13fd2a8216f8c52d4000c5080653015cec884d158d8a358" + "sha256": "209299837eba2321043be5f9a7156466863e1ec2977ff1318f10ac7b8b586fdf" }, "aliasOf": "development.query.chief.state" }, @@ -49493,9 +49493,9 @@ "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": "fc5ace41bfe6d855d13fd2a8216f8c52d4000c5080653015cec884d158d8a358", + "core/src/host/project-chief-control.js": "209299837eba2321043be5f9a7156466863e1ec2977ff1318f10ac7b8b586fdf", "core/src/host/project-chief-lifecycle.js": "ed85bb1be90198910b20ffd605895812dfa848fce1049f7bd27b32dbfae3a328", - "core/src/host/project-chief-protocol.js": "416d57a83ae6064f04d13e70ffba63d8732b4cee4598e37fdcbf63ece7181a79", + "core/src/host/project-chief-protocol.js": "324bb2fe60caf5ad53019ad1a7e6622b04f378e1639bdb7c8e3e6afe644429d6", "core/src/host/project-chief-recovery.js": "c80a9d791aab06d4b343e8740a25a471ddf93e0af52b59dd9b7e6fc835c0d824", "core/src/host/project-chief.js": "a3f01a053177fd8f10d4b778d79ac98c25aee70aa3c4d967dc7e1da2aea2cf6e", "core/src/host/project-child-inputs.js": "db1f534c4222dba1b500d308b1cd07f0df50a02eb228f520b150a3b79d6f126d", @@ -49508,7 +49508,7 @@ "core/src/host/project-credentials.js": "18fe61057fb920664fc5077d81eee0957f7eaf64821dd10d2d2a3cd463f5fdea", "core/src/host/project-delegations.js": "d4d86192551994a3419e0d2ca0f64bc4d50a75960970d2b79ed9f97b82940903", "core/src/host/project-delivery-template.js": "f149d09786ac6db41a7ebeccb7620cce62b57f334d3ddd5e3c66ee673e5b66a8", - "core/src/host/project-delivery.js": "959cedecd7aec46cfd5693711c18637af8956bd6aed0701febb9f314a5f4f1ba", + "core/src/host/project-delivery.js": "262b03fbc1897f58bbe0be6bd731818be4f020450c9ed578a06051ce72c5307f", "core/src/host/project-development-service.js": "6d5fcbc4fead594a4951b0ac8e2fb04f74089d7d8792f0df50dc38b2f94e6c71", "core/src/host/project-domain.js": "6d6a20727905005f7a8de0247a429a10ffbd1436773ee96a5d7615b2fb763e82", "core/src/host/project-effects.js": "dc02f89eb221d543805d115e48a9534882a344d233bf089299e43b1a1a4ede63", diff --git a/docs/subsystems/backend-service.md b/docs/subsystems/backend-service.md index 2128f2e20..4d2a21415 100644 --- a/docs/subsystems/backend-service.md +++ b/docs/subsystems/backend-service.md @@ -30,6 +30,11 @@ source fingerprints reflect the host adapter fix. See [Harness](project-harness. # Persistent source backend entry (#2189) +#2483 interrupted-role facts now reach existing Chief inspection without a manual +API call. `chief.state` source identifies the claim/policy/allocation fingerprint; +TaskView reflects the applied recovery, not notification success. Public operations +and permissions are unchanged; OpenAPI implementation fingerprints are refreshed. + The entry composes the existing DSH host/profile, all five FlyBot packages and the original restricted HTTP/SSE gateway. It adds neither a domain service nor a Task writer, permission policy, scheduler or executor. The upstream cookie stays in the diff --git a/docs/subsystems/project-chief-control.md b/docs/subsystems/project-chief-control.md index 31f75c965..ca2c11676 100644 --- a/docs/subsystems/project-chief-control.md +++ b/docs/subsystems/project-chief-control.md @@ -74,6 +74,23 @@ actual execution still requires physical resource capacity. ## Restart and process proof +#2483 adds missing interrupted-role notification to the existing controller pump. +A current recoverable Attempt with a blocked interrupted/intervened role claim +requests one scoped inspection under the configured policy. Existing durable Run +source identity binds Project/Task/Attempt/claim, original claim generation, policy +revision, current allocation evidence and committed exit retirement. Failed routing +alone cannot generate another notice; changed exit/retirement facts can. Disabled +authority, cancelled/superseded work, terminal Tasks and Chief's separate arbitration +claims are excluded. A queued notice is cancelled if its responsibility/facts change. + +This is event delivery, not a selected recovery workflow. The resident Chief sees +current checkpoint/exit evidence and chooses its next action. Existing exit, context, +input, permission and acceptance gates remain. Unknown exit retains occupancy; +confirmed retirement can notify Chief again without choosing the next role. No new +timer, Effect kind, Task store or implicit human escalation is introduced. Integration +tests persist state, dispose the old Runtime, reload and verify actual re-dispatch; +their Chief/worker replies are fixtures, distinct from live provider acceptance. + Interrupted work is never assumed successful or automatically freed on missing PID. Local workers record the actual systemd invocation and control group only when supervisor metadata matches the running process and KillMode=control-group. diff --git a/docs/subsystems/project-delivery.md b/docs/subsystems/project-delivery.md index b2240da6a..f9ecc681c 100644 --- a/docs/subsystems/project-delivery.md +++ b/docs/subsystems/project-delivery.md @@ -676,6 +676,14 @@ an App Attempt refuses personal/sealing fallback. [Contract](../role-github-apps ## Exit reconciliation (#2375) +#2483 restart recovery now reaches Chief through the original inspection controller +without requiring an operator inspect call. The restart failure message preserves +the checkpoint for judgement instead of prescribing a new Attempt. Persist/reload +tests cover draft/cwd/logical Session continuity, already persisted blocks, released +SDK evidence, explicit retirement followed by a separate Chief routing decision, +and retained unknown occupancy. The existing rework/resume gates clear only the +current block and retain its audit; notification itself changes no business state. + `attempt.reconcile-exit` requires current configure authority, attemptId, claimId, generation and reason. It rechecks exact worker/Session/allocation exit, preparation and Chief occupancy, pending results/correction and unknown publication effects before committing pool retirement. It preserves drafts/candidates and does not cancel or revive the Attempt. Fetch a fresh `attempt.integration-state` before `attempt.integration-bind`; inputs are not candidate/PR evidence. ## Explicit successor continuation (#2377) diff --git a/docs/subsystems/project-runtime-setup.md b/docs/subsystems/project-runtime-setup.md index 67cea6638..027c7dc4a 100644 --- a/docs/subsystems/project-runtime-setup.md +++ b/docs/subsystems/project-runtime-setup.md @@ -120,6 +120,12 @@ See [delegation contract](project-delegations.md). ## Resource SDK authentication — #2398 +#2483 confirms that legacy stored ready without the current profile ID cannot +admit SDK execution even at occupied=0. Explicit `project-runtime-check` bypasses +that stale observation, performs the existing bounded probe, saves matching profile +health and wakes dispatch without restart. A disposable fixture worker covers the +transition; no computed ID is silently assigned to unverified ready history. + #2398 returns the actual profile HOME/configDir only to the explicit terminal admin; public health exposes safe profile IDs. Setup resolves the selected resource SDK CLI, not the host/global CLI. Login/check/worker use the same selector; changed profiles 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 9d1856a7a..951aeffbd 100644 --- a/patches/ds-bot/files/src/host/project-chief-control.js +++ b/patches/ds-bot/files/src/host/project-chief-control.js @@ -127,6 +127,13 @@ export function createProjectChiefControl(rt) { return { runId: run.id, pending: ['queued', 'running', 'correction-pending'].includes(run.status) } } const scheduledState = ({ row, delivery }) => scheduledInspectionState(rt.state, row, delivery) + function interruptedSource(task) { + const c = task.collaboration, claim = c?.claim, attempt = rt.state.projectAttempts?.[task.deliveryAttemptId], policy = policyOf(task.projectId) + if (terminal.has(task.status) || c?.status !== 'blocked' || !claim || !policy?.enabled || claim.chiefRunId || claim.botId === `${task.projectId}:chief`) return null + if (!attempt || attempt.projectId !== task.projectId || attempt.taskId !== task.id || attempt.id !== c.attemptId || claim.attemptId !== attempt.id) return null + if (attempt.cancelRequested || attempt.supersededBy || !['running', 'interrupted', 'failed'].includes(attempt.status) || !['interrupted', 'intervened'].includes(claim.status) || attempt.status !== 'interrupted' && claim.status !== 'interrupted') return null + return { blockedTaskId: task.id, interruptedClaimId: claim.id, attemptId: attempt.id, claimGeneration: claim.generation, policyRevision: policy.revision, retirementCommitted: Boolean(claim.retirement && rt.projectEffects?.intentCommitted(claim.retirement.effect)), exitConfirmed: claim.workerId ? rt.projectDelivery.exitConfirmed?.(claim.workerId) ?? false : allocationProof(rt.state, task).status === 'not-allocated', allocationSha256: digest(inspectionResources(rt.state, task).resources.filter(row => row.attemptId === attempt.id).map(row => JSON.stringify(row)).sort()) } + } async function snapshot(run) { if (run.source?.mergeEffectId) { const source = run.source, attempt = rt.state.projectAttempts?.[source.attemptId], task = rt.state.tasks[run.taskId ?? attempt?.taskId] @@ -506,7 +513,12 @@ export function createProjectChiefControl(rt) { try { await rt.projectPools.retryChief(task.id, { memberId: policy.authorityMemberId }); run.recovery = { status: 'requeued', at: Date.now() }; await rt.save() } catch (error) { run.recovery = { status: 'blocked', reason: error.message, at: Date.now() } } } for (const task of Object.values(rt.state.tasks)) if (task.collaboration?.status === 'arbitration' && !terminal.has(task.status) && policyOf(task.projectId)?.enabled) await request(task.projectId, 'arbitration', { handoffId: task.collaboration.currentHandoffId }, task.id) + for (const task of Object.values(rt.state.tasks)) { + const source = interruptedSource(task) + if (source) await request(task.projectId, 'inspection', source) + } for (const run of Object.values(rt.state.projectExecutions ?? {}).filter(row => row.kind === 'chief-control' && row.status === 'queued')) { + if (run.source?.interruptedClaimId && digest(interruptedSource(rt.state.tasks[run.source.blockedTaskId] ?? {})) !== digest(run.source)) { run.status = 'cancelled'; run.failure = 'Interrupted execution facts changed before Chief notification'; run.finishedAt = Date.now(); await rt.save(); continue } if ([...active.values()].some(job => job.run.projectId === run.projectId) || rt.projectAgents.chiefBusy(run.projectId)) { run.waitingFor = 'chief-busy'; continue } if (await inspectionBarrier(run)) { run.waitingFor = 'worker-exit'; continue } const policy = policyOf(run.projectId) diff --git a/patches/ds-bot/files/src/host/project-chief-protocol.js b/patches/ds-bot/files/src/host/project-chief-protocol.js index 8d3d5b7db..9893d7bc9 100644 --- a/patches/ds-bot/files/src/host/project-chief-protocol.js +++ b/patches/ds-bot/files/src/host/project-chief-protocol.js @@ -1,6 +1,6 @@ import { assistanceRequest } from './project-chief-assistance.js' // Protocol feedback is data for Chief judgement, never a workflow or permission grant. -export const CHIEF_AUTONOMY = 'Within the explicitly authorized Project goals, your first priority is autonomous progress supported by verifiable results. Diagnose errors using current host facts and approved recovery tools; correct malformed decisions or choose another lawful path. Do not repeat the same return/route without new facts. Consult the appropriate personal project agent only when needed information, authority or a human-only decision is missing. The host never selects that escalation for you. Never invent evidence or bypass exit, permission or acceptance gates.' +export const CHIEF_AUTONOMY = 'Within the explicitly authorized Project goals, your first priority is autonomous progress supported by verifiable results. Diagnose errors using current host facts and approved recovery tools; correct malformed decisions or choose another lawful path. After host restart, assess current exit and checkpoint evidence; an old restart message alone does not require a human decision. Do not repeat the same return/route without new facts. Consult the appropriate personal project agent only when needed information, authority or a human-only decision is missing. The host never selects that escalation for you. Never invent evidence or bypass exit, permission or acceptance gates.' const actions = new Set(['close', 'route', 'return', 'request-tests', 'needs-human', 'continue', 'admit', 'revise', 'cancel', 'reopen', 'resolve-dependency', 'retry-notification', 'reconcile-exit']) export function chiefProtocolError(field, expected, taskId = null) { return Object.assign(new Error(`Chief protocol: ${field} needs ${expected}`), { code: 'bot/chief-protocol', fields: [{ field, expected }], taskId: typeof taskId === 'string' ? taskId.slice(0, 100) : null }) diff --git a/patches/ds-bot/files/src/host/project-delivery.js b/patches/ds-bot/files/src/host/project-delivery.js index 4baf0ceac..512708948 100644 --- a/patches/ds-bot/files/src/host/project-delivery.js +++ b/patches/ds-bot/files/src/host/project-delivery.js @@ -1058,7 +1058,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti changed = true; continue } attempt.status = 'interrupted' - attempt.failure = { message: 'Host restarted before delivery completed; start a new Attempt', at: Date.now() } + attempt.failure = { message: 'Host restarted before delivery completed; preserve the checkpoint for Chief recovery judgement', at: Date.now() } rt.taskDeliveryState?.(attempt.taskId, attempt.id, 'failed', attempt.failure.message) if (attempt.runId && ownEntry(state.projectExecutions, attempt.runId)) state.projectExecutions[attempt.runId].status = 'interrupted' changed = true 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 654f4e83a..e559962bf 100644 --- a/patches/ds-bot/files/test/project-chief-control.test.js +++ b/patches/ds-bot/files/test/project-chief-control.test.js @@ -101,6 +101,90 @@ test('a new Chief request wakes execution and duplicate receipts do not create a assert.equal(first.id, repeated.id) assert.equal(wakes, 1) }) + +test('a persisted interrupted role claim reaches Chief without a manual or scheduled inspect', async () => { + const f = await controlFixture() + await f.request.onResult(f.result({ action: 'continue', reason: 'Finish the original judgement' })) + const before = f.request.runId, c = f.task.collaboration + c.botId = 'p:developer'; c.status = 'claimed'; c.claim = { id: 'old-dev-claim', botId: c.botId, attemptId: 'a', status: 'active', workerId: 'old-worker', generation: c.generation, createdAt: Date.now() } + f.rt.state.projectAttempts.a.status = 'interrupted' + assert.equal(f.rt.projectPools.recover(), true) + const restored = createProjectChiefControl(f.rt); f.rt.projectChiefControl = restored + await restored.pump() + for (let pass = 0; pass < 100 && f.request.runId === before; ++pass) await new Promise(resolve => setTimeout(resolve, 5)) + assert.notEqual(f.request.runId, before, 'Host-restart block must notify the resident Chief through its existing controller') + const run = f.rt.state.projectExecutions[f.request.runId] + assert.equal(run.purpose, 'inspection'); assert.equal(run.source.blockedTaskId, 't') + assert.equal(f.request.projectFacts.tasks.length, 1) + assert.equal(f.request.projectFacts.tasks[0].execution.claimId, 'old-dev-claim') + assert.equal(c.status, 'blocked', 'Notification never selects a role or clears the block') + const count = Object.keys(f.rt.state.projectExecutions).length + await restored.pump(); await restored.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count, 'Repeated pump must not create duplicate restart judgements') +}) + +async function interruptedRoleFixture() { + const f = await controlFixture() + await f.request.onResult(f.result({ action: 'continue', reason: 'Finish the original judgement' })) + const c = f.task.collaboration + c.botId = 'p:developer'; c.status = 'claimed'; c.claim = { id: 'old-dev', botId: c.botId, attemptId: 'a', generation: c.generation, status: 'active', workerId: 'old-worker', createdAt: Date.now() } + f.rt.state.projectAttempts.a.status = 'interrupted'; f.rt.projectPools.recover() + return f +} + +test('interrupted notifications deduplicate across controller restart and wake again only for new exit evidence', async () => { + const f = await interruptedRoleFixture(); let exited = false + const first = { attemptId: 'a', role: 'developer', status: 'released', releasedAt: 1 }, second = { attemptId: 'a', role: 'tester', status: 'released', releasedAt: 2 } + f.rt.state.projectResourceLeases = { first, second } + f.rt.projectDelivery.exitConfirmed = () => exited + await f.control.pump() + assert.equal(f.rt.state.projectExecutions[f.request.runId].source.exitConfirmed, false) + await f.request.onResult(f.result({ action: 'continue', reason: 'Keep waiting for actual allocation exit evidence' })) + const count = Object.keys(f.rt.state.projectExecutions).length + f.rt.state.projectResourceLeases = { second, first } + const restored = createProjectChiefControl(f.rt); f.rt.projectChiefControl = restored + f.task.collaboration.claim.status = 'intervened'; ++f.task.collaboration.generation + await restored.pump(); await restored.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count, 'Failed routing or process recreation alone is not new allocation evidence') + exited = true + await restored.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count + 1) + assert.equal(Object.values(f.rt.state.projectExecutions).at(-1).source.exitConfirmed, true) + assert.equal(f.task.collaboration.status, 'blocked', 'Exit evidence delivers a fact, not an automatic routing decision') +}) + +test('interrupted notifications respect disabled policy, terminal Task and current claim binding', async () => { + for (const condition of ['disabled', 'accepted', 'cancelled', 'foreign-attempt', 'chief-claim', 'waiting-human', 'attempt-cancelled', 'superseded', 'read-only']) { + const f = await interruptedRoleFixture(), count = Object.keys(f.rt.state.projectExecutions).length + if (condition === 'disabled') f.rt.state.projectChiefPolicies.p.enabled = false + else if (['accepted', 'cancelled'].includes(condition)) f.task.status = condition + else if (condition === 'foreign-attempt') f.task.collaboration.claim.attemptId = 'other' + else if (condition === 'chief-claim') f.task.collaboration.claim.chiefRunId = 'other-chief' + else if (condition === 'attempt-cancelled') f.rt.state.projectAttempts.a.cancelRequested = true + else if (condition === 'superseded') f.rt.state.projectAttempts.a.supersededBy = 'other' + else if (condition === 'read-only') f.rt.readOnly = () => true + else f.task.collaboration.claim.status = 'waiting-human' + await f.control.pump() + assert.equal(Object.keys(f.rt.state.projectExecutions).length, count, condition) + } + const f = await interruptedRoleFixture(); f.revokeRole() + await f.control.pump() + const run = Object.values(f.rt.state.projectExecutions).at(-1) + assert.equal(run.status, 'failed'); assert.match(run.dispatchFailure, /denied/) + assert.equal(f.task.collaboration.status, 'blocked') +}) + +test('queued interrupted notification is cancelled after responsibility progresses', async () => { + const f = await interruptedRoleFixture() + f.rt.projectAgents.chiefBusy = () => true + await f.control.pump() + const notification = Object.values(f.rt.state.projectExecutions).at(-1) + assert.equal(notification.status, 'queued') + f.task.collaboration.status = 'queued'; delete f.task.collaboration.claim + f.rt.projectAgents.chiefBusy = () => false + await f.control.pump() + assert.equal(notification.status, 'cancelled'); assert.match(notification.failure, /facts changed/) +}) test('Chief results cannot close a Task that progressed during reasoning or whose actor lost permission', async () => { const f = await controlFixture(); ++f.task.collaboration.generation await f.request.onResult(f.result()) diff --git a/patches/ds-bot/files/test/project-delivery.test.js b/patches/ds-bot/files/test/project-delivery.test.js index 29f279895..2e0fe23b8 100644 --- a/patches/ds-bot/files/test/project-delivery.test.js +++ b/patches/ds-bot/files/test/project-delivery.test.js @@ -10,6 +10,7 @@ import { createRuntime } from '../index.js' import { roleExecutionPrompt } from '../src/host/project-role-policy.js' import { githubCredentialBinding } from '../src/host/project-github-app.js' import { createProjectDelivery } from '../src/host/project-delivery.js' +import { taskView } from '../src/host/project-development-service.js' import { createTaskContract, admitTask, addTaskDependencies } from '../src/host/project-task-lifecycle.js' import { childResultSources } from '../src/host/project-child-inputs.js' import { fakeContext } from './fake-ctx.js' @@ -173,6 +174,82 @@ test('restart explicitly interrupts an unfinished Attempt and preserves its work await ctx.dispose() }) +for (const allocation of ['no-worker', 'already-blocked', 'released-worker', 'retire-then-route', 'unknown-worker']) test('persisted host restart reaches Chief and respects ' + allocation + ' when resuming the draft', async t => { + const f = await setup(t, undefined, { pool: true }) + f.rt.projectPools.connect({ validate: () => ({ ok: true }) }) + await f.start('before-restart') + const previous = f.attempt(), task = f.rt.state.tasks[f.task.id] + const claim = await f.rt.projectPools.claim(task.id, 'p1:developer') + const definition = f.rt.projectAgents.deliveryDefinitions('p1').roles.find(row => row.role === 'developer') + await prepareRole(f.rt, task, claim, definition) + const workspace = previous.workspaces.developer, sessionId = previous.roleSessions.developer.logicalSessionId + writeFileSync(join(workspace.path, 'code.mjs'), 'export const add = (a,b) => a+b;\n') + if (['released-worker', 'retire-then-route', 'unknown-worker'].includes(allocation)) { + const exited = allocation !== 'unknown-worker', proof = { kind: 'sdk-stop-ack-and-process-exit', workerId: 'prior-sdk', generation: 1, at: Date.now() } + f.rt.state.projectHarnessWorkers ??= {}; f.rt.state.projectResourceLeases ??= {} + task.collaboration.claim.workerId = 'prior-sdk' + f.rt.state.projectHarnessWorkers['prior-sdk'] = { id: 'prior-sdk', projectId: 'p1', taskId: task.id, attemptId: previous.id, role: 'developer', sessionKey: previous.roleSessions.developer.id, resourceId: 'prior-resource', leaseId: 'prior-lease', generation: 1, status: exited ? 'released' : 'unreachable', ...(exited ? { releaseProof: proof } : {}) } + f.rt.state.projectResourceLeases['prior-lease'] = { id: 'prior-lease', projectId: 'p1', attemptId: previous.id, role: 'developer', resourceId: 'prior-resource', generation: 1, status: exited ? 'released' : 'unreachable', worker: { kind: 'sdk', handle: 'prior-sdk' }, ...(exited ? { releaseProof: proof } : {}) } + } + f.rt.state.projectAgents ??= {}; f.rt.state.projectChiefPolicies ??= {} + f.rt.state.projectAgents['public-p1'] = { id: 'public-p1', projectId: 'p1', visibility: 'public', botId: 'a', sessionId: 'a', carrierGeneration: 1 } + f.rt.state.projectChiefPolicies.p1 = { projectId: 'p1', enabled: true, authorityMemberId: alice.memberId, revision: 1, inspection: { thresholds: { maxHandoffsWithoutProgress: 3, longExecutionMs: 10000, missingHeartbeatMs: 5000, longWaitMs: 8000 }, prompt: 'Inspect interrupted work' } } + if (allocation === 'already-blocked') { f.rt.projectDelivery.recover(); f.rt.projectPools.recover() } + await f.rt.save(); await f.rt.ctx.dispose() + + const ctx = fakeContext() + Object.assign(ctx, { llm: { listProviders: () => [], listModels: async () => [] }, workspaceRegistry: { get: () => undefined, archiveSession: async () => {} }, sessionController: { resolveAgent: async () => ({ agent: undefined }) }, sessions: { flush: async () => {} } }) + const rt = createRuntime(ctx, { ...f.rt.config }) + rt.projectDelivery = createProjectDelivery(rt, { publisher: f.publisher }) + let chief, dispatched + rt.projectAgents.startChiefJob = async args => { chief = args; return 'fixture-chief' } + rt.projectPools.connect({ validate: () => ({ ok: true }), ready: () => true, rework: args => rt.projectDelivery.reworkPool(args), validateRecovery: args => rt.projectDelivery.validateRecovery(args), + execute: async ({ task, claim, definition }) => { await rt.projectDelivery.preparePoolRole(task, claim, definition); return 'fixture-resumed-dev' }, dispatch: async args => { dispatched = args }, release: async id => rt.projectDelivery.exitConfirmed(id) }) + try { + await rt.load() + const resumedTask = rt.state.tasks[task.id] + assert.equal(resumedTask.collaboration.status, 'blocked'); assert.match(resumedTask.collaboration.block.reason, /Host restarted/) + assert.equal(rt.state.projectAttempts[previous.id].status, 'interrupted') + await rt.projectScheduler.wake() + for (let pass = 0; pass < 200 && !chief; ++pass) await new Promise(resolve => setTimeout(resolve, 5)) + assert.ok(chief, 'No manual inspect or schedule delivery is needed') + assert.equal(chief.projectFacts.tasks[0].execution.claimId, claim.id) + if (allocation === 'retire-then-route') { + const retiring = chief + await retiring.onResult({ ok: true, text: JSON.stringify({ summary: 'Reconcile the exact old responsibility first', decisions: [{ taskId: task.id, action: 'reconcile-exit', reason: 'Matching SDK stop and lease release prove exit' }] }), executionEvidence: { kind: 'native-chief', chiefRunId: retiring.runId, carrierGeneration: 1, botId: 'a', sessionId: 'a' } }) + await rt.projectScheduler.wake() + for (let pass = 0; pass < 200 && chief.runId === retiring.runId; ++pass) await new Promise(resolve => setTimeout(resolve, 5)) + assert.notEqual(chief.runId, retiring.runId, JSON.stringify(rt.state.projectExecutions[retiring.runId])) + assert.equal(rt.state.projectEffects[resumedTask.collaboration.claim.retirement.effect.id].kind, 'pool.retire') + assert.equal(resumedTask.collaboration.status, 'blocked', 'Confirmed retirement still leaves the next role to Chief') + } + await chief.onResult({ ok: true, text: JSON.stringify({ summary: 'Assess the original allocation and resume the checkpoint', decisions: [{ taskId: task.id, action: 'route', targetBotId: 'p1:developer', reason: 'Continue the preserved draft when actual exit proof permits it' }] }), executionEvidence: { kind: 'native-chief', chiefRunId: chief.runId, carrierGeneration: 1, botId: 'a', sessionId: 'a' } }) + await rt.projectScheduler.wake() + if (allocation === 'unknown-worker') { + assert.equal(dispatched, undefined); assert.equal(resumedTask.collaboration.status, 'blocked') + assert.match(resumedTask.collaboration.block.reason, /confirmed worker exit/) + assert.equal(rt.state.projectResourceLeases['prior-lease'].status, 'unreachable') + const count = Object.keys(rt.state.projectExecutions).length + await rt.projectScheduler.wake(); await rt.projectScheduler.wake() + assert.equal(Object.keys(rt.state.projectExecutions).length, count, 'Unknown exit must not create a Chief notification hot loop') + assert.equal(readFileSync(join(workspace.path, 'code.mjs'), 'utf8'), 'export const add = (a,b) => a+b;\n') + return + } + assert.ok(dispatched, JSON.stringify(rt.state.projectExecutions[chief.runId])) + assert.equal(dispatched.task.id, task.id); assert.equal(dispatched.claim.attemptId, previous.id) + assert.equal(resumedTask.collaboration.status, 'claimed'); assert.equal(resumedTask.collaboration.block, undefined) + assert.equal(taskView(rt.state, resumedTask).reason, null) + assert.ok(['running', 'starting'].includes(taskView(rt.state, resumedTask).phase)) + assert.equal(resumedTask.collaboration.history.at(-1).resolvedBlock.reason.startsWith('Host restarted'), true) + const resumed = rt.state.projectAttempts[previous.id] + assert.equal(resumed.status, 'running'); assert.equal(resumed.workspaces.developer.path, workspace.path) + assert.equal(resumed.roleSessions.developer.logicalSessionId, sessionId) + assert.equal(readFileSync(join(workspace.path, 'code.mjs'), 'utf8'), 'export const add = (a,b) => a+b;\n') + assert.equal(Object.keys(rt.state.projectAttempts).length, 1) + assert.equal(resumedTask.status === 'accepted', false) + } finally { await ctx.dispose() } +}) + test('a code Task cannot be accepted through generic transitions before it has delivery evidence', async t => { const fixture = await setup(t) await assert.rejects(fixture.rt.taskEndpoint('task-update', { id: fixture.task.id, column: 'done' }, alice), /commit-specific/) diff --git a/patches/ds-bot/files/test/project-runtime-setup.test.js b/patches/ds-bot/files/test/project-runtime-setup.test.js index 0cfe0a5b4..587e47a65 100644 --- a/patches/ds-bot/files/test/project-runtime-setup.test.js +++ b/patches/ds-bot/files/test/project-runtime-setup.test.js @@ -161,3 +161,27 @@ test('SDK probe preserves typed refresh-token failure and does not reuse readine const again = await setup.check(body,owner) assert.equal(again.status,'authRequired'); assert.notEqual(again.profile.id,result.profile.id) }) + +test('legacy ready without profile cannot dispatch until an explicit fresh probe binds current identity', async t => { + const { setup, rt, body, root } = await fixture(t) + const { mkdir, writeFile } = await import('node:fs/promises') + const runtime = join(root, 'fixture-runtime'), observed = join(root, 'probe-observed') + await mkdir(join(runtime, 'src/host'), { recursive: true }) + rt.config.projectResources.local.runtimeDir = runtime + const definition = { role: 'developer', execution: { harness: 'codex', model: 'fixture-model', resourceIds: ['local'] } } + rt.projectAgents.deliveryDefinitions = () => ({ roles: [definition] }) + await writeFile(join(runtime, 'src/host/project-harness-worker.mjs'), `import{createInterface}from'node:readline';import{writeFileSync}from'node:fs';const send=x=>process.stdout.write(JSON.stringify(x)+'\\n');send({type:'ready',sdk:{codex:'0.166.0'}});createInterface({input:process.stdin}).on('line',line=>{const m=JSON.parse(line);if(m.type==='start'){writeFileSync(${JSON.stringify(observed)},'fixture probe executed');send({type:'completed'})}if(m.type==='stop'){send({type:'stopped',released:true});process.exit(0)}});`) + rt.state.projectResourceHealth = { local: { status: 'ready', at: Date.now() } } + rt.state.projectHarnessHealth = { 'p:local:codex': { status: 'ready', at: Date.now() } } + assert.equal(rt.projectResources.occupied('local'), 0) + assert.equal(rt.projectResources.harnessHealth('p', 'local', 'codex').status, 'unconfigured') + assert.throws(() => rt.projectResources.plan('p', { roles: [definition] }), /capacity or readiness/) + let wakes = 0; rt.projectScheduler = { wake() { ++wakes } } + const result = await setup.endpoint('project-runtime-check', body, owner) + assert.equal(await readFile(observed, 'utf8'), 'fixture probe executed', 'Recent legacy ready must not bypass the selected profile check') + assert.equal(result.status, 'ready') + assert.equal(result.profile.id, (await rt.projectHarness.profile('p', 'local', 'codex')).profile.id) + assert.equal(rt.state.projectHarnessHealth['p:local:codex'].profile.id, result.profile.id) + assert.equal(rt.projectResources.plan('p', { roles: [definition] })[0].resourceId, 'local') + assert.equal(rt.projectResources.occupied('local'), 0); assert.ok(wakes > 0) +})