@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.
package/dist/cli/index.js CHANGED
@@ -1,22 +1,23 @@
1
1
  import { fileURLToPath } from 'node:url';
2
2
  // packages/cli/src/cli/index.ts — CLI entry point: factory <command> [options]
3
3
  import { exec as execCb, execSync } from 'node:child_process';
4
- import { randomUUID } from 'node:crypto';
5
4
  import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, unlinkSync, writeFileSync, } from 'node:fs';
6
5
  import { createRequire } from 'node:module';
7
6
  import { arch, hostname as osHostname, release, platform, userInfo } from 'node:os';
8
7
  import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
9
8
  import { promisify, styleText } from 'node:util';
10
9
  import { INVALID_BRANCH_PREFIX_MESSAGE, resolveBranchPrefixOption } from './branch-prefix.js';
11
- import { acquirePortLease, aggregateCosts, appendKpiHistoryLine, applyRepoConfig, buildPhase, checkPhase, clearProxyState, computeHealthKpis, ConstitutionLoader, createDefaultWorkSourceRegistry, createLogger, createLaneProxy, defaultFindPortListeners, describeEffectiveConfig, detectPostMergeDefects, diagnoseModels, probeDeclaredHarnesses, estimateTrailingSpend, fetchDefectSources, fetchHumanEventSources, fetchSubscriptionUsage, formatKpiLines, formatUsageReport, getConstitutionsDir, getFactoryPaths, GITHUB_ISSUE_SOURCE, hasUnresolvedPark, InvalidArtifactsDirError, InvalidWorkRequestInputError, InvalidWorkspaceError, inspectPortLeases, isCommandAvailable, isProxyRunning, kpiBranchPrefixes, KNOWN_HARNESS_IDS, kpisToHistoryRecord, laneBaseUrl, laneEnv, laneHostLabel, loadFactoryConfig, loadFactoryConfigForRepo, loadModelsConfig, loadRepoConfig, loadRoutesConfig, LOCAL_BRIEF_SOURCE, joinClassifierOutcomes, mergeClassifierOutcomes, parseClassifierOutcomes, summarizeClassifierOutcomes, formatClassifierReport, mergedPrRefs, ModelRegistry, ModelRouter, parkReasonFor, parseKpiHistory, parseQueue, partitionLocalQueueByActivity, planPhase, provisionLaneContainer, ProviderBreaker, readEvents, readPortLeases, readQueue, readUsage, reapOrphanProcesses, reapStalePortLeases, reconstructKpiEvents, recordLeasePgid, releasePortLease, renderKpiReport, renderKpiTrend, resolveCodexDisabled, resolveDefectWindowDays, resolveSizeGateMode, parseSizeGateMode, parseMaxSlices, SIZE_GATE_MODES, resolveEffectiveBuildRoute, resolveEffectiveModelPins, resolveLaneBreakerThreshold, resolveEnvironmentProxy, resolveArtifactsDir, resolveLocalOnlyPolicy, resolveMergePolicy, resolveProcessGroupGraceMs, resolveSandboxPolicy, resolveSkipCI, resolveTimeouts, resolveUsageCap, resolveWatchdogPolicy, runAutoIngest, shipPhase, formatCostTotal, validateQueue, watchUsage, writeLocalRunReport, writeProxyState, } from '@on-par/factory-core';
12
- import { acquirePidFile, createCheckResumeStore, findSlicePlanComment, recordSliceMerged, upsertSlicePlanComment, withSliceState, DEFAULT_GARDEN_MAX_CLUSTERS, DEFAULT_GARDEN_SINCE, capGardenClusters, clusterGarden, dedupGardenClusters, filterGardenOnlyNew, filterGardenWindow, markGardenTrackingUnknown, parseGardenDuration, readHarvestEvents, renderGardenJson, renderGardenReport, formatWorktreeLocation, laneWorktreePath, resolveWorktreeRoot, migrateRepoConfigToYaml, REPO_CONFIG_YAML_HEADER, claudeConfigDirOverride, claudeKeychainService, cleanupWorktree, createDaemonLogSink, factoryBranchIssue, factoryBranchPrefixes, createDockerEngine, createFactorydServer, createRunRuntime, createShipExecutor, createGithubQueue, createLocalSmallDryRun, createOctokitFilingClient, createOctokitGreenPrClient, createOctokitQueueClient, daemonRuntimePaths, defaultRegistryPath, ensureDir, FACTORY_RUNTIME_CONFIG_KEYS, findUnmergedGreenPrs, formatGcReport, gitFetch, listMicroVms, listOrphanContainers, logCost, logEvent, microVmName, planQueueMigration, readCosts, readGithubQueueSnapshot, reapLaneWorktree, reapOrphanContainers, reapOrphanMicroVm, releaseRuntimeFiles, releaseStaleClaims, resolveBranchPrefix, resolveEffectiveConfig, worktreeSandboxFor, resolveExperimental, resolveLocalOnly, resolvePrClassifierPolicy, resolveRunUsageHarness, resolveReviewFloorRules, RunLockHeldError, runOvernightQueue, parseRemoteSlug, setupWorktree, shellEscape, listBuiltInCheckers, slugify, sweepWorktrees, watchChecks, withAdmissionGuard, withFileLock, withGitLock, withRunLock, wrapCommandInSandbox, writePortFile, buildTriagePrompt, normalizeTriageQueue, parseTriageCandidates, codexChatgptSessionState, hasClaudeLoginEvidence, claudeStoredSessionState, localAuthFailureSinceLogin, recordLocalAuthFailure, hasOpencodeLoginEvidence, } from '@on-par/factory-core/internal';
10
+ import { acquirePortLease, aggregateCosts, appendKpiHistoryLine, applyRepoConfig, buildPhase, checkPhase, clearProxyState, computeHealthKpis, ConstitutionLoader, createDefaultWorkSourceRegistry, createLogger, createLaneProxy, defaultFindPortListeners, describeEffectiveConfig, detectPostMergeDefects, diagnoseModels, probeDeclaredHarnesses, estimateTrailingSpend, fetchDefectSources, fetchHumanEventSources, fetchSubscriptionUsage, formatUsageReport, getConstitutionsDir, getFactoryPaths, GITHUB_ISSUE_SOURCE, hasUnresolvedPark, InvalidArtifactsDirError, InvalidWorkRequestInputError, InvalidWorkspaceError, isCommandAvailable, isProxyRunning, kpiBranchPrefixes, kpisToHistoryRecord, laneBaseUrl, laneEnv, laneHostLabel, loadFactoryConfigForRepo, loadModelsConfig, loadRepoConfig, loadRoutesConfig, LOCAL_BRIEF_SOURCE, joinClassifierOutcomes, mergeClassifierOutcomes, parseClassifierOutcomes, summarizeClassifierOutcomes, formatClassifierReport, mergedPrRefs, ModelRegistry, ModelRouter, parkReasonFor, parseKpiHistory, parseQueue, planPhase, readEvents, readPortLeases, readQueue, readUsage, reapOrphanProcesses, reconstructKpiEvents, recordLeasePgid, releasePortLease, renderKpiReport, renderKpiTrend, resolveDefectWindowDays, parseSizeGateMode, parseMaxSlices, SIZE_GATE_MODES, resolveEffectiveModelPins, resolveEnvironmentProxy, resolveArtifactsDir, resolveLocalOnlyPolicy, resolveMergePolicy, resolveSandboxPolicy, resolveSkipCI, resolveTimeouts, resolveUsageCap, resolveWatchdogPolicy, runAutoIngest, shipPhase, formatCostTotal, validateQueue, watchUsage, writeLocalRunReport, writeProxyState, } from '@on-par/factory-core';
11
+ import { acquirePidFile, createCheckResumeStore, findSlicePlanComment, upsertSlicePlanComment, withSliceState, DEFAULT_GARDEN_MAX_CLUSTERS, DEFAULT_GARDEN_SINCE, capGardenClusters, clusterGarden, dedupGardenClusters, filterGardenOnlyNew, filterGardenWindow, markGardenTrackingUnknown, parseGardenDuration, readHarvestEvents, renderGardenJson, renderGardenReport, laneWorktreePath, migrateRepoConfigToYaml, REPO_CONFIG_YAML_HEADER, claudeConfigDirOverride, claudeKeychainService, cleanupWorktree, createDaemonLogSink, factoryBranchIssue, factoryBranchPrefixes, createFactorydServer, createRunRuntime, createShipExecutor, createGithubQueue, createLocalSmallDryRun, createOctokitFilingClient, createOctokitGreenPrClient, createOctokitQueueClient, daemonRuntimePaths, defaultRegistryPath, ensureDir, FACTORY_RUNTIME_CONFIG_KEYS, findUnmergedGreenPrs, formatGcReport, gitFetch, listMicroVms, listOrphanContainers, logCost, logEvent, planQueueMigration, readCosts, readGithubQueueSnapshot, reapLaneWorktree, reapOrphanContainers, reapOrphanMicroVm, releaseRuntimeFiles, releaseStaleClaims, resolveBranchPrefix, resolveEffectiveConfig, worktreeSandboxFor, resolveExperimental, resolveLocalOnly, resolvePrClassifierPolicy, resolveReviewFloorRules, RunLockHeldError, runOvernightQueue, parseRemoteSlug, setupWorktree, shellEscape, listBuiltInCheckers, sweepWorktrees, watchChecks, withFileLock, withGitLock, withRunLock, writePortFile, buildTriagePrompt, normalizeTriageQueue, parseTriageCandidates, recordLocalAuthFailure, } from '@on-par/factory-core/internal';
13
12
  import { runTui } from '@on-par/factory-tui';
14
13
  import { admissionStateReaderFor, runQueueReconcile } from './admission.js';
15
14
  import { cmdClassifierBacktest, ClassifierBacktestInputError, } from './classifier-backtest.js';
16
- import { findOpenPRForIssue, landIssue, waitForMerge } from './land.js';
15
+ import { findOpenPRForIssue, landIssue } from './land.js';
17
16
  import { AwaitingReviewError, CiFailedError, CiUnverifiedError, getPullRequestLandState, LandConflictError, LandFailureError, MergeGatedError, } from './land-pr.js';
18
17
  import { setLandRuntime } from './land-runtime.js';
18
+ import { cmdRun } from './run.js';
19
19
  import { shipIssue } from './ship.js';
20
+ import { cmdStatus } from './status.js';
20
21
  import { cmdSupervise } from './supervise.js';
21
22
  import { setShipRuntime } from './ship-runtime.js';
22
23
  import { runQueueClear } from './queue-clear.js';
@@ -27,14 +28,13 @@ import { buildQueueListJson } from './queue-list.js';
27
28
  import { runUnpark, UnparkError } from './unpark.js';
28
29
  import { buildRunsJson, parseRunsLimit } from './runs.js';
29
30
  import { buildRunDiffstatJson, NoRunError, parseDiffstatIssue } from './runs-diffstat.js';
30
- import { buildStatusJson } from './status-json.js';
31
- import { buildStatusUpdateJson, formatUpdateStartupLine, formatUpdateStatusLine, initialUpdateState, readUpdateState, updateStatePath, writeUpdateState, } from './update-state.js';
31
+ import { formatUpdateStartupLine, initialUpdateState, readUpdateState, updateStatePath, writeUpdateState, } from './update-state.js';
32
32
  import { buildStopResumeJson } from './stop-resume-json.js';
33
33
  import { buildUsageJson } from './usage-json.js';
34
34
  import { parseResetIssues, runReset } from './reset.js';
35
35
  import { cmdDaemonLogs, cmdDaemonStart, cmdDaemonStatus, cmdDaemonStop, createAutoUpdateDeps, DaemonCtlError, detectInstall, loadDaemonFileConfig, recoverStaleDrain, resolveFactorydConfig, runAutoUpdateLoop, } from './daemon.js';
36
36
  import { checkForUpdate, cmdSelfUpdateCheck } from './self-update.js';
37
- import { analyzeEventLog, claudeKeychainCheck, doctorFailed, eventLogCheck, formatClaimReconcileReport, formatContainerReconcileReport, formatDoctorChecks, formatMicroVmReconcileReport, formatReconcileReport, formatWorktreeReconcileReport, declaredHarnessChecks, hasSandboxRuntime, leaseChecks, keychainPreflightError, orphanContainerChecks, configLintChecks, modelsRegistryChecks, orphanMicroVmChecks, runDoctorChecks, sandboxAuthSkipDetail, reconcileAgentAuthChecks, repoCommandHarnesses, sandboxClaudeAuthChecks, unmergedGreenPrChecks, } from './doctor.js';
37
+ import { cmdDoctor } from './doctor.js';
38
38
  import { DOCS_URL, formatOverview, availableAgentClis, missingAgentCliMessage, missingTokenMessage, notInitializedMessage, } from './first-run.js';
39
39
  import { cmdHostedSmoke } from './hosted.js';
40
40
  import { cmdHostedQueue } from './hosted-queue.js';
@@ -48,11 +48,8 @@ import { selectOctokit } from './github-stub.js';
48
48
  import { createFactoryOctokit } from './octokit.js';
49
49
  import { DeepCheckError, createDeepModelRunner, runIssueCheck } from './ready-check.js';
50
50
  import { childRunSucceeded, formatChildRunSummary, runChildrenInOrder } from './run-children.js';
51
- import { createBaseHoldGate, formatBaseHoldCleared, formatBaseHoldStatus, readBaseHold, writeBaseHold, } from './base-hold.js';
52
- import { readRunFlagOverrides, writeRunFlagOverrides } from './run-flags.js';
53
- import { distFreshnessProbe, runStalenessGuard } from './staleness.js';
54
- import { readStopFileStatus, stopSentinelCheck, stopSentinelRunSkipMessage } from './stop-sentinel.js';
55
- import { checkSweepHeartbeat, defaultSweepHeartbeatDeps, formatSweepHeartbeatStatusLine, sweepHeartbeatCheck, } from './sweep-heartbeat.js';
51
+ import { readRunFlagOverrides } from './run-flags.js';
52
+ import { runStalenessGuard } from './staleness.js';
56
53
  const DEFAULT_CONFIG_LOADERS = {
57
54
  loadModelsConfig,
58
55
  loadRoutesConfig,
@@ -1246,173 +1243,6 @@ function warnQueueDiagnostics(diagnostics) {
1246
1243
  console.error(styleText('yellow', `factory: queue ${d.message} — skipped`));
1247
1244
  }
1248
1245
  }
1249
- /** Formats the time since `lastActivityAt` as a short duration (e.g. `3m`) for the
1250
- * `factory status` `== Active ==` section's `[phase, <age> ago]` display (#1343). An
1251
- * active claim's heartbeat is always within DEFAULT_QUEUE_ACTIVITY_STALE_THRESHOLD_MS
1252
- * (15m), so minutes is the only unit that ever shows. */
1253
- function formatClaimAge(lastActivityAt, now) {
1254
- const ageMs = Math.max(0, now - Date.parse(lastActivityAt));
1255
- const minutes = Math.floor(ageMs / 60_000);
1256
- return minutes < 1 ? '<1m' : `${minutes}m`;
1257
- }
1258
- export async function cmdStatus(opts = {}) {
1259
- if (opts.json) {
1260
- // `--kpis` is deliberately ignored here: the JSON shape is fixed (#1101).
1261
- const repoRoot = await getRepoRoot();
1262
- const paths = getFactoryPaths(repoRoot);
1263
- if (!existsSync(paths.root))
1264
- throw new CliExitError(`factory: ${notInitializedMessage()}`, 2);
1265
- const ghRepo = await getGitHubRepo();
1266
- let active = [];
1267
- if (existsSync(paths.queue)) {
1268
- const { entries, diagnostics } = parseQueue(readFileSync(paths.queue, 'utf-8'));
1269
- if (entries.length > 0)
1270
- ({ active } = await partitionLocalQueueByActivity(entries, paths.runs));
1271
- warnQueueDiagnostics(diagnostics);
1272
- }
1273
- console.log(JSON.stringify(buildStatusJson({
1274
- repo: ghRepo,
1275
- product: readActiveProduct(paths.product) ?? null,
1276
- stop: existsSync(paths.stop),
1277
- breakers: await new ProviderBreaker(paths.breaker).list(),
1278
- active,
1279
- update: buildStatusUpdateJson(readUpdateState(updateStatePath(daemonRuntimePaths().dir))),
1280
- now: Date.now(),
1281
- })));
1282
- return;
1283
- }
1284
- const repoRoot = await getRepoRoot();
1285
- const ghRepo = await getGitHubRepo();
1286
- const paths = getFactoryPaths(repoRoot);
1287
- const repoConfig = configLoaders.loadRepoConfig(repoRoot);
1288
- const modelsConfig = applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig);
1289
- const routesConfig = configLoaders.loadRoutesConfig();
1290
- const effective = resolveEffectiveConfig(repoConfig);
1291
- const router = new coreOps.ModelRouter(modelsConfig, routesConfig, false, undefined, effective.allowExperimental, effective.localOnly);
1292
- router.setLocalAuthSink?.(recordLocalAuthFailure);
1293
- const product = readActiveProduct(paths.product) ?? '(none)';
1294
- console.log(styleText('bold', `== ${ghRepo} ==`));
1295
- console.log(`Product: ${product}`);
1296
- const worktreeParent = configLoaders.loadFactoryConfigForRepo(paths.config).worktree.parent;
1297
- console.log(formatWorktreeLocation(resolveWorktreeRoot({ repoRoot, parent: worktreeParent, repo: ghRepo }), worktreeParent));
1298
- console.log(styleText('bold', '\n== Active =='));
1299
- if (existsSync(paths.queue)) {
1300
- const { entries, diagnostics } = parseQueue(readFileSync(paths.queue, 'utf-8'));
1301
- if (entries.length > 0) {
1302
- const { active, staleCount } = await partitionLocalQueueByActivity(entries, paths.runs);
1303
- if (active.length === 0) {
1304
- console.log(' (idle — no active claims)');
1305
- }
1306
- else {
1307
- const now = Date.now();
1308
- for (const e of active) {
1309
- console.log(` ${e.lane} ${e.issue} [${e.phase}, ${formatClaimAge(e.lastActivityAt, now)} ago]`);
1310
- }
1311
- }
1312
- if (staleCount > 0) {
1313
- console.log(` (${staleCount} stale entr${staleCount === 1 ? 'y' : 'ies'} hidden — no recent activity)`);
1314
- }
1315
- }
1316
- else if (diagnostics.length === 0) {
1317
- console.log(' (idle — no active claims)');
1318
- }
1319
- warnQueueDiagnostics(diagnostics);
1320
- }
1321
- else {
1322
- console.log(' (no queue file)');
1323
- }
1324
- const baseHold = readBaseHold(paths.baseHold);
1325
- if (baseHold !== null && baseHold.repo === ghRepo) {
1326
- console.log(styleText('bold', '\n== Base hold =='));
1327
- for (const line of formatBaseHoldStatus(baseHold))
1328
- console.log(line);
1329
- }
1330
- console.log(styleText('bold', '\n== Queue =='));
1331
- if (!hasGitHubToken()) {
1332
- console.log(' (no GitHub token — run `gh auth login`)');
1333
- }
1334
- else {
1335
- const [owner, repoName] = ghRepo.split('/');
1336
- const githubQueue = createGithubQueue({ client: createOctokitQueueClient(getOctokit()), owner, repo: repoName });
1337
- try {
1338
- const lanes = await githubQueue.lanes();
1339
- let printed = false;
1340
- for (const lane of lanes) {
1341
- const issues = await githubQueue.list(lane);
1342
- for (const issue of issues) {
1343
- console.log(` ${lane} #${issue}`);
1344
- printed = true;
1345
- }
1346
- }
1347
- if (!printed) {
1348
- console.log(' (no claimable work)');
1349
- }
1350
- }
1351
- catch (err) {
1352
- console.log(` (queue lookup failed — ${errorDetail(err)})`);
1353
- }
1354
- }
1355
- console.log(styleText('bold', '\n== Health =='));
1356
- const update = buildStatusUpdateJson(readUpdateState(updateStatePath(daemonRuntimePaths().dir)));
1357
- console.log(` ${formatUpdateStatusLine(update)}`);
1358
- if (opts.kpis) {
1359
- console.log(styleText('bold', '\n Effective config:'));
1360
- for (const line of describeEffectiveConfig({
1361
- router,
1362
- repo: repoConfig,
1363
- repoConfigPath: '.factory/config.json',
1364
- mergePolicy: resolveMergePolicy(configLoaders.loadFactoryConfigForRepo(paths.config), process.env, {
1365
- auto: readRunFlagOverrides(paths.runFlags).autoMerge,
1366
- }),
1367
- prClassifier: resolvePrClassifierPolicy(configLoaders.loadFactoryConfigForRepo(paths.config), process.env, {
1368
- classifier: readRunFlagOverrides(paths.runFlags).prClassifier,
1369
- }),
1370
- })) {
1371
- console.log(` ${line}`);
1372
- }
1373
- }
1374
- else {
1375
- console.log('\n (Effective config and KPIs hidden — run `factory status --kpis` to view)');
1376
- }
1377
- console.log(styleText('bold', '\n Provider breaker:'));
1378
- const openBreakers = await new ProviderBreaker(paths.breaker).list();
1379
- if (openBreakers.length === 0) {
1380
- console.log(' (closed)');
1381
- }
1382
- else {
1383
- for (const b of openBreakers) {
1384
- console.log(` ${b.provider}: OPEN (${b.reason}) — ${Math.ceil(b.remainingMs / 60_000)}m remaining`);
1385
- }
1386
- }
1387
- console.log(styleText('bold', '\n Sweep heartbeat:'));
1388
- const sweepStatus = checkSweepHeartbeat(configLoaders.loadFactoryConfigForRepo(paths.config).sweep, process.env, defaultSweepHeartbeatDeps());
1389
- console.log(` ${formatSweepHeartbeatStatusLine(sweepStatus)}`);
1390
- console.log(styleText('bold', '\n Last Events:'));
1391
- if (existsSync(paths.events)) {
1392
- const events = readFileSync(paths.events, 'utf-8').trim().split('\n').slice(-12);
1393
- for (const e of events) {
1394
- try {
1395
- const ev = JSON.parse(e);
1396
- console.log(` ${ev.type} #${ev.issue}: ${ev.msg}`);
1397
- }
1398
- catch { }
1399
- }
1400
- }
1401
- else {
1402
- console.log(' (none)');
1403
- }
1404
- if (opts.kpis) {
1405
- console.log(styleText('bold', '\n KPIs:'));
1406
- const kpiEvents = existsSync(paths.events) ? readEvents(paths.events) : [];
1407
- const kpiCosts = existsSync(paths.costs) ? internalOps.readCosts(paths.costs) : [];
1408
- for (const line of formatKpiLines(computeHealthKpis(kpiEvents, kpiCosts))) {
1409
- console.log(` ${line}`);
1410
- }
1411
- }
1412
- if (existsSync(paths.stop)) {
1413
- console.log(styleText('red', '\n!! STOP file present — factory halting between issues'));
1414
- }
1415
- }
1416
1246
  /** GitHub is polled far less often than local files: a queue changes on the order of minutes. */
1417
1247
  const TUI_GITHUB_QUEUE_POLL_MS = 30_000;
1418
1248
  /** The Queue tab's backlog source for `factory tui` (#1362). Mirrors `factory run`: GitHub Issues
@@ -2685,194 +2515,9 @@ function usageFlagOverrides(opts, threshold) {
2685
2515
  pollSeconds: opts.usagePoll === undefined ? undefined : Number(opts.usagePoll),
2686
2516
  };
2687
2517
  }
2688
- async function cmdRun(opts = {}) {
2689
- const repoRoot = await getRepoRoot();
2690
- const paths = getFactoryPaths(repoRoot);
2691
- const branchPrefix = branchPrefixOrExit(opts.branchPrefix);
2692
- return withRepoRunLock(paths, 'factory run', async () => {
2693
- const stopStatus = readStopFileStatus(paths);
2694
- if (stopStatus.present) {
2695
- const msg = stopSentinelRunSkipMessage(stopStatus);
2696
- console.log(styleText('yellow', `!! ${msg}`));
2697
- logEvent(paths.events, 'stopped', 'all', msg);
2698
- return;
2699
- }
2700
- writeRunFlagOverrides(paths.runFlags, { autoMerge: opts.autoMerge, prClassifier: opts.prClassifier });
2701
- const ghRepo = await getGitHubRepo();
2702
- const factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
2703
- const keychainErr = keychainPreflightError(probeClaudeKeychain());
2704
- if (keychainErr) {
2705
- logEvent(paths.events, 'environment_warning', 'all', keychainErr);
2706
- throw new Error(keychainErr);
2707
- }
2708
- await runAutoWorktreeGc(repoRoot, paths, ghRepo, 'all');
2709
- const { lanes, diagnostics } = await planRunLanes({
2710
- localQueue: opts.localQueue === true,
2711
- readLocalQueue: () => (existsSync(paths.queue) ? readFileSync(paths.queue, 'utf-8') : null),
2712
- queue: () => {
2713
- const [owner, repo] = ghRepo.split('/');
2714
- const octokit = getOctokit();
2715
- return createGithubQueue({
2716
- client: createOctokitQueueClient(octokit),
2717
- owner,
2718
- repo,
2719
- preflight: withAdmissionGuard({
2720
- reader: admissionStateReaderFor(paths.state),
2721
- preflight: (issue) => preflightQueuedIssue(issue, createQueuePreflightOps(octokit, owner, repo, {
2722
- autoMergeStack: resolveSizeGateMode(factoryConfig) === 'stack' &&
2723
- resolveMergePolicy(factoryConfig, process.env, { auto: opts.autoMerge }).auto,
2724
- })),
2725
- onConflict: (issue, verdict) => {
2726
- const line = `#${issue} refused — ${verdict.reason} | repair: ${verdict.repair}`;
2727
- console.error(styleText('yellow', `factory: ${line}`));
2728
- logEvent(paths.events, 'queue_admission_conflict', String(issue), line);
2729
- },
2730
- }),
2731
- });
2732
- },
2733
- });
2734
- warnQueueDiagnostics(diagnostics);
2735
- let knobs;
2736
- let laneBreakerThreshold;
2737
- try {
2738
- knobs = resolveUsageKnobs(process.env, configLoaders.loadRepoConfig(repoRoot), usageFlagOverrides(opts, 'stopAt'));
2739
- laneBreakerThreshold = resolveLaneBreakerThreshold(configLoaders.loadRepoConfig(repoRoot));
2740
- }
2741
- catch (err) {
2742
- throw new CliExitError(`factory: ${err.message}`, 2);
2743
- }
2744
- if (knobs.watchSource === 'flag') {
2745
- const detail = `usage watchdog ${knobs.watch ? 'enabled' : 'disabled'} by ${usageWatchSourceLabel(knobs.watchSource, knobs.watch)}`;
2746
- console.log(`[factory] run: ${detail}`);
2747
- logEvent(paths.events, 'watchdog', 'all', detail);
2748
- }
2749
- if (knobs.stopAtSource === 'flag') {
2750
- const detail = 'usage watchdog threshold set by flag: --usage-threshold';
2751
- console.log(`[factory] run: ${detail}`);
2752
- logEvent(paths.events, 'watchdog', 'all', detail);
2753
- }
2754
- if (knobs.pollSource === 'flag') {
2755
- const detail = 'usage watchdog poll set by flag: --usage-poll';
2756
- console.log(`[factory] run: ${detail}`);
2757
- logEvent(paths.events, 'watchdog', 'all', detail);
2758
- }
2759
- let watchdogHarness;
2760
- if (knobs.watch) {
2761
- try {
2762
- const repoConfig = configLoaders.loadRepoConfig(repoRoot);
2763
- const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), repoConfig));
2764
- const pins = configLoaders.resolveEffectiveModelPins(registry, repoConfig, process.env);
2765
- const build = resolveEffectiveBuildRoute(registry, repoConfig, pins);
2766
- watchdogHarness = resolveRunUsageHarness({
2767
- buildRoute: build.route,
2768
- buildRouteSource: build.source,
2769
- planHarnesses: [pins.plan, pins.planFallback]
2770
- .filter((m) => !!m)
2771
- .map((m) => registry.getHarnessId(m)),
2772
- codexDisabled: resolveCodexDisabled(repoConfig),
2773
- localOnly: resolveLocalOnly(),
2774
- });
2775
- }
2776
- catch {
2777
- watchdogHarness = undefined; // unresolvable config keeps today's Claude gating
2778
- }
2779
- }
2780
- const controller = new AbortController();
2781
- const watchdog = knobs.watch
2782
- ? coreOps
2783
- .watchUsage({
2784
- cap: knobs.cap,
2785
- stopAt: knobs.stopAt,
2786
- pollMs: knobs.pollMs,
2787
- stopFile: paths.stop,
2788
- eventsFile: paths.events,
2789
- signal: controller.signal,
2790
- estimator: knobs.estimator,
2791
- ...(watchdogHarness ? { harness: watchdogHarness } : {}),
2792
- })
2793
- .catch((err) => {
2794
- // a watchdog crash must never take down the run
2795
- logEvent(paths.events, 'warn', '-', `usage watchdog crashed: ${err.message}`);
2796
- })
2797
- : Promise.resolve();
2798
- const { proxy } = await startLaneProxy(paths, resolveEnvironmentProxy(factoryConfig));
2799
- const workspaceBackend = factoryConfig.workspace?.backend ?? 'host';
2800
- const runId = randomUUID();
2801
- const containerEngine = workspaceBackend === 'disposable-docker' ? createDockerEngine({}) : undefined;
2802
- try {
2803
- // Run lanes in parallel
2804
- const pids = [];
2805
- for (const planned of lanes) {
2806
- logEvent(paths.events, 'lane-start', '-', `lane '${planned.lane}' started${planned.issues.length ? ` (${planned.issues.length} issues)` : ''}`, { lane: planned.lane });
2807
- if (containerEngine) {
2808
- const provision = await provisionLaneContainer(containerEngine, workspaceBackend, runId, slugify(planned.lane), ghRepo);
2809
- if (provision.attempted && !provision.created) {
2810
- logEvent(paths.events, 'warn', '-', `disposable-docker container creation failed for lane '${planned.lane}': ${provision.error}`, { lane: planned.lane });
2811
- }
2812
- else if (provision.attempted && provision.created && provision.workspaceCloned === false) {
2813
- logEvent(paths.events, 'warn', '-', `disposable-docker workspace clone failed for lane '${planned.lane}': ${provision.workspaceError}`, { lane: planned.lane });
2814
- }
2815
- }
2816
- pids.push(runLane(planned.lane, planned.issues, repoRoot, ghRepo, paths, {
2817
- ...planned.deps,
2818
- laneBreakerThreshold,
2819
- baseHoldGate: opts.localQueue === true
2820
- ? undefined
2821
- : createBaseHoldGate({
2822
- file: paths.baseHold,
2823
- repo: ghRepo,
2824
- onCleared: (c) => logEvent(paths.events, 'base-hold-cleared', planned.lane, formatBaseHoldCleared(c), {
2825
- lane: planned.lane,
2826
- }),
2827
- git: (cmd) => shellOps.exec(cmd, { cwd: repoRoot }).then((r) => r.stdout),
2828
- listCheckRuns: (ref) => {
2829
- const [owner, repoName] = ghRepo.split('/');
2830
- return createOctokitGreenPrClient(getOctokit()).listCheckRuns({ owner, repo: repoName, ref });
2831
- },
2832
- }),
2833
- commentIssue: async (issue, body) => {
2834
- const [owner, repoName] = ghRepo.split('/');
2835
- await getOctokit().rest.issues.createComment({ owner, repo: repoName, issue_number: issue, body });
2836
- },
2837
- ship: (issue, o, c) => shipIssue(issue, { ...o, branchPrefix, prClassifier: opts.prClassifier }, c),
2838
- sweepFinished: (issue) => sweepBetweenLaneIssues(issue, planned.lane, repoRoot, ghRepo, paths).then(() => { }),
2839
- reapWorktree: (issue) => reapParkedLaneWorktree(issue, repoRoot, ghRepo, paths, opts.branchPrefix === undefined ? undefined : branchPrefix),
2840
- recordStackMerge: (issue, prNumber) => recordSliceMerged({ octokit: getOctokit(), repo: ghRepo, issue, prNumber }).then(() => { }),
2841
- waitMerge: (issue, branch, root, gh, p, d = {}) => waitForMerge(issue, branch, root, gh, p, { ...d, mergeOverrides: { auto: opts.autoMerge } }),
2842
- }));
2843
- }
2844
- await Promise.allSettled(pids);
2845
- controller.abort();
2846
- await watchdog;
2847
- logEvent(paths.events, 'run-done', 'all', 'all lanes finished');
2848
- }
2849
- finally {
2850
- if (proxy) {
2851
- await proxy.stop();
2852
- clearProxyState(paths.proxyState);
2853
- }
2854
- }
2855
- if (lanes.length > 0) {
2856
- try {
2857
- const events = existsSync(paths.events) ? readEvents(paths.events) : [];
2858
- const costs = existsSync(paths.costs) ? internalOps.readCosts(paths.costs) : [];
2859
- const recon = await reconstructHumanEventsForKpis(events, branchPrefix);
2860
- const kpis = computeHealthKpis(recon.events, costs);
2861
- await appendKpiSnapshot(paths, repoRoot, kpis, { partial: recon.partial });
2862
- if (recon.partial) {
2863
- logEvent(paths.events, 'warn', 'all', `kpi snapshot partial: GitHub human-event reconstruction failed: ${recon.error}`);
2864
- }
2865
- logEvent(paths.events, 'kpi-snapshot', 'all', `KPI snapshot appended to ${paths.kpiHistory}`);
2866
- }
2867
- catch (err) {
2868
- logEvent(paths.events, 'warn', 'all', `kpi snapshot failed: ${err.message}`);
2869
- }
2870
- }
2871
- });
2872
- }
2873
2518
  /** Wraps cmdRun so an empty queue is a no-op (not a throw) while auto-ingest is enabled and watching. */
2874
2519
  export function createSuperviseRunQueue(paths, ingestCfg, deps = {}) {
2875
- const { cmdRunFn = cmdRun, readQueueFile = () => (existsSync(paths.queue) ? readFileSync(paths.queue, 'utf-8') : ''), pendingCount = async () => parseQueue(readQueueFile()).entries.length, emitEvent = logEvent, } = deps;
2520
+ const { cmdRunFn = runRun, readQueueFile = () => (existsSync(paths.queue) ? readFileSync(paths.queue, 'utf-8') : ''), pendingCount = async () => parseQueue(readQueueFile()).entries.length, emitEvent = logEvent, } = deps;
2876
2521
  return async () => {
2877
2522
  if ((await pendingCount()) === 0 && ingestCfg.enabled) {
2878
2523
  emitEvent(paths.events, 'idle', 'auto', 'queue empty — waiting for ready issues');
@@ -3105,234 +2750,6 @@ export async function planRunLanes(input) {
3105
2750
  diagnostics: [],
3106
2751
  };
3107
2752
  }
3108
- /** How often an in-flight issue's claim lease is refreshed (#1500). Well inside the
3109
- * 15-minute default lease (github-queue.ts's DEFAULT_CLAIM_LEASE_MS) so a normal poll
3110
- * cadence never lets the lease lapse mid-ship. */
3111
- const HEARTBEAT_INTERVAL_MS = 5 * 60 * 1000;
3112
- /** Runs `work` while periodically calling `heartbeat(issue)` so a long-running ship or
3113
- * merge wait keeps this claimant's lease fresh. A heartbeat failure is swallowed — losing
3114
- * one refresh must never abort the work it is protecting. */
3115
- async function withHeartbeat(issue, heartbeat, work) {
3116
- if (!heartbeat)
3117
- return work();
3118
- const timer = setInterval(() => {
3119
- heartbeat(issue).catch(() => { });
3120
- }, HEARTBEAT_INTERVAL_MS);
3121
- try {
3122
- return await work();
3123
- }
3124
- finally {
3125
- clearInterval(timer);
3126
- }
3127
- }
3128
- export function renderLanePausedComment(payload) {
3129
- const checks = payload.failingChecks.length
3130
- ? payload.failingChecks.map((c) => `\`${c}\``).join(', ')
3131
- : 'none recorded';
3132
- return [
3133
- `Lane '${payload.lane}' is paused: this issue and #${payload.firstIssue} parked with the same failure signature, so the factory stopped claiming issues on this lane.`,
3134
- `Shared failing checks: ${checks}`,
3135
- `To resume: fix the shared failure (often on the base branch), then re-run \`factory run\` to resume lane '${payload.lane}'.`,
3136
- `Failure signature: \`${payload.signature}\``,
3137
- ].join('\n\n');
3138
- }
3139
- export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {}) {
3140
- const { ship = shipIssue, waitMerge = waitForMerge, recordStackMerge = async () => { }, pathExists = existsSync, emitEvent = logEvent, claimNext = async () => null, releaseIssue = async () => { }, heartbeat, reapWorktree = async () => { }, sweepFinished = async () => { }, countRemaining, baseHoldGate, laneBreakerThreshold = 0, commentIssue = async () => { }, } = deps;
3141
- let streak = null;
3142
- let merged = 0;
3143
- let awaitingReview = 0;
3144
- let skipped = 0;
3145
- let decomposed = 0;
3146
- let parked = 0;
3147
- const buildClaim = (issue) => ({ issue, decision: { kind: 'build' } });
3148
- const pending = issues.map(buildClaim);
3149
- const seen = new Set(issues);
3150
- // Truthful park-time remaining count (#1222): local pending plus the lane's still-queued
3151
- // remote issues. Fail-closed — a count lookup may never change the message beyond the
3152
- // local truth, and never lane flow.
3153
- const remainingAfter = async (index) => {
3154
- const local = pending.length - index - 1;
3155
- if (!countRemaining)
3156
- return local;
3157
- try {
3158
- return local + (await countRemaining());
3159
- }
3160
- catch {
3161
- return local;
3162
- }
3163
- };
3164
- // Every terminal outcome releases the claim, then gives finished worktrees a GC pass before
3165
- // the lane moves on (#1757). The pass is fail-closed and must never change lane flow.
3166
- const settle = async (issue, outcome) => {
3167
- await releaseIssue(issue, outcome);
3168
- await sweepFinished(issue).catch(() => { });
3169
- };
3170
- for (let i = 0;; i++) {
3171
- if (i >= pending.length) {
3172
- // A persisted base hold on the unchanged origin/main SHA means the base is still broken
3173
- // (#2349): claiming would just cycle an issue again, so stop this lane without claiming.
3174
- const gate = baseHoldGate ? await baseHoldGate().catch(() => ({ kind: 'claim' })) : null;
3175
- if (gate?.kind === 'hold') {
3176
- emitEvent(paths.events, 'warn', lane, `lane '${lane}' not claiming: base hold on ${gate.hold.baseSha.slice(0, 8)} (${gate.hold.failingChecks.join(', ')}) — origin/main unchanged and its CI is not green`, { lane });
3177
- return;
3178
- }
3179
- const claimed = await claimNext();
3180
- if (claimed === null)
3181
- break;
3182
- seen.add(claimed.issue);
3183
- pending.push(claimed);
3184
- }
3185
- const { issue, decision } = pending[i];
3186
- if (pathExists(paths.stop)) {
3187
- emitEvent(paths.events, 'stopped', issue, 'STOP file present', { lane });
3188
- await releaseIssue(issue, 'queued');
3189
- return;
3190
- }
3191
- if (decision.kind === 'park') {
3192
- emitEvent(paths.events, 'escalate', issue, decision.reason, { lane });
3193
- emitEvent(paths.events, 'parked', issue, `issue #${issue} parked (${decision.reason}); lane '${lane}' continuing, ${await remainingAfter(i)} issue(s) remaining`, { lane });
3194
- // A preflight-parked issue may still have a stale worktree from a prior run; the reap
3195
- // is fail-closed and must never change lane flow.
3196
- await reapWorktree(issue).catch(() => { });
3197
- await settle(issue, 'parked');
3198
- parked++;
3199
- streak = null;
3200
- continue;
3201
- }
3202
- const layer = decision.kind === 'adopt' ? decision.stackLayer : undefined;
3203
- try {
3204
- const sliceOutcome = {};
3205
- const branch = decision.kind === 'adopt'
3206
- ? decision.branch
3207
- : await withHeartbeat(issue, heartbeat, () => ship(issue, {}, {
3208
- repoRoot,
3209
- ghRepo,
3210
- paths,
3211
- lane,
3212
- onOutcome: (o) => {
3213
- sliceOutcome.value = o;
3214
- },
3215
- }));
3216
- const shipped = sliceOutcome.value;
3217
- if (shipped?.state === 'ready' && shipped.slice && shipped.slice.index < shipped.slice.count) {
3218
- // ADR-0147: a non-final slice's PR is open; hand the issue back for the next slice
3219
- // instead of waiting for the merge.
3220
- awaitingReview++;
3221
- streak = null;
3222
- await settle(issue, 'queued');
3223
- continue;
3224
- }
3225
- if (layer) {
3226
- // ADR-0161: land exactly this layer's branch, record it, and re-queue for the next layer.
3227
- await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths, { landBranch: branch }));
3228
- await recordStackMerge(issue, layer.prNumber).catch((err) => emitEvent(paths.events, 'warn', issue, `slice ${layer.index}/${layer.count} merged but recording it failed: ${errorDetail(err)}`, { lane }));
3229
- merged++;
3230
- streak = null;
3231
- await settle(issue, layer.index < layer.count ? 'queued' : 'done');
3232
- continue;
3233
- }
3234
- await withHeartbeat(issue, heartbeat, () => waitMerge(issue, branch, repoRoot, ghRepo, paths));
3235
- merged++;
3236
- streak = null;
3237
- await settle(issue, 'done');
3238
- }
3239
- catch (err) {
3240
- if (err instanceof AwaitingReviewError) {
3241
- // The land path already emitted the awaiting-review event and cleaned the
3242
- // worktree — this is a clean outcome, not a park; move to the next issue.
3243
- awaitingReview++;
3244
- streak = null;
3245
- await settle(issue, layer && layer.index < layer.count ? 'queued' : 'done');
3246
- continue;
3247
- }
3248
- if (err instanceof IssueSkippedError) {
3249
- // shipIssue already emitted skipped-already-closed; nothing was attempted.
3250
- skipped++;
3251
- await settle(issue, 'done');
3252
- continue;
3253
- }
3254
- if (err instanceof IssueDecomposedError) {
3255
- // shipIssue already rewrote the queue — splice the children in after the
3256
- // current issue so this same lane run ships them, deduping ones already queued.
3257
- const fresh = err.childIssues.filter((n) => !seen.has(n));
3258
- for (const n of fresh)
3259
- seen.add(n);
3260
- pending.splice(i + 1, 0, ...fresh.map(buildClaim));
3261
- decomposed++;
3262
- emitEvent(paths.events, 'decompose_filed', issue, `lane '${lane}' continuing with ${fresh.length} child issue(s) in place of #${issue}`, { lane });
3263
- await settle(issue, 'done');
3264
- continue;
3265
- }
3266
- if (err instanceof LaneEnvironmentPauseError) {
3267
- // The base is broken, not the issue (#1928): release to queued (never parked),
3268
- // pause the lane whatever laneBreakerThreshold is, and stop claiming.
3269
- streak = null;
3270
- // A preserved build is kept for `factory resume --from check` (#2564).
3271
- if (!err.buildPreserved)
3272
- await reapWorktree(issue).catch(() => { });
3273
- await settle(issue, 'queued');
3274
- // Persist the hold so a restarted supervisor remembers the broken base (#2348);
3275
- // best-effort, never allowed to change lane flow.
3276
- try {
3277
- writeBaseHold(paths.baseHold, {
3278
- repo: ghRepo,
3279
- baseSha: err.baseSha,
3280
- failingChecks: err.failingChecks,
3281
- since: new Date().toISOString(),
3282
- });
3283
- }
3284
- catch { }
3285
- const payload = {
3286
- lane,
3287
- signature: err.failureSignature ?? '',
3288
- failingChecks: err.failingChecks,
3289
- firstIssue: issue,
3290
- secondIssue: issue,
3291
- cause: 'environment',
3292
- baseSha: err.baseSha,
3293
- };
3294
- emitEvent(paths.events, 'lane-paused', lane, `lane '${lane}' paused: #${issue} failed only on checkers that also fail on base ${err.baseSha.slice(0, 8)} (${err.failingChecks.join(', ')}); #${issue} released to queued`, { lane, lanePaused: payload });
3295
- return;
3296
- }
3297
- const reason = parkReasonFor(err);
3298
- // Terminal reason events (escalate/timeout/fail/conflict) are emitted exactly
3299
- // once by the layer that detects the failure — shipIssue for pipeline failures,
3300
- // the land path for merge failures. runLane owns only lane-lifecycle events
3301
- // (stopped/parked/lane-done), so injected ship functions never change ownership.
3302
- const signature = err instanceof LaneParkError ? err.failureSignature : undefined;
3303
- const checks = err instanceof LaneParkError ? (err.failingChecks ?? []) : [];
3304
- if (!signature)
3305
- streak = null;
3306
- else if (streak && streak.signature === signature)
3307
- streak.count++;
3308
- else
3309
- streak = { signature, firstIssue: issue, failingChecks: checks, count: 1 };
3310
- const tripped = laneBreakerThreshold > 0 && streak !== null && streak.count >= laneBreakerThreshold;
3311
- emitEvent(paths.events, 'parked', issue, `issue #${issue} parked (${reason}); ${tripped ? `lane '${lane}' paused (lane breaker)` : `lane '${lane}' continuing`}, ${await remainingAfter(i)} issue(s) remaining`, { lane });
3312
- // Remove the parked lane's own worktree so a parked issue stops leaving a sibling
3313
- // checkout behind (#1007); fail-closed and never allowed to change lane flow.
3314
- await reapWorktree(issue).catch(() => { });
3315
- await settle(issue, 'parked');
3316
- parked++;
3317
- if (tripped && streak) {
3318
- const { signature: tripSignature, firstIssue, failingChecks: firstChecks } = streak;
3319
- const payload = {
3320
- lane,
3321
- signature: tripSignature,
3322
- failingChecks: checks.filter((c) => firstChecks.includes(c)),
3323
- firstIssue,
3324
- secondIssue: issue,
3325
- };
3326
- emitEvent(paths.events, 'lane-paused', lane, `lane '${lane}' paused: #${payload.firstIssue} and #${payload.secondIssue} parked with the same failure signature (${payload.failingChecks.join(', ') || 'no failing checks'})`, { lane, lanePaused: payload });
3327
- // Tell the developer on GitHub why the lane stopped (#1919); fail-closed, never changes lane flow.
3328
- await commentIssue(payload.secondIssue, renderLanePausedComment(payload)).catch(() => { });
3329
- return;
3330
- }
3331
- continue;
3332
- }
3333
- }
3334
- emitEvent(paths.events, 'lane-done', lane, `lane complete (${merged} merged, ${awaitingReview} awaiting review, ${skipped} skipped, ${decomposed} decomposed, ${parked} parked)`, { lane });
3335
- }
3336
2753
  /** The target issue was already resolved before the run started — a clean skip,
3337
2754
  * not a park. Callers advance to the next queue entry. (#681) */
3338
2755
  export class IssueSkippedError extends Error {
@@ -3555,220 +2972,6 @@ function probeClaudeKeychain() {
3555
2972
  return { status: 'unreadable', inTmux: !!process.env.TMUX, ...profile };
3556
2973
  }
3557
2974
  }
3558
- async function cmdDoctor(opts = {}) {
3559
- let repoRoot;
3560
- try {
3561
- repoRoot = shellOps
3562
- .execSync('git rev-parse --show-toplevel', {
3563
- timeout: 10_000,
3564
- stdio: ['ignore', 'pipe', 'ignore'],
3565
- })
3566
- .trim();
3567
- }
3568
- catch {
3569
- repoRoot = null;
3570
- }
3571
- const checks = runDoctorChecks({
3572
- commandAvailable: coreOps.isCommandAvailable,
3573
- envPresent: (key) => !!process.env[key],
3574
- tryExec: (cmd, opts) => {
3575
- try {
3576
- return shellOps
3577
- .execSync(cmd, {
3578
- timeout: opts?.timeoutMs ?? 10_000,
3579
- stdio: ['ignore', 'pipe', 'ignore'],
3580
- ...(opts?.env ? { env: { ...process.env, ...opts.env } } : {}),
3581
- })
3582
- .trim();
3583
- }
3584
- catch {
3585
- return null;
3586
- }
3587
- },
3588
- pathExists: existsSync,
3589
- distFreshness: distFreshnessProbe(import.meta.url),
3590
- codexSessionState: () => codexChatgptSessionState(),
3591
- claudeLoggedIn: () => hasClaudeLoginEvidence({
3592
- exec: (file, args) => {
3593
- shellOps.execSync([file, ...args].map(shellEscape).join(' '), { timeout: 10_000, stdio: 'ignore' });
3594
- },
3595
- fileExists: existsSync,
3596
- }),
3597
- claudeSessionState: () => claudeStoredSessionState(),
3598
- localAuthFailure: (agent) => localAuthFailureSinceLogin(agent),
3599
- opencodeLoggedIn: () => hasOpencodeLoginEvidence(),
3600
- commandHarnesses: () => repoRoot === null
3601
- ? []
3602
- : repoCommandHarnesses(configLoaders.loadModelsConfig().harnesses, configLoaders.loadRepoConfig(repoRoot)?.models?.registry),
3603
- });
3604
- // Only with --probe-inference: these send a real prompt (uses quota). Plain doctor makes no inference call.
3605
- if (opts.probeInference) {
3606
- const CLAUDE_AUTH_PROBE = 'claude -p "reply with exactly: ok"';
3607
- const probeExec = (cmd) => {
3608
- try {
3609
- return shellOps.execSync(cmd, { timeout: 120_000, stdio: ['ignore', 'pipe', 'ignore'] }).trim();
3610
- }
3611
- catch {
3612
- return null;
3613
- }
3614
- };
3615
- let host = 'skipped';
3616
- let hostDetail = 'skipped: claude CLI not on PATH';
3617
- if (coreOps.isCommandAvailable('claude')) {
3618
- host = probeExec(CLAUDE_AUTH_PROBE) === null ? 'failed' : 'ok';
3619
- hostDetail = host === 'ok' ? 'claude -p succeeded on the host' : 'claude -p failed on the host';
3620
- }
3621
- let sandboxed = 'skipped';
3622
- let sandboxDetail = 'skipped: host claude auth was not verified';
3623
- const runtimeAvailable = hasSandboxRuntime(process.platform, (cmd) => isCommandAvailableViaShell(shellOps, cmd));
3624
- const skip = sandboxAuthSkipDetail({ runtimeAvailable, hostOk: host === 'ok', inRepo: repoRoot !== null });
3625
- if (skip !== null) {
3626
- sandboxDetail = skip;
3627
- }
3628
- else if (repoRoot !== null) {
3629
- const policy = resolveSandboxPolicy(configLoaders.loadFactoryConfigForRepo(getFactoryPaths(repoRoot).config).sandbox, {
3630
- worktree: repoRoot,
3631
- repoRoot,
3632
- isAvailable: (cmd) => isCommandAvailableViaShell(shellOps, cmd),
3633
- });
3634
- if (!policy) {
3635
- sandboxDetail = 'skipped: sandbox disabled by config or FACTORY_SANDBOX';
3636
- }
3637
- else if (policy.runtime === 'none') {
3638
- sandboxDetail = 'skipped: no sandbox runtime (sandbox-exec/firejail) on this host';
3639
- }
3640
- else if (policy.runtime === 'docker-sandbox') {
3641
- sandboxDetail = 'skipped: docker-sandbox containment is the microVM, not command wrapping (#653)';
3642
- }
3643
- else {
3644
- sandboxed = probeExec(wrapCommandInSandbox(CLAUDE_AUTH_PROBE, policy)) === null ? 'failed' : 'ok';
3645
- sandboxDetail =
3646
- sandboxed === 'ok'
3647
- ? `claude -p succeeded under ${policy.runtime}`
3648
- : `claude -p failed under ${policy.runtime} (local_auth)`;
3649
- }
3650
- }
3651
- checks.push(...sandboxClaudeAuthChecks({ host, sandboxed, hostDetail, sandboxDetail }));
3652
- }
3653
- checks.push(claudeKeychainCheck(probeClaudeKeychain()));
3654
- checks.splice(0, checks.length, ...reconcileAgentAuthChecks(checks));
3655
- if (repoRoot !== null) {
3656
- const paths = getFactoryPaths(repoRoot);
3657
- let factoryConfig;
3658
- try {
3659
- factoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
3660
- }
3661
- catch {
3662
- factoryConfig = loadFactoryConfig(); // schema errors are reported by the config row (#2611)
3663
- }
3664
- const health = await inspectPortLeases({ registryFile: paths.ports });
3665
- checks.push(...leaseChecks(health.map(toLeaseRow)));
3666
- const dockerAvailable = coreOps.isCommandAvailable('docker');
3667
- const orphanContainers = dockerAvailable ? await internalOps.listOrphanContainers().catch(() => []) : [];
3668
- checks.push(...orphanContainerChecks(orphanContainers.map((c) => c.name)));
3669
- const sbxAvailable = coreOps.isCommandAvailable('sbx');
3670
- const activeVmNames = new Set(health.filter((h) => h.alive).map((h) => microVmName(h.lease.worktreeId)));
3671
- const orphanVmNames = sbxAvailable ? (await internalOps.listMicroVms()).filter((n) => !activeVmNames.has(n)) : [];
3672
- checks.push(...orphanMicroVmChecks(orphanVmNames));
3673
- const sweepStatus = checkSweepHeartbeat(factoryConfig.sweep, process.env, defaultSweepHeartbeatDeps());
3674
- const sweepCheck = sweepHeartbeatCheck(sweepStatus);
3675
- if (sweepCheck)
3676
- checks.push(sweepCheck);
3677
- checks.push(stopSentinelCheck(readStopFileStatus(paths)));
3678
- const eventsContent = existsSync(paths.events) ? readFileSync(paths.events, 'utf-8') : null;
3679
- checks.push(eventLogCheck(eventsContent === null ? null : analyzeEventLog(eventsContent)));
3680
- let constitution = null;
3681
- let loadError;
3682
- try {
3683
- constitution = new ConstitutionLoader().loadRepoConstitution(repoRoot, factoryConfig.paths?.constitution);
3684
- }
3685
- catch (err) {
3686
- loadError = `cannot parse constitution: ${err instanceof Error ? err.message : String(err)}`;
3687
- }
3688
- checks.push(...configLintChecks({ constitution, configFile: repoConfigFileFor(repoRoot, paths.config) }, loadError));
3689
- try {
3690
- const declared = Object.keys(configLoaders.loadModelsConfig().harnesses ?? {});
3691
- checks.push(...modelsRegistryChecks(paths.config, configLoaders.loadRepoConfig(repoRoot)?.models?.registry, [
3692
- ...KNOWN_HARNESS_IDS,
3693
- ...declared,
3694
- ]));
3695
- }
3696
- catch {
3697
- // malformed config is reported by the load paths / `factory models --doctor`; doctor keeps going
3698
- }
3699
- let harnessProbes = [];
3700
- try {
3701
- const registry = new coreOps.ModelRegistry(applyRepoConfig(configLoaders.loadModelsConfig(), configLoaders.loadRepoConfig(repoRoot)));
3702
- harnessProbes = coreOps.probeDeclaredHarnesses(registry);
3703
- }
3704
- catch {
3705
- // models config problems are reported by `factory models --doctor`; doctor keeps going
3706
- }
3707
- checks.push(...declaredHarnessChecks(harnessProbes));
3708
- // Full run-lifecycle reap (#998): dead-run worktrees and their branches. Uses the same
3709
- // GitHub evidence and the same lock pairing as `factory worktree gc` (cmdWorktreeGc) —
3710
- // a merged or closed PR is what makes a worktree reapable.
3711
- const ghRepo = await getGitHubRepo().catch(() => undefined);
3712
- const octokit = ghRepo && hasGitHubToken() ? getOctokit() : undefined;
3713
- // Green-and-ready PRs with no merge (#1000). Report only — doctor never lands a PR.
3714
- checks.push(...unmergedGreenPrChecks(await scanGreenPrs(ghRepo, octokit)));
3715
- if (opts.reconcile) {
3716
- if (orphanContainers.length > 0) {
3717
- const reapedContainers = await internalOps.reapOrphanContainers(orphanContainers);
3718
- console.log(formatContainerReconcileReport(reapedContainers));
3719
- }
3720
- if (orphanVmNames.length > 0) {
3721
- const reapedVms = await Promise.all(orphanVmNames.map((name) => internalOps.reapOrphanMicroVm(name)));
3722
- console.log(formatMicroVmReconcileReport(reapedVms));
3723
- }
3724
- const reaped = await reapStalePortLeases({ registryFile: paths.ports, lockDir: paths.portsLock });
3725
- console.log(formatReconcileReport(reaped));
3726
- if (reaped.length > 0) {
3727
- const graceMs = resolveProcessGroupGraceMs(factoryConfig);
3728
- const orphanEvents = await coreOps.reapOrphanProcesses({ reaped, graceMs });
3729
- for (const e of orphanEvents) {
3730
- 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' : ''}`);
3731
- }
3732
- }
3733
- try {
3734
- const gcLog = (type, msg) => logEvent(paths.events, type, '-', msg);
3735
- const gcSandbox = gcWorktreeSandbox(factoryConfig.sandbox, repoRoot);
3736
- const report = await withGitLock(repoRoot, () => withFileLock(paths.gitLock, () => internalOps.sweepWorktrees({
3737
- repoRoot,
3738
- ttlDays: factoryConfig.worktree.gcTtlDays,
3739
- repo: ghRepo,
3740
- branchPrefix: resolveBranchPrefix(),
3741
- }, { log: gcLog, octokit, sandbox: gcSandbox })));
3742
- console.log(formatWorktreeReconcileReport(report.removed));
3743
- }
3744
- catch (err) {
3745
- console.log(`reconcile: worktree sweep failed: ${err?.message ?? String(err)}`);
3746
- }
3747
- // Stale claim reap (#999): factory:in-progress claims naming a dead local pid go back to
3748
- // factory:queued so the next run can claim them with no manual label surgery.
3749
- try {
3750
- if (ghRepo && octokit) {
3751
- const [owner, repoName] = ghRepo.split('/');
3752
- const released = await internalOps.releaseStaleClaims({
3753
- client: createOctokitQueueClient(octokit),
3754
- owner,
3755
- repo: repoName,
3756
- });
3757
- console.log(formatClaimReconcileReport(released));
3758
- }
3759
- else {
3760
- console.log('reconcile: skipping stale claims: no GitHub repo or token');
3761
- }
3762
- }
3763
- catch (err) {
3764
- console.log(`reconcile: stale claim release failed: ${err?.message ?? String(err)}`);
3765
- }
3766
- }
3767
- }
3768
- console.log(formatDoctorChecks(checks, { inRepo: repoRoot !== null }));
3769
- if (doctorFailed(checks))
3770
- process.exit(1);
3771
- }
3772
2975
  /** Appends one event to the events log without echoing it to stdout (stop/resume --json must print only JSON). */
3773
2976
  function logEventQuietly(eventsFile, type, msg) {
3774
2977
  createLogger(eventsFile, { issue: '-' }, { out: { write: () => true } }).warn(type, msg);
@@ -3893,19 +3096,82 @@ function runSupervise(opts) {
3893
3096
  superviseLoop,
3894
3097
  createSuperviseRunQueue,
3895
3098
  createIngestHook,
3896
- cmdRun: (o) => cmdRun(o),
3099
+ cmdRun: (o) => runRun(o),
3100
+ CliExitError,
3101
+ });
3102
+ }
3103
+ /** `factory status` (#2798): the handler lives in ./status.ts; this binds its runtime. */
3104
+ function runStatus(opts = {}) {
3105
+ return cmdStatus(opts, {
3106
+ configLoaders,
3107
+ ModelRouter: coreOps.ModelRouter,
3108
+ readCosts: internalOps.readCosts,
3109
+ getRepoRoot: () => getRepoRoot(),
3110
+ getGitHubRepo: () => getGitHubRepo(),
3111
+ getOctokit: () => getOctokit(),
3112
+ hasGitHubToken: () => hasGitHubToken(),
3113
+ readActiveProduct,
3114
+ warnQueueDiagnostics,
3115
+ errorDetail,
3897
3116
  CliExitError,
3898
3117
  });
3899
3118
  }
3119
+ /** `factory run` (#2800): the handler lives in ./run.ts; this binds its runtime. */
3120
+ function runRun(opts = {}) {
3121
+ return cmdRun(opts, {
3122
+ configLoaders,
3123
+ coreOps,
3124
+ shellOps,
3125
+ readCosts: internalOps.readCosts,
3126
+ getRepoRoot: () => getRepoRoot(),
3127
+ getGitHubRepo: () => getGitHubRepo(),
3128
+ getOctokit: () => getOctokit(),
3129
+ branchPrefixOrExit,
3130
+ withRepoRunLock,
3131
+ runAutoWorktreeGc,
3132
+ planRunLanes,
3133
+ preflightQueuedIssue,
3134
+ createQueuePreflightOps,
3135
+ warnQueueDiagnostics,
3136
+ resolveUsageKnobs,
3137
+ usageFlagOverrides,
3138
+ usageWatchSourceLabel,
3139
+ probeClaudeKeychain,
3140
+ startLaneProxy,
3141
+ sweepBetweenLaneIssues,
3142
+ reapParkedLaneWorktree,
3143
+ reconstructHumanEventsForKpis,
3144
+ appendKpiSnapshot,
3145
+ CliExitError,
3146
+ });
3147
+ }
3148
+ /** `factory doctor` (#2799): the handler lives in ./doctor.ts; this binds its runtime. */
3149
+ function runDoctor(opts = {}) {
3150
+ return cmdDoctor(opts, {
3151
+ shellOps,
3152
+ coreOps,
3153
+ internalOps,
3154
+ configLoaders,
3155
+ getGitHubRepo: () => getGitHubRepo(),
3156
+ getOctokit: () => getOctokit(),
3157
+ hasGitHubToken: () => hasGitHubToken(),
3158
+ scanGreenPrs,
3159
+ probeClaudeKeychain,
3160
+ toLeaseRow,
3161
+ gcWorktreeSandbox,
3162
+ repoConfigFileFor,
3163
+ isCommandAvailableViaShell,
3164
+ });
3165
+ }
3900
3166
  export const PROGRAM_HANDLERS = {
3901
3167
  cmdInit,
3902
3168
  cmdMigrate,
3903
3169
  cmdConstitution,
3904
3170
  cmdModels,
3905
- cmdDoctor,
3171
+ cmdDoctor: runDoctor,
3906
3172
  cmdCost,
3907
3173
  cmdUsage,
3908
- cmdStatus,
3174
+ cmdStatus: runStatus,
3909
3175
  cmdRuns,
3910
3176
  cmdKpis,
3911
3177
  cmdClassifierReport,
@@ -3934,7 +3200,7 @@ export const PROGRAM_HANDLERS = {
3934
3200
  cmdLocalSmallOvernight,
3935
3201
  cmdLand,
3936
3202
  cmdResumeApproved,
3937
- cmdRun,
3203
+ cmdRun: runRun,
3938
3204
  cmdProxy,
3939
3205
  cmdFactoryd,
3940
3206
  cmdDaemonStart,