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
8 changes: 8 additions & 0 deletions .github/scripts/openapi.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -100,3 +100,11 @@ test('Chief assistance API exposes explicit operations scope and bounded compens
assert.throws(() => validate(query.resultSchema, { ...value, policy: { enabled: true, autonomy: { maxRecoveryActions: 21 } } }, spec), /maxRecoveryActions: number out of bounds/)
assert.throws(() => validate(query.resultSchema, { ...value, compensation: [{ ...value.compensation[0], nextCheckAt: 'guessed' }] }, spec), /nextCheckAt:/)
})

test('Chief source/acceptance failures and incomplete correction transport remain machine readable', () => {
const spec = readSpec(), query = operationFor(spec, 'project-development', { kind: 'query', name: 'chief.state' })
const recovery = { version: 'chief-recovery/v1', kind: 'source-changed', retryable: false, conditions: ['fresh source'], actions: ['Chief reassesses'], candidateSha: 'a'.repeat(40) }
const value = { compensation: [], health: { enabled: true, status: 'degraded', lastSuccessAt: null, lastAppliedDecisionAt: null, active: [] }, policy: { enabled: true }, template: {}, schedule: null, runs: [{ id: 'run', projectId: 'project', status: 'failed', recoveryIssue: recovery, correction: { version: 'chief-correction/v1', round: 1, limit: 2, status: 'transport-incomplete', feedback: [] }, lastResult: { ok: false, invocation: 2, source: 'host-dispatch', responseSha256: null, diagnosticSha256: 'b'.repeat(64), receivedAt: 2 }, failureDetail: { invocation: 2, stage: 'dispatch', code: 'bot/chief', responseSha256: null, diagnosticSha256: 'b'.repeat(64), recoveryKind: 'source-changed', criteriaIssues: [], at: 2 } }] }
validate(query.resultSchema, value, spec)
for (const kind of ['input-protocol', 'acceptance-evidence-missing']) { value.runs[0].recoveryIssue.kind = kind; validate(query.resultSchema, value, spec) }
})
2 changes: 2 additions & 0 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#2378 reuses role Apps for bounded PR/CI reads and hash-verified host logs in Reviewer scratch; absence or incomplete pages are not verification success.

#2393 keeps Chief execution faults on their Run, preserves business evidence and returns per-Task/current-snapshot correction; Agent chooses recovery through existing capabilities.


#2375 adds explicit exit-proven claim retirement through existing pools/Effects; preserve drafts, pending results and unknown writes. No cancellation or automatic routing.

Expand Down
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@

#2378: enable explicit Actions reads/download hosts and retrievable exact-SHA test artifacts; real installed-App/private-repository acceptance remains separate.

#2393: precise Run/receipt/snapshot feedback and no replay of applied Task decisions; no automatic role/human routing or new scheduler.


#2375: retire proven exited responsibility before fresh integration admission; source/isolated tests remain separate from live recovery.

Expand Down
1 change: 1 addition & 0 deletions changelog.d/2393.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Keep Chief execution failures separate from Task acceptance, correct scoped decisions independently and refresh stale snapshot feedback without replaying applied work or rebasing receipts.
94 changes: 68 additions & 26 deletions core/src/host/project-chief-control.js

Large diffs are not rendered by default.

42 changes: 25 additions & 17 deletions core/src/host/project-chief-protocol.js
Original file line number Diff line number Diff line change
@@ -1,32 +1,40 @@
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.'
const actions = new Set(['close', 'route', 'return', 'request-tests', 'needs-human', 'continue', 'admit', 'revise', 'cancel', 'reopen', 'resolve-dependency', 'retry-notification'])
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 })
}
export function parseChiefResponse(text, roles) {
export function parseChiefResponse(text, roles, { onInvalid } = {}) {
let value
try { value = JSON.parse(String(text).replace(/^```(?:json)?\s*|\s*```$/g, '')) }
catch { throw chiefProtocolError('response', 'one valid JSON object with summary and decisions') }
if (!value || typeof value !== 'object' || typeof value.summary !== 'string' || value.summary.length > 8000 || !Array.isArray(value.decisions) || value.decisions.length > 30) throw chiefProtocolError('response', 'a bounded summary and an array of at most30 decisions')
const seen = new Set()
for (const decision of value.decisions) {
const taskId = decision?.taskId
if (typeof taskId !== 'string' || !taskId || taskId.length > 100) throw chiefProtocolError('taskId', 'an exact Task ID from the current snapshot')
if (seen.has(taskId)) throw chiefProtocolError('decisions', 'at most one decision per Task in this response', taskId)
seen.add(taskId)
if (!actions.has(decision.action)) throw chiefProtocolError('action', 'a supported Chief action', taskId)
if (typeof decision.reason !== 'string' || !decision.reason.trim() || decision.reason.length > 4000) throw chiefProtocolError('reason', 'bounded nonempty text up to4000 characters', taskId)
if (['route', 'return'].includes(decision.action)) {
if (typeof decision.targetBotId !== 'string' || !decision.targetBotId.trim() || !roles.some(row => row.id === decision.targetBotId && row.role !== 'chief')) throw chiefProtocolError('targetBotId', 'one exact active logical Bot ID from logicalBots, excluding Chief', taskId)
}
if (decision.action === 'needs-human') assistanceRequest(decision)
else if (decision.recipient !== undefined || decision.assistance !== undefined) throw chiefProtocolError('assistance', 'consultation fields only on needs-human', taskId)
if (decision.requirements !== undefined) {
if (!decision.requirements || typeof decision.requirements !== 'object' || Array.isArray(decision.requirements)) throw chiefProtocolError('requirements', 'an object of bounded text requirements', taskId)
for (const key of ['title', 'criteria', 'impact', 'scope']) if (decision.requirements[key] !== undefined && (typeof decision.requirements[key] !== 'string' || !decision.requirements[key].trim() || decision.requirements[key].length > 16000)) throw chiefProtocolError(`requirements.${key}`, 'bounded nonempty text, not an evidence array', taskId)
}
try {
const taskId = decision?.taskId
if (typeof taskId !== 'string' || !taskId || taskId.length > 100) throw chiefProtocolError('taskId', 'an exact Task ID from the current snapshot')
if (seen.has(taskId)) throw chiefProtocolError('decisions', 'at most one decision per Task in this response', taskId)
seen.add(taskId)
if (!actions.has(decision.action)) throw chiefProtocolError('action', 'a supported Chief action', taskId)
if (typeof decision.reason !== 'string' || !decision.reason.trim() || decision.reason.length > 4000) throw chiefProtocolError('reason', 'bounded nonempty text up to4000 characters', taskId)
if (['route', 'return'].includes(decision.action)) {
if (typeof decision.targetBotId !== 'string' || !decision.targetBotId.trim() || !roles.some(row => row.id === decision.targetBotId && row.role !== 'chief')) throw chiefProtocolError('targetBotId', 'one exact active logical Bot ID from logicalBots, excluding Chief', taskId)
}
if (decision.criteria !== undefined) {
if (!Array.isArray(decision.criteria)) throw chiefProtocolError('criteria', 'an evidence array, separate from requirements.criteria text', taskId)
for (const [index, item] of decision.criteria.entries()) {
if (!item || typeof item.criterion !== 'string' || typeof item.passed !== 'boolean' || typeof item.evidence !== 'string') throw chiefProtocolError(`criteria[${index}]`, 'criterion/evidence strings and a passed boolean; never inferred or repaired', taskId)
}
}
if (decision.action === 'needs-human') assistanceRequest(decision)
else if (decision.recipient !== undefined || decision.assistance !== undefined) throw chiefProtocolError('assistance', 'consultation fields only on needs-human', taskId)
if (decision.requirements !== undefined) {
if (!decision.requirements || typeof decision.requirements !== 'object' || Array.isArray(decision.requirements)) throw chiefProtocolError('requirements', 'an object of bounded text requirements', taskId)
for (const key of ['title', 'criteria', 'impact', 'scope']) if (decision.requirements[key] !== undefined && (typeof decision.requirements[key] !== 'string' || !decision.requirements[key].trim() || decision.requirements[key].length > 16000)) throw chiefProtocolError(`requirements.${key}`, 'bounded nonempty text, not an evidence array', taskId)
}
} catch (error) { if (!onInvalid) throw error; onInvalid(decision, error) }
}
if (value.roleProposals !== undefined && (!Array.isArray(value.roleProposals) || value.roleProposals.length > 4)) throw chiefProtocolError('roleProposals', 'a bounded array of at most4 proposals')
return value
Expand Down
2 changes: 2 additions & 0 deletions core/src/host/project-chief-recovery.js
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export function nativeChiefBoundary(state, run) {

export function classifyChiefRecovery(error) {
if (error.recovery) return structuredClone(error.recovery)
const receipt = error.receiptFeedback?.kind ?? error.inputIssue?.kind
if (receipt) return { version: 'chief-recovery/v1', kind: ['stale-snapshot', 'stale-or-foreign-batch'].includes(receipt) ? 'stale-snapshot' : ['foreign-input', 'source-revoked'].includes(receipt) ? 'authority-unavailable' : receipt === 'content-changed' ? 'source-changed' : 'input-protocol', retryable: false, conditions: ['fresh scoped snapshot; retain original input state and receipt history'], actions: ['Chief reassesses current facts; never rebase an old receipt'] }
const message = String(error.message)
const kind = /stale|generation|snapshot.*changed/i.test(message) ? 'stale-snapshot' : /worker.*exit|confirmed.*exit|unreachable/i.test(message) ? 'worker-exit-unknown' : /interrupted delivery|candidate.*missing|matching passing tests/i.test(message) ? 'unsupported-route' : ['bot/forbidden', 'bot/delegation', 'bot/execution-revoked'].includes(error.code) ? 'authority-unavailable' : ['bot/resource', 'bot/busy'].includes(error.code) ? 'resource-unavailable' : 'execution-failed'
return { version: 'chief-recovery/v1', kind, retryable: ['worker-exit-unknown', 'resource-unavailable'].includes(kind), conditions: kind === 'worker-exit-unknown' ? ['matching exit proof; absence of a host controller is insufficient'] : kind === 'stale-snapshot' ? ['fresh scoped snapshot'] : ['inspect current scoped evidence'], actions: kind === 'unsupported-route' ? ['select an evidence-supported role or rework explicitly'] : [] }
Expand Down
10 changes: 6 additions & 4 deletions core/src/host/project-delivery.js
Original file line number Diff line number Diff line change
Expand Up @@ -507,14 +507,15 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti
function integrationBlockers(attempt, permit, retiringClaim) {
const chief = row => row?.kind === 'chief-control' && row.projectId === attempt.projectId
const claim = taskOf(attempt.taskId)?.collaboration?.claim, ownChiefRun = permit?.[chiefInputPermit]
const currentChief = row => ownChiefRun && row.projectId === attempt.projectId && row.role === 'chief' && row.chiefRunId === ownChiefRun && Number.isSafeInteger(row.chiefInvocation) && row.chiefInvocation === state.projectExecutions?.[ownChiefRun]?.invocation
const blockers = []
if (claim?.status === 'active' && claim !== retiringClaim && !(ownChiefRun && claim.chiefRunId === ownChiefRun && claim.botId === `${attempt.projectId}:chief`)) blockers.push({ kind: 'claim-active', claimId: claim.id, action: 'attempt.reconcile-exit after result and exit reconciliation' })
if (claim?.retirement && !rt.projectEffects?.intentCommitted(claim.retirement.effect)) blockers.push({ kind: 'claim-retirement-uncommitted', claimId: claim.id })
for (const row of Object.values(state.projectExecutions ?? {})) if (chief(row) && row.status === 'running' && row.id !== ownChiefRun) blockers.push({ kind: 'chief-running', runId: row.id })
for (const row of Object.values(state.projectResourceLeases ?? {})) if ((row.attemptId === attempt.id || chief(state.projectExecutions?.[row.attemptId])) && (row.status !== 'released' || row.preparation && row.preparation.exitConfirmed === false)) blockers.push({ kind: row.preparation?.exitConfirmed === false ? 'preparation-exit-unknown' : 'lease-not-released', leaseId: row.id })
for (const row of Object.values(state.projectResourceLeases ?? {})) if (!currentChief(row) && (row.attemptId === attempt.id || chief(state.projectExecutions?.[row.chiefRunId ?? row.attemptId])) && (row.status !== 'released' || row.preparation && row.preparation.exitConfirmed === false)) blockers.push({ kind: row.preparation?.exitConfirmed === false ? 'preparation-exit-unknown' : 'lease-not-released', leaseId: row.id })
// Old released Chief turns never owned this workspace. Unknown current workers stay held.
for (const row of Object.values(state.projectHarnessWorkers ?? {})) {
const relevant = !(ownChiefRun && row.role === 'chief' && row.chiefRunId === ownChiefRun) && (row.attemptId === attempt.id || row.projectId === attempt.projectId && row.chiefRunId && (row.status !== 'released' || row.chiefRunId === claim?.chiefRunId))
const relevant = !currentChief(row) && (row.attemptId === attempt.id || row.projectId === attempt.projectId && row.chiefRunId && (row.status !== 'released' || row.chiefRunId === claim?.chiefRunId))
if (relevant && !confirmedWorkerExit(state, row)) blockers.push({ kind: 'worker-exit-unknown', workerId: row.id })
}
for (const row of Object.values(state.bots ?? {})) if (row.ephemeral && row.attemptId === attempt.id) blockers.push({ kind: 'native-worker-present', workerId: row.id })
Expand Down Expand Up @@ -545,7 +546,8 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti
proof = { workerId: record.id, leaseId: record.leaseId, generation: record.generation, releaseProof: record.releaseProof }
}
if (Object.values(state.projectEffects ?? {}).some(row => row.taskId === task.id && row.payload?.attemptId === attempt.id && ['pending', 'running', 'failed'].includes(row.status) && ['github.candidate', 'github.publish', 'github.review', 'github.merge'].includes(row.kind))) throw reject('Reconcile pending external writes before retiring execution responsibility')
integrationReleased(attempt, undefined, claim)
if (actor.chiefJobId) rt.projectChiefControl.requireJobActor(actor, task.projectId)
integrationReleased(attempt, actor.chiefJobId ? { [chiefInputPermit]: actor.chiefJobId } : undefined, claim)
rt.requireProjectAccess(actor, task.projectId, 'configure', task)
if (task.deliveryAttemptId !== attempt.id || attempt.cancelRequested) throw reject('Execution retirement Attempt changed during validation')
return proof
Expand Down Expand Up @@ -1330,7 +1332,7 @@ export function createProjectDelivery(rt, { publisher, releaseProvider, roleArti
}
}
const ownsWorkspace = (projectId, path) => Object.values(state.projectWorkspaces ?? {}).find(workspace => workspace.projectId === projectId && workspace.path === path && DELIVERY_ROLES.includes(workspace.policyRole ?? workspace.role) && ['worktree', 'resource-clone', 'decision-directory'].includes(workspace.kind) && workspace.status === 'allocated' && ongoing.has(ownEntry(attempts(), workspace.attemptId)?.status))
return { releases, recoveryFacts, admitChiefInputs, reconcileExecutions: () => reconcileExecutionAuthority(rt), exitConfirmed: workerId => executionExitConfirmed(rt, workerId), nativeSessions, endpoint, context, admitChildInputs: (taskId, claim) => serialAttempt(claim.attemptId, () => childInputs.admit(taskId, claim)), preparePoolRole, validateRecovery, resumeVerification, abortPoolAdmission, completePoolRole, chiefAccept, reworkPool, recordExecution, repositoryContext, repositoryRolePolicy, bindSession, releaseSession, sessionFor, recover, runReported, ownsWorkspace, gate: input => serialAttempt(current(input.taskId)?.id ?? input.taskId, () => gate(input)) }
return { reconcileExit, releases, recoveryFacts, admitChiefInputs, reconcileExecutions: () => reconcileExecutionAuthority(rt), exitConfirmed: workerId => executionExitConfirmed(rt, workerId), nativeSessions, endpoint, context, admitChildInputs: (taskId, claim) => serialAttempt(claim.attemptId, () => childInputs.admit(taskId, claim)), preparePoolRole, validateRecovery, resumeVerification, abortPoolAdmission, completePoolRole, chiefAccept, reworkPool, recordExecution, repositoryContext, repositoryRolePolicy, bindSession, releaseSession, sessionFor, recover, runReported, ownsWorkspace, gate: input => serialAttempt(current(input.taskId)?.id ?? input.taskId, () => gate(input)) }
}

export function install(rt) {
Expand Down
Loading
Loading