@on-par/factory-cli 0.9.7-next.1 → 0.9.7-next.11

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,10 +1,15 @@
1
1
  // packages/cli/src/cli/doctor.ts — `factory doctor` environment preflight
2
- import { existsSync } from 'node:fs';
2
+ import { existsSync, readFileSync } from 'node:fs';
3
3
  import { resolve } from 'node:path';
4
- import { detectSandboxRuntime } from '@on-par/factory-core';
5
- import { findConfigSchemaErrors, isHarnessCliPresent, isTemplateToken, lintFactoryConfig, listBuiltInCheckers, } from '@on-par/factory-core/internal';
4
+ import { applyRepoConfig, ConstitutionLoader, detectSandboxRuntime, getFactoryPaths, inspectPortLeases, KNOWN_HARNESS_IDS, loadFactoryConfig, reapStalePortLeases, resolveProcessGroupGraceMs, resolveSandboxPolicy, } from '@on-par/factory-core';
5
+ import { claudeStoredSessionState, codexChatgptSessionState, createOctokitQueueClient, hasClaudeLoginEvidence, hasOpencodeLoginEvidence, isHarnessCliPresent, localAuthFailureSinceLogin, logEvent, microVmName, resolveBranchPrefix, shellEscape, withFileLock, withGitLock, wrapCommandInSandbox, } from '@on-par/factory-core/internal';
6
6
  import { MIN_NODE_MAJOR } from '../node-version.js';
7
+ import { configLintChecks, declaredHarnessChecks, modelsRegistryChecks } from './doctor-config.js';
8
+ import { analyzeEventLog, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, leaseChecks, orphanContainerChecks, orphanMicroVmChecks, unmergedGreenPrChecks, } from './doctor-health.js';
7
9
  import { availableAgentClis, CLAUDE_CODE_URL, CODEX_CLI_URL, GITHUB_TOKENS_URL } from './first-run.js';
10
+ import { distFreshnessProbe } from './staleness.js';
11
+ import { readStopFileStatus, stopSentinelCheck } from './stop-sentinel.js';
12
+ import { checkSweepHeartbeat, defaultSweepHeartbeatDeps, sweepHeartbeatCheck } from './sweep-heartbeat.js';
8
13
  /** Section for rows about repo-configured command harnesses (#2691). */
9
14
  export const COMMAND_HARNESS_SECTION = 'Command harness';
10
15
  /** Repo wired-up rows, sectioned only inside a git repo (#2557). */
@@ -672,279 +677,220 @@ export function reconcileAgentAuthChecks(checks) {
672
677
  : { ...c, optional: false };
673
678
  });
674
679
  }
675
- export function leaseChecks(rows) {
676
- if (rows.length === 0) {
677
- return [{ name: 'port leases', ok: true, optional: true, detail: 'no active leases' }];
680
+ export async function cmdDoctor(opts, rt) {
681
+ let repoRoot;
682
+ try {
683
+ repoRoot = rt.shellOps
684
+ .execSync('git rev-parse --show-toplevel', {
685
+ timeout: 10_000,
686
+ stdio: ['ignore', 'pipe', 'ignore'],
687
+ })
688
+ .trim();
678
689
  }
679
- return rows.map((row) => {
680
- if (row.alive) {
681
- return {
682
- name: `port lease :${row.port}`,
683
- ok: true,
684
- optional: true,
685
- detail: `${row.worktreeId}: port ${row.port}, pid ${row.pid}, live`,
686
- };
687
- }
688
- const squatted = row.portSquatted ? '; port still in use by another process' : '';
689
- return {
690
- name: `port lease :${row.port}`,
691
- ok: false,
692
- optional: true,
693
- detail: `${row.worktreeId}: port ${row.port}, pid ${row.pid}, stale (${row.reason})${squatted}`,
694
- fix: 'run `factory doctor --reconcile` to reclaim stale leases',
695
- };
690
+ catch {
691
+ repoRoot = null;
692
+ }
693
+ const checks = runDoctorChecks({
694
+ commandAvailable: rt.coreOps.isCommandAvailable,
695
+ envPresent: (key) => !!process.env[key],
696
+ tryExec: (cmd, opts) => {
697
+ try {
698
+ return rt.shellOps
699
+ .execSync(cmd, {
700
+ timeout: opts?.timeoutMs ?? 10_000,
701
+ stdio: ['ignore', 'pipe', 'ignore'],
702
+ ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
703
+ })
704
+ .trim();
705
+ }
706
+ catch {
707
+ return null;
708
+ }
709
+ },
710
+ pathExists: existsSync,
711
+ distFreshness: distFreshnessProbe(import.meta.url),
712
+ codexSessionState: () => codexChatgptSessionState(),
713
+ claudeLoggedIn: () => hasClaudeLoginEvidence({
714
+ exec: (file, args) => {
715
+ rt.shellOps.execSync([file, ...args].map(shellEscape).join(' '), { timeout: 10_000, stdio: 'ignore' });
716
+ },
717
+ fileExists: existsSync,
718
+ }),
719
+ claudeSessionState: () => claudeStoredSessionState(),
720
+ localAuthFailure: (agent) => localAuthFailureSinceLogin(agent),
721
+ opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
722
+ commandHarnesses: () => repoRoot === null
723
+ ? []
724
+ : repoCommandHarnesses(rt.configLoaders.loadModelsConfig().harnesses, rt.configLoaders.loadRepoConfig(repoRoot)?.models?.registry),
696
725
  });
697
- }
698
- export function analyzeEventLog(content) {
699
- const lines = content.split('\n').filter((l) => l.trim() !== '');
700
- let unparseable = 0;
701
- for (const line of lines) {
726
+ // Only with --probe-inference: these send a real prompt (uses quota). Plain doctor makes no inference call.
727
+ if (opts.probeInference) {
728
+ const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
729
+ const probeExec = (cmd) => {
730
+ try {
731
+ return rt.shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
732
+ }
733
+ catch {
734
+ return null;
735
+ }
736
+ };
737
+ let host = 'skipped';
738
+ let hostDetail = 'skipped: claude CLI not on PATH';
739
+ if (rt.coreOps.isCommandAvailable('claude')) {
740
+ host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
741
+ hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
742
+ }
743
+ let sandboxed = 'skipped';
744
+ let sandboxDetail = 'skipped: host claude auth was not verified';
745
+ const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => rt.isCommandAvailableViaShell(rt.shellOps, cmd));
746
+ const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
747
+ if (skip !== null) {
748
+ sandboxDetail = skip;
749
+ }
750
+ else if (repoRoot !== null) {
751
+ const policy = resolveSandboxPolicy(rt.configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
752
+ worktree: repoRoot,
753
+ repoRoot,
754
+ isAvailable: (cmd) => rt.isCommandAvailableViaShell(rt.shellOps, cmd),
755
+ });
756
+ if (!policy) {
757
+ sandboxDetail = 'skipped: sandbox disabled by config or FACTORY_SANDBOX';
758
+ }
759
+ else if (policy.runtime === 'none') {
760
+ sandboxDetail = 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
761
+ }
762
+ else if (policy.runtime === 'docker-sandbox') {
763
+ sandboxDetail = 'skipped: docker-sandbox containment is the microVM, not command wrapping (#653)';
764
+ }
765
+ else {
766
+ sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
767
+ sandboxDetail =
768
+ sandboxed === 'ok'
769
+ ? `claude -p succeeded under ${policy.runtime}`
770
+ : `claude -p failed under ${policy.runtime} (local_auth)`;
771
+ }
772
+ }
773
+ checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
774
+ }
775
+ checks.push(claudeKeychainCheck(rt.probeClaudeKeychain()));
776
+ checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
777
+ if (repoRoot !== null) {
778
+ const paths = getFactoryPaths(repoRoot);
779
+ let factoryConfig;
702
780
  try {
703
- JSON.parse(line);
781
+ factoryConfig = rt.configLoaders.loadFactoryConfigForRepo(paths.config);
704
782
  }
705
783
  catch {
706
- unparseable++;
784
+ factoryConfig = loadFactoryConfig(); // schema errors are reported by the config row (#2611)
707
785
  }
708
- }
709
- return { total: lines.length, unparseable };
710
- }
711
- export function eventLogCheck(integrity) {
712
- if (integrity === null) {
713
- return { name: 'event log integrity', ok: true, optional: true, detail: 'no events.ndjson yet' };
714
- }
715
- if (integrity.total === 0) {
716
- return { name: 'event log integrity', ok: true, optional: true, detail: 'events.ndjson is empty' };
717
- }
718
- const pct = ((integrity.unparseable / integrity.total) * 100).toFixed(1);
719
- if (integrity.unparseable === 0) {
720
- return {
721
- name: 'event log integrity',
722
- ok: true,
723
- optional: true,
724
- detail: `${integrity.total} lines, 0 unparseable (0.0%)`,
725
- };
726
- }
727
- return {
728
- name: 'event log integrity',
729
- ok: false,
730
- optional: true,
731
- detail: `${integrity.unparseable} of ${integrity.total} lines unparseable (${pct}%)`,
732
- fix: 'historical corruption from pre-lock parallel writes; appends are now serialized; metrics over this file carry this error bar',
733
- };
734
- }
735
- export function orphanContainerChecks(names) {
736
- if (names.length === 0) {
737
- return [{ name: 'orphan containers', ok: true, detail: 'no orphan sf-job-*/factory.managed containers' }];
738
- }
739
- return names.map((name) => ({
740
- name: `orphan container ${name}`,
741
- ok: false,
742
- detail: `${name} is stopped and not tied to any running job`,
743
- fix: 'run `factory doctor --reconcile` to remove it and its anonymous volumes',
744
- }));
745
- }
746
- export function orphanMicroVmChecks(names) {
747
- if (names.length === 0) {
748
- return [{ name: 'orphan sbx VMs', ok: true, detail: 'no orphan factory-* sbx VMs' }];
749
- }
750
- return names.map((name) => ({
751
- name: `orphan sbx VM ${name}`,
752
- ok: false,
753
- detail: `${name} has no active lane holding its worktree`,
754
- fix: 'run `factory doctor --reconcile` to remove it via `sbx rm --force`',
755
- }));
756
- }
757
- export function formatContainerReconcileReport(reaped) {
758
- if (reaped.length === 0)
759
- return 'reconcile: no orphan containers';
760
- return reaped
761
- .map((r) => r.removed
762
- ? `reconcile: removed container ${r.name}`
763
- : `reconcile: failed to remove container ${r.name}: ${r.detail}`)
764
- .join('\n');
765
- }
766
- export function formatMicroVmReconcileReport(reaped) {
767
- if (reaped.length === 0)
768
- return 'reconcile: no orphan sbx VMs';
769
- return reaped
770
- .map((r) => r.removed
771
- ? `reconcile: removed sbx VM ${r.name} (sbx rm --force)`
772
- : `reconcile: failed to remove sbx VM ${r.name}: ${r.detail}`)
773
- .join('\n');
774
- }
775
- export function formatReconcileReport(reaped) {
776
- if (reaped.length === 0)
777
- return 'reconcile: no stale leases';
778
- return reaped
779
- .map((r) => `reconcile: freed port ${r.lease.port} (worktree ${r.lease.worktreeId}, reason ${r.reason})`)
780
- .join('\n');
781
- }
782
- export function formatWorktreeReconcileReport(removed) {
783
- if (removed.length === 0)
784
- return 'reconcile: no dead-run worktrees';
785
- return removed
786
- .map((w) => {
787
- const branchLabel = w.branch ?? 'detached';
788
- const deleted = w.branchDeleted ? `, deleted branch ${w.branch}` : '';
789
- return `reconcile: removed worktree ${w.path} (branch ${branchLabel}, reason ${w.reason})${deleted}`;
790
- })
791
- .join('\n');
792
- }
793
- export function formatClaimReconcileReport(released) {
794
- if (released.length === 0)
795
- return 'reconcile: no stale claims';
796
- return released
797
- .map((c) => c.released
798
- ? `reconcile: released issue #${c.issue} back to factory:queued (lease expired at ${c.expiresAt})`
799
- : `reconcile: failed to release issue #${c.issue} (lease expired at ${c.expiresAt}): ${c.detail ?? 'unknown error'}`)
800
- .join('\n');
801
- }
802
- const GREEN_PR_CHECK = 'green PRs awaiting merge';
803
- export function unmergedGreenPrChecks(result) {
804
- if (result.status === 'skipped') {
805
- return [{ name: GREEN_PR_CHECK, ok: true, optional: true, detail: result.detail }];
806
- }
807
- if (result.status === 'failed') {
808
- return [
809
- {
810
- name: GREEN_PR_CHECK,
811
- ok: false,
812
- optional: true,
813
- detail: `scan failed: ${result.detail}`,
814
- fix: 'check `gh auth status` and network access, then re-run `factory doctor`',
815
- },
816
- ];
817
- }
818
- if (result.rows.length === 0) {
819
- return [{ name: GREEN_PR_CHECK, ok: true, optional: true, detail: 'no green-and-ready PRs awaiting merge' }];
820
- }
821
- return result.rows.map((row) => ({
822
- name: `green PR #${row.number}`,
823
- ok: false,
824
- optional: true,
825
- detail: `${row.issue === null ? 'owning issue unknown' : `issue #${row.issue}`}: green and ready on ${row.branch}, open ${row.ageHours}h, not merged`,
826
- fix: `review and merge #${row.number} (or close it); the factory never auto-merges an orphaned PR`,
827
- }));
828
- }
829
- /** One doctor check per user-declared harness, from its `probe` result. */
830
- export function declaredHarnessChecks(probes) {
831
- return probes.map((p) => {
832
- const name = `harness ${p.harness}`;
833
- if (p.status === 'available')
834
- return { name, ok: true, detail: `available (probe: ${p.probe})` };
835
- if (p.status === 'unavailable') {
836
- return {
837
- name,
838
- ok: false,
839
- detail: `unavailable: probe \`${p.probe}\` failed: ${p.detail}`,
840
- fix: 'install the agent CLI on PATH or fix the "probe" command in the models config harnesses section',
841
- };
786
+ const health = await inspectPortLeases({ registryFile: paths.ports });
787
+ checks.push(...leaseChecks(health.map(rt.toLeaseRow)));
788
+ const dockerAvailable = rt.coreOps.isCommandAvailable('docker');
789
+ const orphanContainers = dockerAvailable ? await rt.internalOps.listOrphanContainers().catch(() => []) : [];
790
+ checks.push(...orphanContainerChecks(orphanContainers.map((c) => c.name)));
791
+ const sbxAvailable = rt.coreOps.isCommandAvailable('sbx');
792
+ const activeVmNames = new Set(health.filter((h) => h.alive).map((h) => microVmName(h.lease.worktreeId)));
793
+ const orphanVmNames = sbxAvailable
794
+ ? (await rt.internalOps.listMicroVms()).filter((n) => !activeVmNames.has(n))
795
+ : [];
796
+ checks.push(...orphanMicroVmChecks(orphanVmNames));
797
+ const sweepStatus = checkSweepHeartbeat(factoryConfig.sweep, process.env, defaultSweepHeartbeatDeps());
798
+ const sweepCheck = sweepHeartbeatCheck(sweepStatus);
799
+ if (sweepCheck)
800
+ checks.push(sweepCheck);
801
+ checks.push(stopSentinelCheck(readStopFileStatus(paths)));
802
+ const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
803
+ checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
804
+ let constitution = null;
805
+ let loadError;
806
+ try {
807
+ constitution = new ConstitutionLoader().loadRepoConstitution(repoRoot, factoryConfig.paths?.constitution);
842
808
  }
843
- return {
844
- name,
845
- ok: false,
846
- optional: true,
847
- detail: 'unverified: no probe configured',
848
- fix: 'add a "probe" command (exit 0 = reachable) to the harness in the models config',
849
- };
850
- });
851
- }
852
- const REGISTRY_CHECK = 'models.registry';
853
- /** One failed check per models.registry entry whose harness is missing or unknown;
854
- * a single ok row when every entry is valid; nothing when there is no registry. */
855
- export function modelsRegistryChecks(configPath, registry, validHarnesses) {
856
- const entries = Object.entries(registry ?? {});
857
- if (entries.length === 0)
858
- return [];
859
- const failed = [];
860
- for (const [id, def] of entries) {
861
- const field = `models.registry.${id}.harness`;
862
- const name = `${REGISTRY_CHECK} ${id}`;
863
- if (def.harness === undefined || def.harness.trim() === '') {
864
- failed.push({
865
- name,
866
- ok: false,
867
- detail: `${configPath}: ${field} is missing`,
868
- fix: `set "harness" to one of: ${validHarnesses.join(', ')}`,
869
- });
809
+ catch (err) {
810
+ loadError = `cannot parse constitution: ${err instanceof Error ? err.message : String(err)}`;
870
811
  }
871
- else if (!validHarnesses.includes(def.harness)) {
872
- failed.push({
873
- name,
874
- ok: false,
875
- detail: `${configPath}: ${field} '${def.harness}' is not a known harness (valid harnesses: ${validHarnesses.join(', ')})`,
876
- fix: 'use a built-in harness id or declare it under "harnesses" in the models config',
877
- });
812
+ checks.push(...configLintChecks({ constitution, configFile: rt.repoConfigFileFor(repoRoot, paths.config) }, loadError));
813
+ try {
814
+ const declared = Object.keys(rt.configLoaders.loadModelsConfig().harnesses ?? {});
815
+ checks.push(...modelsRegistryChecks(paths.config, rt.configLoaders.loadRepoConfig(repoRoot)?.models?.registry, [
816
+ ...KNOWN_HARNESS_IDS,
817
+ ...declared,
818
+ ]));
878
819
  }
879
- }
880
- if (failed.length > 0)
881
- return failed;
882
- const n = entries.length;
883
- return [{ name: REGISTRY_CHECK, ok: true, detail: `${n} entr${n === 1 ? 'y' : 'ies'} valid (${configPath})` }];
884
- }
885
- /** `config` rows from the shared pre-PLAN lint (#2612): one failing row per finding, so doctor fails exactly when
886
- * the run preflight would escalate or park. A single ok row when clean. Read-only. */
887
- export function configLintChecks(input, loadError) {
888
- const schemaRows = (input.configFile ? findConfigSchemaErrors(input.configFile) : []).map((f) => ({
889
- name: 'config',
890
- ok: false,
891
- section: REPO_SECTION,
892
- detail: `${f.file}${f.line ? `:${f.line}` : ''}: schema error at '${f.path}': ${f.message}`,
893
- fix: f.fix,
894
- }));
895
- if (loadError !== undefined) {
896
- return [
897
- {
898
- name: 'config',
899
- ok: false,
900
- section: REPO_SECTION,
901
- detail: loadError,
902
- fix: 'fix the constitution frontmatter YAML',
903
- },
904
- ...schemaRows,
905
- ];
906
- }
907
- const { findings } = lintFactoryConfig(input);
908
- if (findings.length > 0) {
909
- const names = [...listBuiltInCheckers().map((c) => c.name), 'custom_*'].join(', ');
910
- const lintRows = findings.map((f) => {
911
- const where = `${f.file}${f.line ? `:${f.line}` : ''}`;
912
- if (f.rule === 'unknown-checker') {
913
- const kind = isTemplateToken(f.token) ? 'placeholder' : 'unknown';
914
- return {
915
- name: 'config',
916
- ok: false,
917
- section: REPO_SECTION,
918
- detail: `${where}: ${kind} checker '${f.token}'`,
919
- fix: `replace '${f.token}' with a real checker name (${names}) or delete the line`,
920
- };
820
+ catch {
821
+ // malformed config is reported by the load paths / `factory models --doctor`; doctor keeps going
822
+ }
823
+ let harnessProbes = [];
824
+ try {
825
+ const registry = new rt.coreOps.ModelRegistry(applyRepoConfig(rt.configLoaders.loadModelsConfig(), rt.configLoaders.loadRepoConfig(repoRoot)));
826
+ harnessProbes = rt.coreOps.probeDeclaredHarnesses(registry);
827
+ }
828
+ catch {
829
+ // models config problems are reported by `factory models --doctor`; doctor keeps going
830
+ }
831
+ checks.push(...declaredHarnessChecks(harnessProbes));
832
+ // Full run-lifecycle reap (#998): dead-run worktrees and their branches. Uses the same
833
+ // GitHub evidence and the same lock pairing as `factory worktree gc` (cmdWorktreeGc) —
834
+ // a merged or closed PR is what makes a worktree reapable.
835
+ const ghRepo = await rt.getGitHubRepo().catch(() => undefined);
836
+ const octokit = ghRepo && rt.hasGitHubToken() ? rt.getOctokit() : undefined;
837
+ // Green-and-ready PRs with no merge (#1000). Report only — doctor never lands a PR.
838
+ checks.push(...unmergedGreenPrChecks(await rt.scanGreenPrs(ghRepo, octokit)));
839
+ if (opts.reconcile) {
840
+ if (orphanContainers.length > 0) {
841
+ const reapedContainers = await rt.internalOps.reapOrphanContainers(orphanContainers);
842
+ console.log(formatContainerReconcileReport(reapedContainers));
921
843
  }
922
- return {
923
- name: 'config',
924
- ok: false,
925
- section: REPO_SECTION,
926
- detail: `${where}: unfilled template token '${f.token}'`,
927
- fix: `replace '${f.token}' with a real value`,
928
- };
929
- });
930
- return [...lintRows, ...schemaRows];
931
- }
932
- if (schemaRows.length > 0)
933
- return schemaRows;
934
- const scanned = [];
935
- if (input.constitution?.repoInstance)
936
- scanned.push(input.constitution.repoInstance.relPath);
937
- if (input.configFile && existsSync(input.configFile.absPath))
938
- scanned.push(input.configFile.relPath);
939
- return [
940
- {
941
- name: 'config',
942
- ok: true,
943
- section: REPO_SECTION,
944
- detail: scanned.length > 0
945
- ? `no unfilled placeholders, unknown checkers or schema errors in ${scanned.join(', ')}`
946
- : 'no constitution or config file to scan',
947
- },
948
- ];
844
+ if (orphanVmNames.length > 0) {
845
+ const reapedVms = await Promise.all(orphanVmNames.map((name) => rt.internalOps.reapOrphanMicroVm(name)));
846
+ console.log(formatMicroVmReconcileReport(reapedVms));
847
+ }
848
+ const reaped = await reapStalePortLeases({ registryFile: paths.ports, lockDir: paths.portsLock });
849
+ console.log(formatReconcileReport(reaped));
850
+ if (reaped.length > 0) {
851
+ const graceMs = resolveProcessGroupGraceMs(factoryConfig);
852
+ const orphanEvents = await rt.coreOps.reapOrphanProcesses({ reaped, graceMs });
853
+ for (const e of orphanEvents) {
854
+ console.log(`reconcile: ${e.action === 'killed' ? 'killed' : 'found'} pid ${e.pid} (pgid ${e.pgid}, ${e.command}) squatting port ${e.port} of dead lane ${e.worktreeId}${e.action === 'reported' ? '; not factory-started, left running' : ''}`);
855
+ }
856
+ }
857
+ try {
858
+ const gcLog = (type, msg) => logEvent(paths.events, type, '-', msg);
859
+ const gcSandbox = rt.gcWorktreeSandbox(factoryConfig.sandbox, repoRoot);
860
+ const report = await withGitLock(repoRoot, () => withFileLock(paths.gitLock, () => rt.internalOps.sweepWorktrees({
861
+ repoRoot,
862
+ ttlDays: factoryConfig.worktree.gcTtlDays,
863
+ repo: ghRepo,
864
+ branchPrefix: resolveBranchPrefix(),
865
+ }, { log: gcLog, octokit, sandbox: gcSandbox })));
866
+ console.log(formatWorktreeReconcileReport(report.removed));
867
+ }
868
+ catch (err) {
869
+ console.log(`reconcile: worktree sweep failed: ${err?.message ?? String(err)}`);
870
+ }
871
+ // Stale claim reap (#999): factory:in-progress claims naming a dead local pid go back to
872
+ // factory:queued so the next run can claim them with no manual label surgery.
873
+ try {
874
+ if (ghRepo && octokit) {
875
+ const [owner, repoName] = ghRepo.split('/');
876
+ const released = await rt.internalOps.releaseStaleClaims({
877
+ client: createOctokitQueueClient(octokit),
878
+ owner,
879
+ repo: repoName,
880
+ });
881
+ console.log(formatClaimReconcileReport(released));
882
+ }
883
+ else {
884
+ console.log('reconcile: skipping stale claims: no GitHub repo or token');
885
+ }
886
+ }
887
+ catch (err) {
888
+ console.log(`reconcile: stale claim release failed: ${err?.message ?? String(err)}`);
889
+ }
890
+ }
891
+ }
892
+ console.log(formatDoctorChecks(checks, { inRepo: repoRoot !== null }));
893
+ if (doctorFailed(checks))
894
+ process.exit(1);
949
895
  }
950
896
  //# sourceMappingURL=doctor.js.map