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
13 changes: 5 additions & 8 deletions PROJECT.md
Original file line number Diff line number Diff line change
Expand Up @@ -126,14 +126,7 @@ Earlier M7.2/M7.3 implementation-status checkpoints are retained verbatim in
[the historical record](docs/history/2026-11-03-completed-m7-checkpoints.md). Current
resource/Harness contracts and authorized acceptance boundaries remain below.

### M7.4 — 2026-10-31

M7.4 adds Owner role/persona/Skills/memory/Harness/model/multiple-resource configuration, bounded connection checks and explicitly granted private target-resource PTYs. Credentials stay in private profiles; generated profiles separate Project/principal/Harness. Actual local and new Docker PTYs, hidden setup, current-session revocation and background cleanup are tested. Real authenticated three-Harness acceptance remains pending dedicated credential/profile selection.


### M7.5 — 2026-10-31

M7.5 implements local/Docker Git workspace handoffs, SHA256 manifests, one final sealed candidate without intermediate worker history, independent host-verifier tests, same-SHA review and sealed SDK event evidence. Browser integration fixes runtime/terminal/input channel routing; callback-aware stop acknowledgement prevents release deadlock. See docs/m7-acceptance.md: positive authenticated three-Harness/two-resource PR acceptance still needs dedicated profiles, so M7 is not declared fully accepted.
M7.4/M7.5 implementation checkpoints are [archived verbatim](docs/history/2026-11-03-completed-m7-checkpoints.md). Private credentials, revocation, exit proof, same-SHA verification and outstanding mixed-Harness/resource acceptance remain required.

### M7 Codex-only acceptance authorization — 2026-11-01

Expand Down Expand Up @@ -292,3 +285,7 @@ Keep personal Chief/Bots, groups, private memories and original Task/Workflow ID
### 2026-11-03 — Persistent deployment and final API docs (#2189)

Use scripts/backend.mjs to initialize/start the original DSH/FlyBot host plus existing restricted HTTP/SSE gateway in a private persistent home. Login and CLI calls reuse native/platform identity; no mock model, sample repository, parallel executor or frontend replacement is packaged. Original guides move to README.native variants. Auth-mode confirms Store writability before readiness. External safe proposal impact/scope must survive publication into canonical requirements (actual production-entry regression). Deployment/restart, deterministic HTTP/DSH tests and real-provider acceptance remain separate evidence.

### Initial role routing — #2292

Initial execution pins an approved active logical role/capability, catalog revision and Task requirements revision. Default developer remains compatible; a unique custom developer can replace it, while ambiguity needs an explicit target. Chief start Effects, capacity queues and Attempts retain the choice. Catalog/requirements changes block pending work for explicit recovery; active Session conventions remain frozen.
2 changes: 2 additions & 0 deletions ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -459,3 +459,5 @@ Canonical ongoing role/resource authority checks now reuse native step/SDK input
#2186 adds the original-capability inventory and committed legacy cutover on the existing Store, group/Chief channels and Workflow controller. Validate same-version JSON/SQLite rollback separately from real-provider and UI acceptance; final real backend packaging/README #2189 follows.

#2189 supplies the persistent source init/start/login/API client and final object/API/operations documentation. Verify isolated fresh installation, retained auth/Task receipt across restart, consultation privacy, external safe/watch and SSE revoke; keep real-provider/remote/browser/production rollout evidence separate. Existing service/frontend flows are untouched.

#2292 follow-up: initial-role admission now follows approved capabilities with durable queue/start bindings; repository selection, bounded legacy history, Release evidence and real-loop acceptance remain pending.
1 change: 1 addition & 0 deletions changelog.d/2292-role-admission.fixed.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Route initial Project execution through approved roles and retain the chosen role across queue waits, restart and explicit recovery.
5 changes: 4 additions & 1 deletion core/src/host/project-delivery.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { mkdir, readFile, realpath, rm, writeFile, lstat } from 'node:fs/promise
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path'
import { promisify } from 'node:util'
import { ownEntry, setEntry } from './store.js'
import { initialRoleTarget } from './project-role-admission.js'
import { DELIVERY_ROLES, DELIVERY_VERSION, deliveryGraph } from './project-delivery-template.js'
import { repositorySettings, preflightRepository } from './project-repository-policy.js'
import { createResourceWorkspaces, fileDigest } from './project-resource-workspaces.js'
Expand Down Expand Up @@ -239,14 +240,16 @@ export function createProjectDelivery(rt, { publisher } = {}) {
if (prior) return { attempt: view(prior), already: true }
if (Object.values(attempts()).some(attempt => attempt.taskId === task.id && (ongoing.has(attempt.status) || attempt.status === 'waiting-acceptance'))) throw reject('Task already has an active Attempt')
if (task.status === 'accepted' || task.status === 'cancelled') throw reject('Terminal Task cannot start a delivery')
const initialTarget = initialRoleTarget(rt, task, body)
const settings = await preflightRepository(rt, project)
if (initialTarget) initialRoleTarget(rt, task, { initialTarget })
const previous = current(task.id)
const publicationBinding = await publicationOf(previous, project)
const feedback = previous?.failure ? { previousAttemptId: previous.id, kind: previous.returned ? 'human-return' : 'execution-failure', reason: previous.failure.message, candidateSha: previous.candidateSha ?? null } : null
const at = Date.now()
const attempt = { id: `attempt-${randomUUID()}`, projectId: project.id, taskId: task.id, requestKey,
workflowVersion: DELIVERY_VERSION, status: 'preparing', initiatedBy: actor.memberId, executionOwnerId: project.ownerUserId,
requirement: requirementsOf(task, project.repository),
requirement: requirementsOf(task, project.repository), ...(initialTarget ? { initialTarget } : {}),
...(feedback ? { feedback } : {}), ...(publicationBinding ? { publicationBinding, seedSha: previous.candidateSha ?? previous.seedSha ?? publicationBinding.previousSha, baseSha: previous.baseSha } : {}), workspaces: {}, evidence: {}, createdAt: at, updatedAt: at }
setEntry(attempts(), attempt.id, attempt)
task.deliveryAttemptId = attempt.id
Expand Down
10 changes: 7 additions & 3 deletions core/src/host/project-pools.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Per-logical-Bot routing on existing Task records. Execution remains a host adapter.
import { randomUUID, createHash } from 'node:crypto'
import { ownEntry } from './store.js'
import { initialRoleTarget } from './project-role-admission.js'
import { taskSessionId } from './project-task-sessions.js'
import { stageRoleSummary, rollbackRoleSummary, stageExecutionMemory, rollbackExecutionMemory, roleMemoryView } from './project-context-memory.js'
import { EffectDeferred, EffectCancelled } from './project-effects.js'
Expand Down Expand Up @@ -117,7 +118,7 @@ export function createProjectPools(rt) {
if (!checked.ok) throw refuse(`Handoff refused (${checked.code}): ${checked.reason}`)
return { fromRole, trustedEvidence }
}
async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary, experience }) {
async function offer(task, { from, to, reason, requestId, attemptId, evidence = [], purpose = 'work', expectedGeneration, summary, experience, initialTarget }) {
if (!adapter) throw refuse('Handoff execution adapter is unavailable')
reason = reasonOf(reason)
const c = task.collaboration, target = targetOf(task.projectId, to)
Expand All @@ -129,6 +130,7 @@ export function createProjectPools(rt) {
}
await authority(task)
const { fromRole, trustedEvidence } = await checkAdmission(task, { from, to, attemptId, requestId, purpose })
if (initialTarget) initialRoleTarget(rt, task, { initialTarget })
if (expectedGeneration !== undefined && c.generation !== expectedGeneration) throw refuse('Claim generation changed before handoff admission')
const row = { id: `handoff-${randomUUID()}`, version: POOL_PROTOCOL, policyVersion: 'project-role-policy/v1', definitionRevision: definitions(task.projectId).revision, projectId: task.projectId, taskId: task.id, attemptId, initiatedBy: c.initiatedBy, from, to, reason, requestId, purpose, evidence: clone(trustedEvidence), claims: clone(evidence), requirements: { title: task.title, criteria: task.criteria ?? '' }, status: 'pending', generation: c.generation + 1, createdAt: Date.now() }
const sourceClaim = c.claim?.botId === from ? c.claim : null
Expand Down Expand Up @@ -183,12 +185,14 @@ export function createProjectPools(rt) {
rt.projectScheduler?.wake()
return clone(row)
}
async function admit(attempt, actor, { targetRole = 'developer', kind } = {}) {
async function admit(attempt, actor, { targetRole, initialTarget, kind } = {}) {
const task = taskOf(attempt.taskId)
rt.projectTaskLifecycle?.requireExecutable(task)
if (kind && task.projectTaskKind && kind !== task.projectTaskKind) throw refuse('Task kind cannot change at execution admission')
kind ??= task.projectTaskKind ?? 'code'
rt.requireProjectAccess(actor, task.projectId, 'task-write', task)
const selected = initialRoleTarget(rt, task, { targetRole, initialTarget: initialTarget ?? attempt.initialTarget })
targetRole = selected?.role ?? targetRole ?? 'developer'
const target = definitions(task.projectId).roles.find(row => row.role === targetRole)
if (!target || !['code', 'decision', 'release'].includes(kind)) throw refuse('Unsupported Task target or kind')
task.collaboration ??= { version: POOL_PROTOCOL, initiatedBy: actor.memberId, generation: 0, history: [] }
Expand All @@ -197,7 +201,7 @@ export function createProjectPools(rt) {
c.sourceSessionId = actor.sessionId ?? null
c.initiatedBy = actor.memberId
c.attemptId = attempt.id; c.kind = kind
return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id })
return offer(task, { from: null, to: target.id, reason: 'Authorized Task execution request', requestId: attempt.id, attemptId: attempt.id, initialTarget: selected })
}
async function claim(taskId, botId, handoffId, requestId) {
const task = taskOf(taskId), c = task.collaboration
Expand Down
17 changes: 17 additions & 0 deletions core/src/host/project-role-admission.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Initial routing is a durable choice, separate from an active role Session.
const fail = message => Object.assign(new Error(message), { code: 'bot/handoff' })
export function initialRoleTarget(rt, task, body = {}) {
if (rt.config.projectDispatch === 'workflow' || !rt.projectAgents?.deliveryDefinitions) return undefined
const catalog = rt.projectAgents.deliveryDefinitions(task.projectId)
const revision = task.projectTask?.requirements.revision ?? null
const pinned = body.initialTarget
if (pinned && (pinned.definitionRevision !== catalog.revision || pinned.requirementsRevision !== revision)) throw fail('Initial role catalog or Task requirements changed; explicitly select a target again')
const requested = body.targetRole ?? pinned?.role
if (requested !== undefined && (typeof requested !== 'string' || !/^[a-z][a-z0-9-]{0,31}$/.test(requested))) throw fail('Invalid initial targetRole')
const candidates = catalog.roles.filter(row => (row.policyRole ?? row.role) === 'developer')
const target = requested ? catalog.roles.find(row => row.role === requested) : candidates.find(row => row.role === 'developer') ?? (candidates.length === 1 ? candidates[0] : null)
if (!target || target.role === 'chief') throw fail('An active initial role is required; provide an explicit targetRole when developer capabilities are ambiguous')
const result = { role: target.role, botId: target.id, policyRole: target.policyRole ?? target.role, definitionRevision: catalog.revision, requirementsRevision: revision }
if (pinned && Object.keys(result).some(key => result[key] !== pinned[key])) throw fail('Initial role selection changed; explicitly select a target again')
return result
}
27 changes: 19 additions & 8 deletions core/src/host/project-scheduler.js
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
// Persisted Project admission queue. The existing Workflow service still dispatches Runs.
import { createHash, randomUUID } from 'node:crypto'
import { ownEntry, setEntry } from './store.js'
import { initialRoleTarget } from './project-role-admission.js'
import { preflightRepository } from './project-repository-policy.js'
const ACTIVE = new Set(['queued', 'running', 'working', 'paused', 'legacy'])
const error = message => Object.assign(new Error(message), { code: 'bot/invalid' })
Expand Down Expand Up @@ -46,8 +47,8 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } =
}
return changed
}
function resourceBlock(projectId) {
try { const definitions = rt.projectAgents?.deliveryDefinitions(projectId); rt.projectResources?.plan(projectId, rt.config.projectDispatch === 'workflow' ? definitions : { ...definitions, roles: definitions?.roles.filter(row => row.role === 'developer') }); return null }
function resourceBlock(projectId, target) {
try { const definitions = rt.projectAgents?.deliveryDefinitions(projectId); rt.projectResources?.plan(projectId, rt.config.projectDispatch === 'workflow' ? definitions : { ...definitions, roles: definitions?.roles.filter(row => row.role === (target?.role ?? 'developer')) }); return null }
catch (failure) { return String(failure.message).slice(0, 300) }
}
async function request(body, actor) {
Expand All @@ -67,13 +68,17 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } =
const prior = Object.values(state.projectAttempts ?? {}).find(attempt => attempt.requestKey === requestKey)
if (prior) return { attempt: structuredClone(prior), already: true }
const queued = Object.values(entries()).find(entry => entry.requestKey === requestKey || entry.taskId === task.id && entry.status === 'waiting')
if (queued) return { queue: structuredClone(queued), already: true }
if (queued) {
if (body.targetRole !== undefined && body.targetRole !== queued.initialTarget?.role) throw error('Queued request target cannot change on replay')
return { queue: structuredClone(queued), already: true }
}
if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended')
if (Object.values(state.projectAttempts ?? {}).some(attempt => attempt.taskId === task.id && ['preparing', 'running', 'waiting-acceptance'].includes(attempt.status))) throw error('Task already has an active Attempt')
const resourceReason = resourceBlock(project.id)
if (slots(project.id) < quota(project) && !resourceReason) return rt.projectDelivery.endpoint('project-task-run', body, actor)
const initialTarget = initialRoleTarget(rt, task, body)
const resourceReason = resourceBlock(project.id, initialTarget)
if (slots(project.id) < quota(project) && !resourceReason) return rt.projectDelivery.endpoint('project-task-run', { ...body, ...(initialTarget ? { initialTarget } : {}) }, actor)
const entry = { id: `queue-${randomUUID()}`, projectId: project.id, taskId: task.id, requestId: body.requestId, requestKey,
memberId: actor.memberId, status: 'waiting', ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() }
memberId: actor.memberId, ...(initialTarget ? { initialTarget } : {}), status: 'waiting', ...(resourceReason ? { reason: resourceReason } : {}), enqueuedAt: Date.now(), updatedAt: Date.now() }
setEntry(entries(), entry.id, entry)
await rt.save()
return { queue: structuredClone(entry) }
Expand All @@ -89,13 +94,17 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } =
if (slots(project.id) >= quota(project)) continue
try { await preflight(rt, project) }
catch (failure) { entry.status = 'blocked'; entry.reason = failure.message; entry.updatedAt = Date.now(); changed = true; continue }
const blocked = resourceBlock(project.id)
let initialTarget
try { initialTarget = initialRoleTarget(rt, state.tasks[entry.taskId], { initialTarget: entry.initialTarget }) }
catch (failure) { entry.status = 'blocked'; entry.reason = failure.message; entry.updatedAt = Date.now(); changed = true; continue }
if (initialTarget && !entry.initialTarget) { entry.initialTarget = initialTarget; await rt.save() }
const blocked = resourceBlock(project.id, initialTarget)
if (blocked) { entry.reason = blocked; changed = true; continue }
entry.status = 'starting'
entry.updatedAt = Date.now()
await rt.save()
try {
const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId }, { memberId: entry.memberId })
const result = await rt.projectDelivery.endpoint('project-task-run', { taskId: entry.taskId, requestId: entry.requestId, initialTarget: entry.initialTarget }, { memberId: entry.memberId })
entry.status = result.attempt?.status === 'failed' ? 'failed' : 'started'
entry.attemptId = result.attempt?.id ?? null
if (result.attempt?.failure) entry.reason = result.attempt.failure.message
Expand Down Expand Up @@ -132,7 +141,9 @@ export function createProjectScheduler(rt, { preflight = preflightRepository } =
if (['accepted', 'cancelled'].includes(task.status)) throw error('Task has ended')
if (!rt.projectCan(requester, entry.projectId, 'task-write', task)) throw error('Original requester no longer has Project authority')
if (Object.values(state.projectAttempts ?? {}).some(attempt => attempt.taskId === task.id && ['preparing', 'running', 'waiting-acceptance'].includes(attempt.status))) throw error('Task already has an active Attempt')
const initialTarget = initialRoleTarget(rt, task, { targetRole: body.targetRole ?? entry.initialTarget?.role })
;(entry.recoveries ??= []).push({ by: actor.memberId, previousReason: entry.reason ?? null, at: Date.now() })
if (initialTarget) entry.initialTarget = initialTarget
entry.status = 'waiting'; delete entry.reason; entry.updatedAt = Date.now()
await rt.save()
return { queue: structuredClone(entry) }
Expand Down
Loading
Loading