@on-par/factory-cli 0.9.7-next.3 → 0.9.7-next.31

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.
Files changed (46) hide show
  1. package/dist/cli/doctor-config.d.ts +14 -0
  2. package/dist/cli/doctor-config.d.ts.map +1 -0
  3. package/dist/cli/doctor-config.js +126 -0
  4. package/dist/cli/doctor-config.js.map +1 -0
  5. package/dist/cli/doctor-harness-view.d.ts +18 -0
  6. package/dist/cli/doctor-harness-view.d.ts.map +1 -0
  7. package/dist/cli/doctor-harness-view.js +21 -0
  8. package/dist/cli/doctor-harness-view.js.map +1 -0
  9. package/dist/cli/doctor-health.d.ts +73 -0
  10. package/dist/cli/doctor-health.d.ts.map +1 -0
  11. package/dist/cli/doctor-health.js +156 -0
  12. package/dist/cli/doctor-health.js.map +1 -0
  13. package/dist/cli/doctor-queue-reconcile.d.ts +7 -0
  14. package/dist/cli/doctor-queue-reconcile.d.ts.map +1 -0
  15. package/dist/cli/doctor-queue-reconcile.js +55 -0
  16. package/dist/cli/doctor-queue-reconcile.js.map +1 -0
  17. package/dist/cli/doctor.d.ts +26 -81
  18. package/dist/cli/doctor.d.ts.map +1 -1
  19. package/dist/cli/doctor.js +201 -267
  20. package/dist/cli/doctor.js.map +1 -1
  21. package/dist/cli/index.d.ts +22 -73
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +91 -828
  24. package/dist/cli/index.js.map +1 -1
  25. package/dist/cli/run-lane.d.ts +52 -0
  26. package/dist/cli/run-lane.d.ts.map +1 -0
  27. package/dist/cli/run-lane.js +240 -0
  28. package/dist/cli/run-lane.js.map +1 -0
  29. package/dist/cli/run.d.ts +52 -0
  30. package/dist/cli/run.d.ts.map +1 -0
  31. package/dist/cli/run.js +200 -0
  32. package/dist/cli/run.js.map +1 -0
  33. package/dist/cli/ship.d.ts.map +1 -1
  34. package/dist/cli/ship.js.map +1 -1
  35. package/dist/cli/status.d.ts +23 -0
  36. package/dist/cli/status.d.ts.map +1 -0
  37. package/dist/cli/status.js +179 -0
  38. package/dist/cli/status.js.map +1 -0
  39. package/dist/cli/unpark.d.ts +5 -27
  40. package/dist/cli/unpark.d.ts.map +1 -1
  41. package/dist/cli/unpark.js +5 -66
  42. package/dist/cli/unpark.js.map +1 -1
  43. package/dist/cli/update-state.d.ts.map +1 -1
  44. package/dist/cli/update-state.js +2 -4
  45. package/dist/cli/update-state.js.map +1 -1
  46. package/package.json +3 -3
@@ -1,10 +1,17 @@
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, 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, formatContainerReconcileReport, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, leaseChecks, orphanContainerChecks, orphanMicroVmChecks, unmergedGreenPrChecks, } from './doctor-health.js';
9
+ import { resolveDoctorHarnesses } from './doctor-harness-view.js';
10
+ import { reconcileQueue } from './doctor-queue-reconcile.js';
7
11
  import { availableAgentClis, CLAUDE_CODE_URL, CODEX_CLI_URL, GITHUB_TOKENS_URL } from './first-run.js';
12
+ import { distFreshnessProbe } from './staleness.js';
13
+ import { readStopFileStatus, stopSentinelCheck } from './stop-sentinel.js';
14
+ import { checkSweepHeartbeat, defaultSweepHeartbeatDeps, sweepHeartbeatCheck } from './sweep-heartbeat.js';
8
15
  /** Section for rows about repo-configured command harnesses (#2691). */
9
16
  export const COMMAND_HARNESS_SECTION = 'Command harness';
10
17
  /** Repo wired-up rows, sectioned only inside a git repo (#2557). */
@@ -291,7 +298,7 @@ export function commandExecutable(command) {
291
298
  export function repoCommandHarnesses(harnesses, registry) {
292
299
  const used = new Set(Object.values(registry ?? {}).map((def) => def.harness));
293
300
  return Object.entries(harnesses ?? {})
294
- .filter(([id, decl]) => decl.agentic === true && used.has(id))
301
+ .filter(([id, decl]) => decl.agentic && used.has(id))
295
302
  .map(([id, decl]) => ({ harness: id, command: decl.command, executable: commandExecutable(decl.command) }));
296
303
  }
297
304
  /** One optional `command harness <id>` row per configured command harness (#2691). */
@@ -318,7 +325,7 @@ export function commandHarnessChecks(harnesses, commandAvailable) {
318
325
  });
319
326
  }
320
327
  function installFix(h) {
321
- return `install ${h.executable ?? 'the agent CLI'} on PATH or fix "command" for harness ${h.harness} in the models config harnesses section`;
328
+ return `install ${h.executable ?? 'the agent CLI'} on PATH or fix harnesses.${h.harness}.command in .factory/config.yaml`;
322
329
  }
323
330
  /** Coding agent prerequisites: at least one agent CLI is required; each CLI is then reported
324
331
  * on its own optional line, with sign-in reported separately from installation (#1269, #2422, #2555, #2556).
@@ -672,279 +679,206 @@ export function reconcileAgentAuthChecks(checks) {
672
679
  : { ...c, optional: false };
673
680
  });
674
681
  }
675
- export function leaseChecks(rows) {
676
- if (rows.length === 0) {
677
- return [{ name: 'port leases', ok: true, optional: true, detail: 'no active leases' }];
682
+ export async function cmdDoctor(opts, rt) {
683
+ let repoRoot;
684
+ try {
685
+ repoRoot = rt.shellOps
686
+ .execSync('git rev-parse --show-toplevel', {
687
+ timeout: 10_000,
688
+ stdio: ['ignore', 'pipe', 'ignore'],
689
+ })
690
+ .trim();
678
691
  }
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
- };
692
+ catch {
693
+ repoRoot = null;
694
+ }
695
+ const harnessView = repoRoot === null ? null : resolveDoctorHarnesses(rt.configLoaders, repoRoot);
696
+ const checks = runDoctorChecks({
697
+ commandAvailable: rt.coreOps.isCommandAvailable,
698
+ envPresent: (key) => !!process.env[key],
699
+ tryExec: (cmd, opts) => {
700
+ try {
701
+ return rt.shellOps
702
+ .execSync(cmd, {
703
+ timeout: opts?.timeoutMs ?? 10_000,
704
+ stdio: ['ignore', 'pipe', 'ignore'],
705
+ ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
706
+ })
707
+ .trim();
708
+ }
709
+ catch {
710
+ return null;
711
+ }
712
+ },
713
+ pathExists: existsSync,
714
+ distFreshness: distFreshnessProbe(import.meta.url),
715
+ codexSessionState: () => codexChatgptSessionState(),
716
+ claudeLoggedIn: () => hasClaudeLoginEvidence({
717
+ exec: (file, args) => {
718
+ rt.shellOps.execSync([file, ...args].map(shellEscape).join(' '), { timeout: 10_000, stdio: 'ignore' });
719
+ },
720
+ fileExists: existsSync,
721
+ }),
722
+ claudeSessionState: () => claudeStoredSessionState(),
723
+ localAuthFailure: (agent) => localAuthFailureSinceLogin(agent),
724
+ opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
725
+ commandHarnesses: () => (harnessView?.merged ? repoCommandHarnesses(harnessView.merged, harnessView.registry) : []),
696
726
  });
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) {
727
+ // Only with --probe-inference: these send a real prompt (uses quota). Plain doctor makes no inference call.
728
+ if (opts.probeInference) {
729
+ const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
730
+ const probeExec = (cmd) => {
731
+ try {
732
+ return rt.shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
733
+ }
734
+ catch {
735
+ return null;
736
+ }
737
+ };
738
+ let host = 'skipped';
739
+ let hostDetail = 'skipped: claude CLI not on PATH';
740
+ if (rt.coreOps.isCommandAvailable('claude')) {
741
+ host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
742
+ hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
743
+ }
744
+ let sandboxed = 'skipped';
745
+ let sandboxDetail = 'skipped: host claude auth was not verified';
746
+ const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => rt.isCommandAvailableViaShell(rt.shellOps, cmd));
747
+ const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
748
+ if (skip !== null) {
749
+ sandboxDetail = skip;
750
+ }
751
+ else if (repoRoot !== null) {
752
+ const policy = resolveSandboxPolicy(rt.configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
753
+ worktree: repoRoot,
754
+ repoRoot,
755
+ isAvailable: (cmd) => rt.isCommandAvailableViaShell(rt.shellOps, cmd),
756
+ });
757
+ if (!policy) {
758
+ sandboxDetail = 'skipped: sandbox disabled by config or FACTORY_SANDBOX';
759
+ }
760
+ else if (policy.runtime === 'none') {
761
+ sandboxDetail = 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
762
+ }
763
+ else if (policy.runtime === 'docker-sandbox') {
764
+ sandboxDetail = 'skipped: docker-sandbox containment is the microVM, not command wrapping (#653)';
765
+ }
766
+ else {
767
+ sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
768
+ sandboxDetail =
769
+ sandboxed === 'ok'
770
+ ? `claude -p succeeded under ${policy.runtime}`
771
+ : `claude -p failed under ${policy.runtime} (local_auth)`;
772
+ }
773
+ }
774
+ checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
775
+ }
776
+ checks.push(claudeKeychainCheck(rt.probeClaudeKeychain()));
777
+ checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
778
+ if (repoRoot !== null) {
779
+ const paths = getFactoryPaths(repoRoot);
780
+ let factoryConfig;
702
781
  try {
703
- JSON.parse(line);
782
+ factoryConfig = rt.configLoaders.loadFactoryConfigForRepo(paths.config);
704
783
  }
705
784
  catch {
706
- unparseable++;
785
+ factoryConfig = loadFactoryConfig(); // schema errors are reported by the config row (#2611)
707
786
  }
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
- };
787
+ const health = await inspectPortLeases({ registryFile: paths.ports });
788
+ checks.push(...leaseChecks(health.map(rt.toLeaseRow)));
789
+ const dockerAvailable = rt.coreOps.isCommandAvailable('docker');
790
+ const orphanContainers = dockerAvailable ? await rt.internalOps.listOrphanContainers().catch(() => []) : [];
791
+ checks.push(...orphanContainerChecks(orphanContainers.map((c) => c.name)));
792
+ const sbxAvailable = rt.coreOps.isCommandAvailable('sbx');
793
+ const activeVmNames = new Set(health.filter((h) => h.alive).map((h) => microVmName(h.lease.worktreeId)));
794
+ const orphanVmNames = sbxAvailable
795
+ ? (await rt.internalOps.listMicroVms()).filter((n) => !activeVmNames.has(n))
796
+ : [];
797
+ checks.push(...orphanMicroVmChecks(orphanVmNames));
798
+ const sweepStatus = checkSweepHeartbeat(factoryConfig.sweep, process.env, defaultSweepHeartbeatDeps());
799
+ const sweepCheck = sweepHeartbeatCheck(sweepStatus);
800
+ if (sweepCheck)
801
+ checks.push(sweepCheck);
802
+ checks.push(stopSentinelCheck(readStopFileStatus(paths)));
803
+ const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
804
+ checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
805
+ let constitution = null;
806
+ let loadError;
807
+ try {
808
+ constitution = new ConstitutionLoader().loadRepoConstitution(repoRoot, factoryConfig.paths?.constitution);
842
809
  }
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
- });
810
+ catch (err) {
811
+ loadError = `cannot parse constitution: ${err instanceof Error ? err.message : String(err)}`;
870
812
  }
871
- else if (!validHarnesses.includes(def.harness)) {
872
- failed.push({
873
- name,
813
+ checks.push(...configLintChecks({ constitution, configFile: rt.repoConfigFileFor(repoRoot, paths.config) }, loadError));
814
+ // A malformed config never aborts doctor; a merge failure is reported once, below.
815
+ const view = harnessView ?? { declaredIds: [] };
816
+ const registryRows = modelsRegistryChecks(paths.config, view.registry, [
817
+ ...new Set([...KNOWN_HARNESS_IDS, ...view.declaredIds]),
818
+ ]);
819
+ checks.push(...registryRows);
820
+ if (view.mergeError !== undefined && registryRows.every((r) => r.ok)) {
821
+ checks.push({
822
+ name: 'models config',
823
+ section: REPO_SECTION,
874
824
  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',
825
+ detail: view.mergeError,
826
+ fix: 'fix the harnesses or models section in .factory/config.yaml (built-in harness ids cannot be redeclared)',
877
827
  });
878
828
  }
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
- };
829
+ let harnessProbes = [];
830
+ try {
831
+ const registry = new rt.coreOps.ModelRegistry(applyRepoConfig(rt.configLoaders.loadModelsConfig(), rt.configLoaders.loadRepoConfig(repoRoot)));
832
+ harnessProbes = rt.coreOps.probeDeclaredHarnesses(registry);
833
+ }
834
+ catch {
835
+ // models config problems are reported by `factory models --doctor`; doctor keeps going
836
+ }
837
+ checks.push(...declaredHarnessChecks(harnessProbes));
838
+ // Full run-lifecycle reap (#998): dead-run worktrees and their branches. Uses the same
839
+ // GitHub evidence and the same lock pairing as `factory worktree gc` (cmdWorktreeGc) —
840
+ // a merged or closed PR is what makes a worktree reapable.
841
+ const ghRepo = await rt.getGitHubRepo().catch(() => undefined);
842
+ const octokit = ghRepo && rt.hasGitHubToken() ? rt.getOctokit() : undefined;
843
+ // Green-and-ready PRs with no merge (#1000). Report only — doctor never lands a PR.
844
+ checks.push(...unmergedGreenPrChecks(await rt.scanGreenPrs(ghRepo, octokit)));
845
+ if (opts.reconcile) {
846
+ if (orphanContainers.length > 0) {
847
+ const reapedContainers = await rt.internalOps.reapOrphanContainers(orphanContainers);
848
+ console.log(formatContainerReconcileReport(reapedContainers));
921
849
  }
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
- ];
850
+ if (orphanVmNames.length > 0) {
851
+ const reapedVms = await Promise.all(orphanVmNames.map((name) => rt.internalOps.reapOrphanMicroVm(name)));
852
+ console.log(formatMicroVmReconcileReport(reapedVms));
853
+ }
854
+ const reaped = await reapStalePortLeases({ registryFile: paths.ports, lockDir: paths.portsLock });
855
+ console.log(formatReconcileReport(reaped));
856
+ if (reaped.length > 0) {
857
+ const graceMs = resolveProcessGroupGraceMs(factoryConfig);
858
+ const orphanEvents = await rt.coreOps.reapOrphanProcesses({ reaped, graceMs });
859
+ for (const e of orphanEvents) {
860
+ 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' : ''}`);
861
+ }
862
+ }
863
+ try {
864
+ const gcLog = (type, msg) => logEvent(paths.events, type, '-', msg);
865
+ const gcSandbox = rt.gcWorktreeSandbox(factoryConfig.sandbox, repoRoot);
866
+ const report = await withGitLock(repoRoot, () => withFileLock(paths.gitLock, () => rt.internalOps.sweepWorktrees({
867
+ repoRoot,
868
+ ttlDays: factoryConfig.worktree.gcTtlDays,
869
+ repo: ghRepo,
870
+ branchPrefix: resolveBranchPrefix(),
871
+ }, { log: gcLog, octokit, sandbox: gcSandbox })));
872
+ console.log(formatWorktreeReconcileReport(report.removed));
873
+ }
874
+ catch (err) {
875
+ console.log(`reconcile: worktree sweep failed: ${err?.message ?? String(err)}`);
876
+ }
877
+ await reconcileQueue(rt.internalOps, ghRepo, octokit, getFactoryPaths(repoRoot).events);
878
+ }
879
+ }
880
+ console.log(formatDoctorChecks(checks, { inRepo: repoRoot !== null }));
881
+ if (doctorFailed(checks))
882
+ process.exit(1);
949
883
  }
950
884
  //# sourceMappingURL=doctor.js.map