mirror of
https://github.com/PR0M3TH3AN/TORCH.git
synced 2026-11-01 10:21:53 +00:00
Harden specialist backlog loop
This commit is contained in:
1 parent
a900459d87
commit
770cdde4a7
10 files changed
+406
-28
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
+206
-2
@@ -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;
|
||||
|
||||
+13
-1
@@ -76,8 +76,10 @@ Usage:
|
||||
torch coordinate --from <id> --body <text> [--with <id,id>] [--task <id>] [--path <path>] [--json]
|
||||
torch handoff --from <id> [--to <id>] (--path <path> | --task <id>) --reason <text> [--json]
|
||||
torch backlog list [--state <state>] [--owner <id>] [--json]
|
||||
torch backlog next --area <id> [--json]
|
||||
torch backlog health [--stale-days <n>] [--stale-observed-commits <n>] [--json]
|
||||
torch backlog get --task <id> [--json]
|
||||
torch backlog create --area <id> --title <text> --description <text> --accept <text,...> [--priority <priority>] [--domains <id,...>] [--depends <task,...>] [--json]
|
||||
torch backlog create --area <id> --title <text> --description <text> --accept <text,...> [--priority <priority>] [--domains <id,...>] [--depends <task,...>] [--observed-at <sha>] [--json]
|
||||
torch backlog transition --task <id> --area <id> --state <state> --revision <n> [--owner <id>] [--evidence <text,...>] [--commit <sha>] [--integration <id>] [--reason <text>] [--note <text>] [--json]
|
||||
torch checks list [--json]
|
||||
torch checks receipts [--commit <sha>] [--id <check>] [--area <id>] [--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'),
|
||||
|
||||
+25
-8
@@ -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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)],
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
+28
-2
@@ -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'),
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -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.'],
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
Reference in new issue
Block a user