From f892ab240268535e10974c8714a5da4371cfaf66 Mon Sep 17 00:00:00 2001 From: thePR0M3TH3AN Date: Sun, 27 Sep 2026 19:18:02 -0400 Subject: [PATCH] Expand doctor operational diagnostics --- README.md | 4 +- docs/PORTABLE_AGENT_FLEET_SPEC.md | 7 +++ ...grity-note-2026-09-27-portable-kernel.yaml | 4 +- src/kernel/doctor.mjs | 61 ++++++++++++++++++- src/kernel/worktree-state.mjs | 12 +++- test/fleet/worktree-lifecycle.test.mjs | 24 +++++++- 6 files changed, 105 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 7f9892d..f441e00 100644 --- a/README.md +++ b/README.md @@ -26,7 +26,9 @@ and the first Claude reference-fleet path: - tracked `.torch/` project configuration; - XDG-local runtime state; - installation ownership manifest; -- health diagnostics; +- read-only health diagnostics covering installation integrity, runtime + availability, worktree drift count and age, Git operations, and honest + recoverability grade; - conservative, reversible purge; - reviewed worktree plans with branch/path collision protection; - isolated branches and worktrees with locally ignored task markers; diff --git a/docs/PORTABLE_AGENT_FLEET_SPEC.md b/docs/PORTABLE_AGENT_FLEET_SPEC.md index 4b884bc..f15f347 100644 --- a/docs/PORTABLE_AGENT_FLEET_SPEC.md +++ b/docs/PORTABLE_AGENT_FLEET_SPEC.md @@ -1126,6 +1126,13 @@ task/commit references, and verified item counts. Reports keep evidence classes separate, derive cache-read ratio and cost per verified item, and warn when usage has no verified outcome. +`torch doctor` additionally reports managed-worktree branch/commit consistency, +ahead and behind counts, the timestamp and age of the oldest missing canonical +commit, in-progress Git operations, configured runtime executable availability, +and the current commit's `ONE-DISK`, `LOCAL-REMOTE`, or `OFF-MACHINE` +recoverability grade. Each finding separates observed evidence from a +recommended action; doctor remains read-only. + ## 22. Scheduling TORCH recognizes two schedule classes. 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 bbc4b2e..eaf362c 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 @@ -41,7 +41,7 @@ test_integrity_note: - id: SCN-worktree-bootstrap given: an approved TORCH organization committed to a clean main branch when: the owner previews and creates its managed worktrees - then: every persistent identity receives the intended branch and external worktree, task markers are ignored, and repeated planning is idempotent + then: every persistent identity receives the intended branch and external worktree, task markers are ignored, repeated planning is idempotent, and doctor reports later drift age, Git operations, runtime availability, and recoverability without repair - id: SCN-bootstrap-acceptance given: one approved three-identity fixture with checks, a scarce resource, and neighboring domains when: TORCH installs worktrees, starts virtualized sessions, coordinates messages, runs backlog/check/resource lifecycles, diagnoses, captures, stops, reopens, resumes, and previews reversal @@ -238,6 +238,7 @@ test_integrity_note: - Git status before and after read-only analysis - presence or absence of .torch and XDG-local project state - doctor health and finding codes + - worktree ahead and behind counts, oldest-missing-commit age, active Git operations, runtime detection, and recoverability grade - CLI exit status and structured error code - proposal review status, repository commit binding, and generated domain count - specification digest, evidence-linked responsibility, and stale-spec refusal @@ -285,6 +286,7 @@ test_integrity_note: - init that silently writes files - installer that overwrites existing state - doctor that reports healthy without required state + - doctor hiding convergence age, an active Git operation, a missing runtime, or one-disk-only recovery - uninstall that deletes owner-modified configuration - CLI that mutates without explicit approval - in-memory-only messaging that disappears on restart diff --git a/src/kernel/doctor.mjs b/src/kernel/doctor.mjs index fb87c79..df20b81 100644 --- a/src/kernel/doctor.mjs +++ b/src/kernel/doctor.mjs @@ -6,12 +6,15 @@ import { projectStatePath } from './paths.mjs'; import { inspectManagedWorktree } from './worktree-state.mjs'; import { validateProjectConfig } from './config.mjs'; import { forgeStatus } from '../forge/service.mjs'; +import { classifyRecoverability } from '../canonical/local.mjs'; +import { createClaudeAdapter } from '../adapters/claude.mjs'; +import { createCodexAdapter } from '../adapters/codex.mjs'; function parseJsonYaml(path) { return JSON.parse(readFileSync(path, 'utf8')); } -export function diagnoseProject({ repository, env = process.env }) { +export function diagnoseProject({ repository, env = process.env, now = () => new Date() }) { const trackedRoot = join(repository.root, '.torch'); const configPath = join(trackedRoot, 'torch.yaml'); const manifestPath = join(trackedRoot, 'install-manifest.json'); @@ -129,10 +132,28 @@ export function diagnoseProject({ repository, env = process.env }) { const state = inspectManagedWorktree(repository.root, entry, config?.project?.main_branch ?? 'main'); for (const problem of state.problems) { findings.push({ - severity: problem === 'worktree-dirty' || problem.startsWith('unique-commits:') ? 'warning' : 'error', + severity: problem === 'worktree-dirty' || problem.startsWith('unique-commits:') + || problem.startsWith('git-operation:') ? 'warning' : 'error', code: 'WORKTREE_PROBLEM', area: entry.area, path: entry.path, problem, }); } + if (state.ahead || state.behind) { + const missingAt = state.oldestMissingCommitAt ? Date.parse(state.oldestMissingCommitAt) : null; + findings.push({ + severity: 'info', code: 'WORKTREE_DRIFT', area: entry.area, path: entry.path, + branch: state.branch, commit: state.commit, drift: state.drift, + ahead: state.ahead, behind: state.behind, + oldestMissingCommitAt: state.oldestMissingCommitAt, + driftAgeSeconds: Number.isFinite(missingAt) + ? Math.max(0, Math.floor((now().getTime() - missingAt) / 1000)) : null, + recommendation: 'Ask the owning domain to review an intentional convergence plan at a safe point.', + }); + } + if (state.operations.length) findings.push({ + severity: 'warning', code: 'GIT_OPERATION_ACTIVE', area: entry.area, + path: entry.path, operations: state.operations, + recommendation: 'The owning domain must complete or deliberately abort the Git operation.', + }); } const managerWorktree = (manifest.external ?? []).find((entry) => entry.type === 'worktree' && entry.area === 'session-manager'); @@ -169,6 +190,42 @@ export function diagnoseProject({ repository, env = process.env }) { if (repository.dirtyEntries.length) { findings.push({ severity: 'info', code: 'WORKTREE_DIRTY', count: repository.dirtyEntries.length }); } + try { + const recoverability = classifyRecoverability({ repositoryRoot: repository.root, commit: repository.head }); + findings.push({ + severity: recoverability.offMachine ? 'info' : 'warning', code: 'RECOVERABILITY', + ...recoverability, + recommendation: recoverability.offMachine + ? 'No action required for this commit.' + : 'Configure and synchronize an off-machine canonical Git boundary before relying on this checkout for recovery.', + }); + } catch (error) { + findings.push({ severity: 'error', code: 'RECOVERABILITY_INVALID', message: error.message }); + } + if (config?.runtimes) { + const adapters = new Map([ + ['claude', createClaudeAdapter({ env })], + ['codex', createCodexAdapter({ env })], + ]); + for (const runtime of Object.keys(config.runtimes).filter((name) => name !== 'default')) { + const adapter = adapters.get(runtime); + if (!adapter) { + findings.push({ + severity: 'warning', code: 'RUNTIME_ADAPTER_UNKNOWN', runtime, + recommendation: 'Install or configure a TORCH runtime adapter before starting this identity.', + }); + continue; + } + const detection = adapter.detect(); + findings.push({ + severity: detection.available ? 'info' : 'warning', + code: detection.available ? 'RUNTIME_AVAILABLE' : 'RUNTIME_UNAVAILABLE', + runtime, executable: detection.executable, + recommendation: detection.available ? 'No action required.' + : `Install ${runtime} or change the approved runtime assignment before startup.`, + }); + } + } if (config?.forge?.provider && config.forge.provider !== 'none') { try { const forge = forgeStatus({ repositoryRoot: repository.root }); diff --git a/src/kernel/worktree-state.mjs b/src/kernel/worktree-state.mjs index aba6322..342a395 100644 --- a/src/kernel/worktree-state.mjs +++ b/src/kernel/worktree-state.mjs @@ -14,14 +14,22 @@ export function inspectManagedWorktree(root, entry, mainBranch) { const problems = []; const actualBranch = git(entry.path, ['branch', '--show-current']); if (actualBranch !== entry.branch) problems.push(`branch-mismatch:${actualBranch || 'detached'}`); + const operations = ['MERGE_HEAD', 'REBASE_HEAD', 'CHERRY_PICK_HEAD', 'REVERT_HEAD'] + .filter((name) => existsSync(git(entry.path, ['rev-parse', '--git-path', name]))); + for (const operation of operations) problems.push(`git-operation:${operation}`); const status = git(entry.path, ['status', '--porcelain=v1', '--untracked-files=all']); if (status) problems.push('worktree-dirty'); + const commit = git(entry.path, ['rev-parse', 'HEAD']); const ahead = Number(git(root, ['rev-list', '--count', `${mainBranch}..${entry.branch}`]) || 0); const behind = Number(git(root, ['rev-list', '--count', `${entry.branch}..${mainBranch}`]) || 0); + const oldestMissingCommitAt = behind > 0 + ? git(root, ['log', '--reverse', '--format=%cI', `${entry.branch}..${mainBranch}`]).split('\n')[0] || null + : null; if (ahead > 0) problems.push(`unique-commits:${ahead}`); return { - ...entry, actualBranch, ahead, behind, - drift: ahead === 0 && behind === 0 ? 'current' : 'diverged', + ...entry, actualBranch, commit, ahead, behind, oldestMissingCommitAt, operations, + drift: ahead > 0 && behind > 0 ? 'diverged' + : (ahead > 0 ? 'ahead' : (behind > 0 ? 'behind' : 'current')), safeToRemove: problems.length === 0, problems, }; } diff --git a/test/fleet/worktree-lifecycle.test.mjs b/test/fleet/worktree-lifecycle.test.mjs index c67941a..7467842 100644 --- a/test/fleet/worktree-lifecycle.test.mjs +++ b/test/fleet/worktree-lifecycle.test.mjs @@ -1,6 +1,6 @@ import assert from 'node:assert/strict'; import { execFileSync } from 'node:child_process'; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from 'node:fs'; +import { existsSync, mkdtempSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { basename, join } from 'node:path'; import test from 'node:test'; @@ -53,6 +53,28 @@ test('SCN-worktree-bootstrap: approved identities receive isolated branches and assert.equal(repeated.actions.every((action) => action.action === 'keep'), true); assert.equal(repeated.canProceed, true); assert.equal(diagnoseProject({ repository: inspectRepository(fixture.root), env: fixture.env }).healthy, true); + + writeFileSync(join(fixture.root, 'drift.txt'), 'new canonical state\n'); + execFileSync('git', ['-C', fixture.root, 'add', 'drift.txt']); + execFileSync('git', ['-C', fixture.root, 'commit', '-m', 'advance canonical main']); + const core = created.created.find((entry) => entry.area === 'core'); + const mergeHead = execFileSync('git', ['-C', core.path, 'rev-parse', '--git-path', 'MERGE_HEAD'], { + encoding: 'utf8', + }).trim(); + writeFileSync(mergeHead, `${execFileSync('git', ['-C', fixture.root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim()}\n`); + const diagnosis = diagnoseProject({ + repository: inspectRepository(fixture.root), env: fixture.env, + now: () => new Date('2026-09-28T00:00:00Z'), + }); + const drift = diagnosis.findings.find((finding) => finding.code === 'WORKTREE_DRIFT' && finding.area === 'core'); + assert.equal(drift.behind, 1); + assert.equal(Number.isInteger(drift.driftAgeSeconds), true); + assert.equal(diagnosis.findings.some((finding) => + finding.code === 'GIT_OPERATION_ACTIVE' && finding.operations.includes('MERGE_HEAD')), true); + assert.equal(diagnosis.findings.some((finding) => + finding.code === 'RECOVERABILITY' && finding.level === 'ONE-DISK'), true); + assert.equal(diagnosis.findings.some((finding) => finding.code.startsWith('RUNTIME_')), true); + unlinkSync(mergeHead); uninstallProject({ repository: inspectRepository(fixture.root), purge: true }); assert.equal(created.created.some((action) => existsSync(action.path)), false); assert.equal(readFileSync(join(fixture.root, '.git', 'info', 'exclude'), 'utf8').includes('.task'), false);