Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions PROJECT.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
1 change: 1 addition & 0 deletions changelog.d/2483-restart.fixed.md
Original file line number Diff line number Diff line change
@@ -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.
12 changes: 12 additions & 0 deletions core/src/host/project-chief-control.js

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion core/src/host/project-chief-protocol.js
Original file line number Diff line number Diff line change
@@ -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 })
Expand Down
2 changes: 1 addition & 1 deletion core/src/host/project-delivery.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
84 changes: 84 additions & 0 deletions core/test/project-chief-control.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
Loading
Loading