diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index c261cb5..ae75707 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1022,7 +1022,27 @@ cancelled ``` Items SHOULD identify owner, dependencies, affected domains, acceptance -criteria, evidence, and current commit when applicable. +criteria, evidence, the commit where the issue was observed, and current commit +when applicable. + +The queue is singular across Program Director, domain lead, Session Manager, +and specialist views. Management layers sequence and route this queue; they do +not create parallel backlogs. For a specialist, next-work resolution MUST: + +1. return that specialist's existing active assignment before any ready item; +2. otherwise return the first eligible ready item in deterministic queue order; +3. otherwise report the area idle without inventing work. + +Assignment MUST fail closed when the specialist already owns another active +item. This invariant is serialized across different task files, not merely +protected by per-task revision locks. Blocked work is durable but not runnable; +it may be reconsidered explicitly when its dependency evidence changes. + +Backlog health is read-only and reports exceptional conditions, including +multiple active assignments, stale assigned work, resolved dependencies on a +blocked item, missing or retired areas, old observation commits, ready work +without a live eligible specialist, and active work whose runtime session is +missing. Health never rewrites or reassigns work automatically. Owner decisions affecting future work MUST be recorded durably. Chat messages alone are insufficient. Project source, tests, approved configuration, and @@ -1030,11 +1050,12 @@ accepted decision records outrank summaries and generated prompts. Implementation checkpoint (2026-09-27): backlog items are revisioned JSON records in the Session Manager's tracked worktree. The service and equivalent -CLI/MCP operations enforce transition, owner, dependency, and concurrent-write -rules; publish assignments and blockers through durable messaging; surface -active work in doctor; and require a landed integration record at the same -commit before completion. Keeping management records in the manager worktree -prevents backlog activity from dirtying canonical main. +CLI/MCP operations enforce transition, owner, dependency, one-active-item, and +concurrent-write rules; resolve assigned work before ready work; publish +assignments and blockers through durable messaging; surface active work and +health anomalies in doctor; and require a landed integration record at the +same commit before completion. Keeping management records in the manager +worktree prevents backlog activity from dirtying canonical main. ## 18. Checks and evidence diff --git a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml index 1a5f5fb..3897028 100644 --- a/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml +++ b/reports/test-audit/test-integrity-note-2026-09-27-portable-kernel.yaml @@ -85,8 +85,8 @@ test_integrity_note: then: each operation observes the same durable control-plane state - id: SCN-cli-backlog given: an installed fleet with Session Manager and worker worktrees - when: public CLI commands create, ready, assign, and list a tracked task - then: revisioned backlog state changes in the manager worktree and the assigned owner is visible through the same service + when: public CLI commands create, ready, assign, resolve next work, inspect health, and list a tracked task + then: revisioned backlog state changes in the manager worktree, next resumes the assignment before ready work, and health reports anomalies without mutation - id: SCN-cli-self-host given: two isolated candidate artifacts with complete named acceptance evidence when: an owner plans, builds, activates, upgrades, and rolls back through the CLI @@ -121,8 +121,12 @@ test_integrity_note: then: provider-specific commands and runtime IDs remain separate, every create and resume launch receives identity-bound TORCH MCP access, the manager starts last, Codex completes idle, and only resident processes are stopped - id: SCN-backlog-lifecycle given: tracked tasks in the Session Manager worktree with domain owners, dependencies, and an integration lookup - when: tasks move through assignment, work, blocking, verification, readiness, landing, and completion - then: authority and optimistic revisions are enforced, concurrent locks fail closed, dependencies gate assignment, doctor observes active work, and completion requires the same landed commit + when: tasks move through assignment, restart-aware next-work resolution, work, blocking, verification, readiness, landing, and completion + then: authority and optimistic revisions are enforced, assignment locks prevent multiple active items per specialist, existing work outranks ready work, health is read-only, dependencies gate assignment, doctor observes active work, and completion requires the same landed commit + - id: SCN-backlog-health + given: a queue with duplicate active ownership, stale work, resolved blocked dependencies, retired areas, old observation commits, and missing specialist sessions + when: backlog health inspects the queue + then: every unusual condition is reported with no task mutation or automatic repair - id: SCN-candidate-isolation given: an inactive candidate in a namespace separate from project and stable runtime state when: acceptance is incomplete, complete, or the accepted files are later modified @@ -309,6 +313,9 @@ test_integrity_note: - messages or handoffs silently granting authority - workers creating tasks, changing owners, or completing work without a landed matching commit - concurrent backlog writers silently overwriting a newer task revision + - restarted specialists skipping assigned work for a newer ready item + - different task files bypassing the one-active-assignment invariant + - backlog health silently rewriting or reassigning anomalous work - management backlog files dirtying canonical main instead of the manager worktree - repository content redirecting machine-local database writes - MCP and CLI drifting onto separate business logic diff --git a/src/backlog/service.mjs b/src/backlog/service.mjs index 5384a50..dd14b4d 100644 --- a/src/backlog/service.mjs +++ b/src/backlog/service.mjs @@ -1,4 +1,5 @@ import { randomUUID } from 'node:crypto'; +import { execFileSync } from 'node:child_process'; import { closeSync, existsSync, openSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync, writeSync, } from 'node:fs'; @@ -25,6 +26,7 @@ const TRANSITIONS = new Map([ const PRIORITIES = new Set(['urgent', 'high', 'normal', 'low']); const WORKER_TRANSITIONS = new Set(['in_progress', 'blocked', 'verification', 'ready_to_integrate']); +const ACTIVE_ASSIGNMENT_STATES = new Set(['assigned', 'in_progress', 'verification', 'ready_to_integrate']); function requiredText(value, name) { if (typeof value !== 'string' || !value.trim()) { @@ -52,6 +54,137 @@ function taskId(value) { return id; } +function optionalObservedAt(value) { + if (value === undefined || value === null || value === '') return null; + const observedAt = requiredText(value, 'observedAt'); + if (!/^[0-9a-f]{7,64}$/i.test(observedAt)) { + throw new TorchError('observedAt must be a Git commit SHA', { + code: 'INVALID_BACKLOG_INPUT', details: { field: 'observedAt' }, + }); + } + return observedAt; +} + +function headSha(repositoryRoot) { + try { + return execFileSync('git', ['-C', repositoryRoot, 'rev-parse', '--short=12', 'HEAD'], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim() || null; + } catch { + return null; + } +} + +export function backlogObservedCommitDistance(repositoryRoot, observedAt) { + try { + const value = execFileSync('git', ['-C', repositoryRoot, 'rev-list', '--count', `${observedAt}..HEAD`], { + encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + return Number.isInteger(Number(value)) ? Number(value) : null; + } catch { + return null; + } +} + +export function assessBacklogHealth({ + tasks, agents = [], now = new Date(), staleAfterDays = 7, staleObservedCommits = 50, + commitDistance = () => null, +} = {}) { + const assessedAt = now instanceof Date ? now : new Date(now); + if (!Number.isFinite(assessedAt.getTime())) { + throw new TorchError('Backlog health requires a valid assessment time', { code: 'INVALID_BACKLOG_INPUT' }); + } + if (!Number.isInteger(staleAfterDays) || staleAfterDays < 0 + || !Number.isInteger(staleObservedCommits) || staleObservedCommits < 0) { + throw new TorchError('Backlog health thresholds must be non-negative integers', { + code: 'INVALID_BACKLOG_INPUT', + }); + } + const items = Array.isArray(tasks) ? tasks : []; + const agentById = new Map(agents.map((agent) => [agent.areaId ?? agent.area_id, agent])); + const taskById = new Map(items.map((task) => [task.id, task])); + const findings = []; + const activeByOwner = new Map(); + + for (const task of items) { + if (task.owner && ACTIVE_ASSIGNMENT_STATES.has(task.state)) { + const owned = activeByOwner.get(task.owner) ?? []; + owned.push(task); + activeByOwner.set(task.owner, owned); + } + } + for (const [owner, owned] of activeByOwner) { + if (owned.length > 1) findings.push({ + severity: 'error', code: 'BACKLOG_MULTIPLE_ACTIVE_ASSIGNMENTS', owner, + tasks: owned.map((task) => ({ id: task.id, state: task.state })), + recommendation: 'Keep one active assignment for this specialist and deliberately requeue the others.', + }); + } + + for (const task of items) { + const referencedAreas = [...new Set([task.owner, ...(task.affectedDomains ?? [])].filter(Boolean))]; + const unknownAreas = referencedAreas.filter((areaId) => !agentById.has(areaId)); + if (unknownAreas.length) findings.push({ + severity: 'error', code: 'BACKLOG_AREA_UNKNOWN', taskId: task.id, areas: unknownAreas, + recommendation: 'Route the task to a defined Fleet identity or restore the retired identity before assignment.', + }); + + if (task.state === 'assigned') { + const updatedAt = Date.parse(task.updatedAt); + const ageDays = Number.isFinite(updatedAt) + ? Math.floor(Math.max(0, assessedAt.getTime() - updatedAt) / 86_400_000) : null; + if (ageDays === null || ageDays >= staleAfterDays) findings.push({ + severity: 'warning', code: 'BACKLOG_ASSIGNED_STALE', taskId: task.id, owner: task.owner, ageDays, + recommendation: 'Confirm the specialist is still working this assignment before routing more work.', + }); + } + + if (task.owner && ACTIVE_ASSIGNMENT_STATES.has(task.state)) { + const agent = agentById.get(task.owner); + if (agent && (agent.state === 'offline' || !agent.runtimeSessionId && !agent.runtime_session_id)) findings.push({ + severity: 'warning', code: 'BACKLOG_ASSIGNED_SESSION_MISSING', taskId: task.id, owner: task.owner, + recommendation: 'Resume the owning specialist or explicitly requeue the task; do not silently assign a second item.', + }); + } + + if (task.state === 'blocked' && (task.dependencies ?? []).length > 0 + && task.dependencies.every((dependency) => taskById.get(dependency)?.state === 'completed')) { + findings.push({ + severity: 'warning', code: 'BACKLOG_BLOCKED_DEPENDENCIES_RESOLVED', taskId: task.id, + dependencies: task.dependencies, + recommendation: 'Re-check the blocker; all recorded dependencies are now complete.', + }); + } + + if (task.state === 'ready' && (task.affectedDomains ?? []).length > 0) { + const defined = task.affectedDomains.map((areaId) => agentById.get(areaId)).filter(Boolean); + if (defined.length > 0 && defined.every((agent) => agent.state === 'offline')) findings.push({ + severity: 'warning', code: 'BACKLOG_READY_NO_LIVE_SPECIALIST', taskId: task.id, + areas: defined.map((agent) => agent.areaId ?? agent.area_id), + recommendation: 'Start or restore an eligible specialist when this ready work should be dispatched.', + }); + } + + if (task.observedAt) { + const distance = commitDistance(task.observedAt); + if (distance === null) findings.push({ + severity: 'warning', code: 'BACKLOG_OBSERVED_COMMIT_MISSING', taskId: task.id, observedAt: task.observedAt, + recommendation: 'Re-observe the task against a reachable commit before implementation.', + }); + else if (distance >= staleObservedCommits) findings.push({ + severity: 'warning', code: 'BACKLOG_OBSERVED_COMMIT_STALE', taskId: task.id, + observedAt: task.observedAt, commitsBehind: distance, + recommendation: 'Verify the issue still reproduces before changing code.', + }); + } + } + + return { + schema: 'torch.dev/backlog-health/v1alpha1', assessedAt: assessedAt.toISOString(), + healthy: findings.length === 0, findings, mutationPerformed: false, + }; +} + function parseTask(path) { let task; try { task = JSON.parse(readFileSync(path, 'utf8')); } catch (error) { @@ -131,6 +264,26 @@ export class BacklogService { } } + #withAssignmentLock(callback) { + const path = join(this.controlPlane.stateRoot, 'locks', 'backlog-assignment.lock'); + let descriptor; + try { + descriptor = openSync(path, 'wx', 0o600); + writeSync(descriptor, `${JSON.stringify({ createdAt: this.clock().toISOString() })}\n`); + } catch (error) { + if (error.code === 'EEXIST') { + throw new TorchError('Another backlog assignment is already being evaluated', { + code: 'BACKLOG_ASSIGNMENT_LOCKED', details: { lockPath: path }, + }); + } + throw error; + } + try { return callback(); } finally { + closeSync(descriptor); + unlinkSync(path); + } + } + get(id) { const path = this.#path(id); if (!existsSync(path)) throw new TorchError(`Unknown backlog task: ${id}`, { code: 'BACKLOG_TASK_NOT_FOUND' }); @@ -149,9 +302,47 @@ export class BacklogService { .sort((left, right) => left.createdAt.localeCompare(right.createdAt) || left.id.localeCompare(right.id)); } + next({ areaId } = {}) { + const area = this.controlPlane.assertIdentity(areaId); + if (area === 'session-manager') { + throw new TorchError('Session Manager does not consume the specialist implementation queue', { + code: 'BACKLOG_OWNER_INVALID', details: { areaId: area }, + }); + } + const tasks = this.list(); + const active = tasks.filter((task) => task.owner === area && ACTIVE_ASSIGNMENT_STATES.has(task.state)); + if (active.length > 1) { + throw new TorchError(`${area} has multiple active backlog assignments`, { + code: 'BACKLOG_MULTIPLE_ACTIVE_ASSIGNMENTS', + details: { areaId: area, tasks: active.map((task) => ({ id: task.id, state: task.state })) }, + }); + } + if (active.length === 1) { + return { + schema: 'torch.dev/backlog-next/v1alpha1', areaId: area, disposition: 'resume', + task: active[0], mutationPerformed: false, + }; + } + const ready = tasks.find((task) => task.state === 'ready' + && (!task.affectedDomains.length || task.affectedDomains.includes(area)) + && task.dependencies.every((dependency) => tasks.find((item) => item.id === dependency)?.state === 'completed')); + return { + schema: 'torch.dev/backlog-next/v1alpha1', areaId: area, + disposition: ready ? 'ready' : 'idle', task: ready ?? null, mutationPerformed: false, + }; + } + + health({ staleAfterDays = 7, staleObservedCommits = 50, now = this.clock() } = {}) { + return assessBacklogHealth({ + tasks: this.list(), agents: this.controlPlane.listAgents(), now, + staleAfterDays, staleObservedCommits, + commitDistance: (observedAt) => backlogObservedCommitDistance(this.repositoryRoot, observedAt), + }); + } + create({ actorId, title, description, priority = 'normal', affectedDomains = [], - dependencies = [], acceptanceCriteria, + dependencies = [], acceptanceCriteria, observedAt, } = {}) { this.#assertMutable(); const actor = this.controlPlane.assertIdentity(actorId); @@ -175,6 +366,7 @@ export class BacklogService { state: 'proposed', owner: null, affectedDomains: domains, dependencies: dependencyIds, acceptanceCriteria: textList(acceptanceCriteria, 'acceptanceCriteria', { required: true }), evidence: [], commit: null, integrationRequest: null, blockedReason: null, + observedAt: optionalObservedAt(observedAt) ?? headSha(this.repositoryRoot), createdAt: now, updatedAt: now, revision: 1, history: [{ from: null, to: 'proposed', actorId: actor, at: now, note: 'Task created.' }], }; @@ -190,7 +382,11 @@ export class BacklogService { transition(input = {}) { this.#assertMutable(); - return this.#withTaskLock(input.taskId, () => this.#applyTransition(input)); + return this.#withTaskLock(input.taskId, () => ( + input.to === 'assigned' + ? this.#withAssignmentLock(() => this.#applyTransition(input)) + : this.#applyTransition(input) + )); } #applyTransition({ @@ -243,6 +439,14 @@ export class BacklogService { }); } } + const existing = this.list().find((candidate) => candidate.id !== task.id + && candidate.owner === assigned && ACTIVE_ASSIGNMENT_STATES.has(candidate.state)); + if (existing) { + throw new TorchError(`${assigned} already has active backlog work`, { + code: 'BACKLOG_OWNER_BUSY', + details: { owner: assigned, taskId: existing.id, state: existing.state }, + }); + } } const nextEvidence = [...new Set([...task.evidence, ...textList(evidence, 'evidence')])]; const nextCommit = commit ?? task.commit; diff --git a/src/cli.mjs b/src/cli.mjs index 0c4ac5d..2d6ee2a 100644 --- a/src/cli.mjs +++ b/src/cli.mjs @@ -76,8 +76,10 @@ Usage: torch coordinate --from --body [--with ] [--task ] [--path ] [--json] torch handoff --from [--to ] (--path | --task ) --reason [--json] torch backlog list [--state ] [--owner ] [--json] + torch backlog next --area [--json] + torch backlog health [--stale-days ] [--stale-observed-commits ] [--json] torch backlog get --task [--json] - torch backlog create --area --title --description --accept [--priority ] [--domains ] [--depends ] [--json] + torch backlog create --area --title --description --accept [--priority ] [--domains ] [--depends ] [--observed-at ] [--json] torch backlog transition --task --area --state --revision [--owner ] [--evidence ] [--commit ] [--integration ] [--reason ] [--note ] [--json] torch checks list [--json] torch checks receipts [--commit ] [--id ] [--area ] [--json] @@ -813,6 +815,15 @@ export async function runCli(argv = process.argv.slice(2), { if (operation === 'list') print({ tasks: backlog.list({ state: optionValue(argv, '--state'), owner: optionValue(argv, '--owner'), }) }, { json }); + else if (operation === 'next') print(backlog.next({ + areaId: optionValue(argv, '--area'), + }), { json }); + else if (operation === 'health') print(backlog.health({ + staleAfterDays: optionValue(argv, '--stale-days') === undefined + ? 7 : Number(optionValue(argv, '--stale-days')), + staleObservedCommits: optionValue(argv, '--stale-observed-commits') === undefined + ? 50 : Number(optionValue(argv, '--stale-observed-commits')), + }), { json }); else if (operation === 'get') print(backlog.get(optionValue(argv, '--task')), { json }); else if (operation === 'create') print(backlog.create({ actorId: optionValue(argv, '--area'), title: optionValue(argv, '--title'), @@ -820,6 +831,7 @@ export async function runCli(argv = process.argv.slice(2), { acceptanceCriteria: commaList(optionValue(argv, '--accept')), affectedDomains: commaList(optionValue(argv, '--domains')), dependencies: commaList(optionValue(argv, '--depends')), + observedAt: optionValue(argv, '--observed-at'), }), { json }); else if (operation === 'transition') print(backlog.transition({ taskId: optionValue(argv, '--task'), actorId: optionValue(argv, '--area'), diff --git a/src/kernel/doctor.mjs b/src/kernel/doctor.mjs index df20b81..fcc4dc9 100644 --- a/src/kernel/doctor.mjs +++ b/src/kernel/doctor.mjs @@ -9,6 +9,7 @@ import { forgeStatus } from '../forge/service.mjs'; import { classifyRecoverability } from '../canonical/local.mjs'; import { createClaudeAdapter } from '../adapters/claude.mjs'; import { createCodexAdapter } from '../adapters/codex.mjs'; +import { assessBacklogHealth, backlogObservedCommitDistance } from '../backlog/service.mjs'; function parseJsonYaml(path) { return JSON.parse(readFileSync(path, 'utf8')); @@ -19,6 +20,7 @@ export function diagnoseProject({ repository, env = process.env, now = () => new const configPath = join(trackedRoot, 'torch.yaml'); const manifestPath = join(trackedRoot, 'install-manifest.json'); const findings = []; + let identityRows = []; if (!existsSync(configPath)) { return { status: 'not-installed', healthy: false, findings: [{ severity: 'error', code: 'CONFIG_MISSING', path: configPath }] }; @@ -78,10 +80,11 @@ export function diagnoseProject({ repository, env = process.env, now = () => new LEFT JOIN message_acks a ON a.message_id = m.id AND a.area_id = i.area_id WHERE a.message_id IS NULL `).get().count; - const present = database.prepare(` + identityRows = database.prepare(` SELECT area_id, state, runtime, runtime_session_id, heartbeat_at - FROM identities WHERE state != 'offline' ORDER BY area_id + FROM identities ORDER BY area_id `).all(); + const present = identityRows.filter((identity) => identity.state !== 'offline'); if (unacknowledged > 0) { findings.push({ severity: 'warning', code: 'MESSAGE_BACKLOG', unacknowledged }); } @@ -167,19 +170,33 @@ export function diagnoseProject({ repository, env = process.env, now = () => new try { const task = parseJsonYaml(join(backlogRoot, name)); if (task.schema !== 'torch.dev/backlog-item/v1alpha1' || !task.id || !task.state) throw new Error('invalid task schema'); - if (!['completed', 'cancelled'].includes(task.state)) { - tasks.push({ id: task.id, state: task.state, owner: task.owner, revision: task.revision }); - } + tasks.push(task); } catch (error) { findings.push({ severity: 'error', code: 'BACKLOG_TASK_INVALID', path: join(backlogRoot, name), message: error.message, }); } } - if (tasks.length) findings.push({ - severity: tasks.some((task) => task.state === 'blocked') ? 'warning' : 'info', - code: 'BACKLOG_ACTIVITY', tasks, + const activeTasks = tasks.filter((task) => !['completed', 'cancelled'].includes(task.state)); + if (activeTasks.length) findings.push({ + severity: activeTasks.some((task) => task.state === 'blocked') ? 'warning' : 'info', + code: 'BACKLOG_ACTIVITY', tasks: activeTasks.map((task) => ({ + id: task.id, state: task.state, owner: task.owner, revision: task.revision, + })), }); + try { + const roster = parseJsonYaml(join(trackedRoot, 'roster.yaml')); + const presenceByArea = new Map(identityRows.map((identity) => [identity.area_id, identity])); + const agents = (roster.areas ?? []).map((area) => ({ + ...area, areaId: area.id, ...(presenceByArea.get(area.id) ?? { state: 'offline' }), + })); + findings.push(...assessBacklogHealth({ + tasks, agents, now: now(), + commitDistance: (observedAt) => backlogObservedCommitDistance(repository.root, observedAt), + }).findings); + } catch (error) { + findings.push({ severity: 'error', code: 'BACKLOG_HEALTH_INVALID', message: error.message }); + } } } } diff --git a/src/kernel/install.mjs b/src/kernel/install.mjs index 8a84d7d..47a0103 100644 --- a/src/kernel/install.mjs +++ b/src/kernel/install.mjs @@ -133,7 +133,7 @@ function initialFiles({ repository, projectId, createdAt, proposal, runtimes }) const managerPrompt = `${promptContent( proposal.session_manager?.prompt_source, '# TORCH Session Manager\n\nRoute owner requests, establish ownership and priority, and keep routine coordination inside the fleet.\n', - ).trimEnd()}\n\n## Fleet evolution\n\nAt startup, after backlog intake, and when repeated handoffs or cross-domain work appear, call \`torch_assess_fleet_evolution\`. Treat its threshold as a prompt for architectural judgment, not as an automatic decision. When recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, inspect repository evidence and use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, activate, or start a persistent identity yourself; owner approval, CLI activation, and quota-consuming runtime start are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`; + ).trimEnd()}\n\n## Backlog loop\n\nKeep one durable Fleet backlog. Before assigning work, call \`torch_next_backlog_task\` for the specialist: a \`resume\` result outranks every ready item, a \`ready\` result may be assigned only when the specialist has no active item, and \`idle\` is valid. After completion, repeat the same lookup. Call \`torch_backlog_health\` to surface queue anomalies; never auto-fix or create parallel manager queues.\n\n## Fleet evolution\n\nAt startup, after backlog intake, and when repeated handoffs or cross-domain work appear, call \`torch_assess_fleet_evolution\`. Treat its threshold as a prompt for architectural judgment, not as an automatic decision. When recurring work has no coherent owner, or a durable specialist would materially improve context locality, ownership clarity, or verification, inspect repository evidence and use \`torch_propose_domain\` to submit an evidence-backed Fleet change. When a specialist no longer earns its coordination cost, use \`torch_propose_domain_retirement\`; retirement preserves its branch and refuses active or unrecoverable work. Use \`torch_list_fleet_changes\` to follow change state. Never create, retire, approve, activate, or start a persistent identity yourself; owner approval, CLI activation, and quota-consuming runtime start are separate. Recommend a merge or split for owner review when boundaries should change but do not silently rewrite ownership.\n`; const files = new Map([ ['torch.yaml', jsonYaml(config)], ['roster.yaml', jsonYaml(roster)], diff --git a/src/kernel/prompts.mjs b/src/kernel/prompts.mjs index 2cc9ff7..f7f5af1 100644 --- a/src/kernel/prompts.mjs +++ b/src/kernel/prompts.mjs @@ -52,11 +52,14 @@ export function renderDomainPrompt(domain) { '', '## Initial backlog', '', - '- No task is implied by session creation. Read the tracked backlog and accept only an explicit assignment.', + '- No task is implied by session creation. Call `torch_next_backlog_task` for this area before choosing work.', + '- A `resume` result is the only current assignment. Continue it before considering any `ready` item.', + '- A `ready` result is a candidate, not an assignment; request Session Manager assignment before implementation.', + '- An `idle` result is valid. Do not invent filler work.', '', '## First move', '', - 'Query live identity and ownership, read the assigned backlog item and inbox, then report current repository evidence to the Session Manager.', + 'Query live identity and ownership, call `torch_next_backlog_task`, read the inbox, then report current repository evidence to the Session Manager.', '', ].join('\n'); } diff --git a/src/mcp/tools.mjs b/src/mcp/tools.mjs index 343479a..5aa7391 100644 --- a/src/mcp/tools.mjs +++ b/src/mcp/tools.mjs @@ -29,6 +29,8 @@ export const TORCH_CORE_MCP_TOOL_NAMES = Object.freeze([ export const TORCH_MCP_TOOL_NAMES = Object.freeze([ ...TORCH_CORE_MCP_TOOL_NAMES, 'torch_list_backlog', + 'torch_next_backlog_task', + 'torch_backlog_health', 'torch_get_backlog_task', 'torch_create_backlog_task', 'torch_transition_backlog_task', @@ -199,6 +201,29 @@ export function createTorchToolset(controlPlane, { schema: { state: z.string().min(1).optional(), owner: z.string().min(1).optional() }, invoke: (input) => ({ tasks: backlogService.list(input) }), }); + tools.set('torch_next_backlog_task', { + description: 'Resume this specialist\'s active assignment before returning its first eligible ready task. Read-only.', + schema: { area_id: z.string().min(1).optional() }, + invoke: ({ area_id: requestedArea }) => { + const bound = controlPlane.assertIdentity(actorId); + const areaId = requestedArea ?? bound; + if (bound !== 'session-manager' && areaId !== bound) { + throw new TorchError('A specialist may only inspect its own next backlog item', { + code: 'BACKLOG_AUTHORITY_REQUIRED', details: { actorId: bound, areaId }, + }); + } + return backlogService.next({ areaId }); + }, + }); + tools.set('torch_backlog_health', { + description: 'Report unusual backlog conditions without changing any task.', + schema: { + stale_after_days: z.number().int().min(0).optional(), + stale_observed_commits: z.number().int().min(0).optional(), + }, + invoke: ({ stale_after_days: staleAfterDays, stale_observed_commits: staleObservedCommits }) => + backlogService.health({ staleAfterDays, staleObservedCommits }), + }); tools.set('torch_get_backlog_task', { description: 'Read one durable tracked backlog task and its transition history.', schema: { task_id: z.string().min(1) }, @@ -212,12 +237,13 @@ export function createTorchToolset(controlPlane, { affected_domains: z.array(z.string().min(1)).optional(), dependencies: z.array(z.string().min(1)).optional(), acceptance_criteria: z.array(z.string().min(1)).min(1), + observed_at: z.string().min(1).optional(), }, invoke: ({ actor_id: actorIdClaim, affected_domains: affectedDomains, - acceptance_criteria: acceptanceCriteria, ...input + acceptance_criteria: acceptanceCriteria, observed_at: observedAt, ...input }) => backlogService.create({ - ...input, affectedDomains, acceptanceCriteria, + ...input, affectedDomains, acceptanceCriteria, observedAt, actorId: claimedIdentity(controlPlane, actorId, actorIdClaim, 'actor_id'), }), }); diff --git a/test/fleet/backlog.test.mjs b/test/fleet/backlog.test.mjs index df1fe42..dc976a2 100644 --- a/test/fleet/backlog.test.mjs +++ b/test/fleet/backlog.test.mjs @@ -4,7 +4,7 @@ import { mkdtempSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import test from 'node:test'; -import { createBacklogService } from '../../src/backlog/service.mjs'; +import { assessBacklogHealth, createBacklogService } from '../../src/backlog/service.mjs'; import { openControlPlane } from '../../src/control-plane/service.mjs'; import { analyzeRepository } from '../../src/kernel/analyze.mjs'; import { diagnoseProject } from '../../src/kernel/doctor.mjs'; @@ -37,6 +37,44 @@ function fixture() { return { root, env, worker: proposal.domains[0].id, mainStatus }; } +test('SCN-backlog-health: unusual queue conditions are reported without repair', () => { + const base = { + schema: 'torch.dev/backlog-item/v1alpha1', priority: 'normal', affectedDomains: ['runtime'], + dependencies: [], acceptanceCriteria: ['Observed.'], evidence: [], commit: null, + integrationRequest: null, blockedReason: null, createdAt: '2026-09-01T00:00:00Z', + updatedAt: '2026-09-01T00:00:00Z', revision: 1, history: [], observedAt: '1111111', + }; + const tasks = [ + { ...base, id: 'TASK-active-1', title: 'One', description: 'One', state: 'assigned', owner: 'runtime' }, + { ...base, id: 'TASK-active-2', title: 'Two', description: 'Two', state: 'in_progress', owner: 'runtime' }, + { ...base, id: 'TASK-dependency', title: 'Done', description: 'Done', state: 'completed', owner: 'runtime' }, + { + ...base, id: 'TASK-blocked', title: 'Blocked', description: 'Blocked', state: 'blocked', owner: 'runtime', + dependencies: ['TASK-dependency'], blockedReason: 'Waiting on dependency.', + }, + { ...base, id: 'TASK-ready', title: 'Ready', description: 'Ready', state: 'ready', owner: null }, + { + ...base, id: 'TASK-retired', title: 'Retired', description: 'Retired', state: 'ready', owner: null, + affectedDomains: ['retired-area'], + }, + ]; + const health = assessBacklogHealth({ + tasks, now: new Date('2026-09-27T00:00:00Z'), staleAfterDays: 7, staleObservedCommits: 10, + agents: [{ areaId: 'runtime', state: 'offline', runtimeSessionId: null }], + commitDistance: () => 60, + }); + const codes = new Set(health.findings.map((finding) => finding.code)); + assert.deepEqual([...[ + 'BACKLOG_MULTIPLE_ACTIVE_ASSIGNMENTS', 'BACKLOG_ASSIGNED_STALE', + 'BACKLOG_ASSIGNED_SESSION_MISSING', 'BACKLOG_BLOCKED_DEPENDENCIES_RESOLVED', + 'BACKLOG_READY_NO_LIVE_SPECIALIST', 'BACKLOG_AREA_UNKNOWN', + 'BACKLOG_OBSERVED_COMMIT_STALE', + ]].filter((code) => !codes.has(code)), []); + assert.equal(health.healthy, false); + assert.equal(health.mutationPerformed, false); + assert.equal(tasks[0].state, 'assigned'); +}); + test('SCN-backlog-lifecycle: tracked tasks enforce authority, revisions, dependencies, evidence, and landed completion', () => { const context = fixture(); const control = openControlPlane({ repositoryRoot: context.root, env: context.env }); @@ -52,6 +90,10 @@ test('SCN-backlog-lifecycle: tracked tasks enforce authority, revisions, depende actorId: 'session-manager', title: 'Implement lifecycle', description: 'Build and verify it.', affectedDomains: [context.worker], acceptanceCriteria: ['Exact test passes.'], }); + const alternate = backlog.create({ + actorId: 'session-manager', title: 'Follow-up lifecycle', description: 'Wait behind active work.', + affectedDomains: [context.worker], acceptanceCriteria: ['Runs only after current work.'], + }); const dependent = backlog.create({ actorId: 'session-manager', title: 'Use lifecycle', description: 'Consume the completed work.', affectedDomains: [context.worker], dependencies: [first.id], acceptanceCriteria: ['Dependency is complete.'], @@ -63,6 +105,26 @@ test('SCN-backlog-lifecycle: tracked tasks enforce authority, revisions, depende taskId: first.id, actorId: 'session-manager', to: 'assigned', expectedRevision: task.revision, owner: context.worker, }); + let alternateTask = backlog.transition({ + taskId: alternate.id, actorId: 'session-manager', to: 'ready', expectedRevision: alternate.revision, + }); + const nextWhileAssigned = backlog.next({ areaId: context.worker }); + assert.equal(nextWhileAssigned.disposition, 'resume'); + assert.equal(nextWhileAssigned.task.id, first.id, 'assigned work must outrank ready work after restart'); + assert.equal(nextWhileAssigned.mutationPerformed, false); + assert.throws( + () => backlog.transition({ + taskId: alternate.id, actorId: 'session-manager', to: 'assigned', + expectedRevision: alternateTask.revision, owner: context.worker, + }), + (error) => error.code === 'BACKLOG_OWNER_BUSY' && error.details.taskId === first.id, + ); + const unhealthy = backlog.health({ staleAfterDays: 0 }); + assert.equal(unhealthy.mutationPerformed, false); + assert.equal(unhealthy.findings.some((finding) => + finding.code === 'BACKLOG_ASSIGNED_STALE' && finding.taskId === first.id), true); + assert.equal(unhealthy.findings.some((finding) => + finding.code === 'BACKLOG_ASSIGNED_SESSION_MISSING' && finding.taskId === first.id), true); assert.equal(control.readMessages({ recipient: context.worker }).at(-1).references.task, first.id); assert.throws( () => backlog.transition({ @@ -121,6 +183,12 @@ test('SCN-backlog-lifecycle: tracked tasks enforce authority, revisions, depende const diagnosis = diagnoseProject({ repository: inspectRepository(context.root), env: context.env }); assert.equal(diagnosis.findings.some((finding) => finding.code === 'BACKLOG_ACTIVITY' && finding.tasks.some((item) => item.id === dependent.id)), true); + assert.equal( + diagnosis.findings.some((finding) => + finding.code === 'BACKLOG_ASSIGNED_SESSION_MISSING' && finding.taskId === first.id), + true, + 'doctor must expose backlog health anomalies without a separate queue', + ); integrations.set('integration-1', { state: 'landed', sourceCommit: '1111111111111111111111111111111111111111', }); @@ -128,6 +196,9 @@ test('SCN-backlog-lifecycle: tracked tasks enforce authority, revisions, depende taskId: first.id, actorId: 'session-manager', to: 'completed', expectedRevision: task.revision, }); assert.equal(task.state, 'completed'); + const nextAfterCompletion = backlog.next({ areaId: context.worker }); + assert.equal(nextAfterCompletion.disposition, 'ready'); + assert.equal(nextAfterCompletion.task.id, alternate.id, 'queue order must remain deterministic'); dependentTask = backlog.transition({ taskId: dependent.id, actorId: 'session-manager', to: 'assigned', expectedRevision: dependentTask.revision, @@ -137,7 +208,13 @@ test('SCN-backlog-lifecycle: tracked tasks enforce authority, revisions, depende assert.equal(backlog.list({ state: 'completed' })[0].id, first.id); assert.equal(callTorchTool(control, 'torch_list_backlog', {}, { actorId: context.worker, backlogService: backlog, - }).tasks.length, 2); + }).tasks.length, 3); + assert.equal(callTorchTool(control, 'torch_next_backlog_task', {}, { + actorId: context.worker, backlogService: backlog, + }).task.id, dependent.id); + assert.equal(callTorchTool(control, 'torch_backlog_health', {}, { + actorId: context.worker, backlogService: backlog, + }).mutationPerformed, false); assert.throws( () => callTorchTool(control, 'torch_create_backlog_task', { title: 'Forged task', description: 'Worker cannot create it.', acceptance_criteria: ['No.'], diff --git a/test/fleet/cli-scenarios.test.mjs b/test/fleet/cli-scenarios.test.mjs index b20b7f7..7797ec8 100644 --- a/test/fleet/cli-scenarios.test.mjs +++ b/test/fleet/cli-scenarios.test.mjs @@ -274,6 +274,17 @@ test('SCN-cli-backlog: public commands create, transition, assign, and list trac } assert.equal(task.state, 'assigned'); assert.equal(task.owner, proposal.domains[0].id); + const next = JSON.parse(run(root, [ + 'backlog', 'next', '--area', proposal.domains[0].id, '--json', + ]).stdout); + assert.equal(next.disposition, 'resume'); + assert.equal(next.task.id, task.id); + assert.equal(next.mutationPerformed, false); + const health = JSON.parse(run(root, [ + 'backlog', 'health', '--stale-days', '0', '--json', + ]).stdout); + assert.equal(health.mutationPerformed, false); + assert.equal(health.findings.some((finding) => finding.code === 'BACKLOG_ASSIGNED_STALE'), true); const listed = JSON.parse(run(root, ['backlog', 'list', '--state', 'assigned', '--json']).stdout); assert.equal(listed.tasks[0].id, task.id); });