@on-par/factory-cli 0.9.5-next.9 → 0.9.6-next.1
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.d.ts +12 -189
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +45 -994
- package/dist/cli/index.js.map +1 -1
- package/dist/cli/land-pr.d.ts +102 -0
- package/dist/cli/land-pr.d.ts.map +1 -0
- package/dist/cli/land-pr.js +233 -0
- package/dist/cli/land-pr.js.map +1 -0
- package/dist/cli/land-runtime.d.ts +16 -0
- package/dist/cli/land-runtime.d.ts.map +1 -0
- package/dist/cli/land-runtime.js +11 -0
- package/dist/cli/land-runtime.js.map +1 -0
- package/dist/cli/land.d.ts +54 -0
- package/dist/cli/land.d.ts.map +1 -0
- package/dist/cli/land.js +298 -0
- package/dist/cli/land.js.map +1 -0
- package/dist/cli/ship-runtime.d.ts +41 -0
- package/dist/cli/ship-runtime.d.ts.map +1 -0
- package/dist/cli/ship-runtime.js +11 -0
- package/dist/cli/ship-runtime.js.map +1 -0
- package/dist/cli/ship-steps.d.ts +140 -0
- package/dist/cli/ship-steps.d.ts.map +1 -0
- package/dist/cli/ship-steps.js +273 -0
- package/dist/cli/ship-steps.js.map +1 -0
- package/dist/cli/ship.d.ts +41 -0
- package/dist/cli/ship.d.ts.map +1 -0
- package/dist/cli/ship.js +293 -0
- package/dist/cli/ship.js.map +1 -0
- package/package.json +3 -3
package/dist/cli/index.js
CHANGED
|
@@ -8,12 +8,17 @@ import { arch, hostname as osHostname, release, platform, userInfo } from 'node:
|
|
|
8
8
|
import { basename, dirname, isAbsolute, join, relative, resolve } from 'node:path';
|
|
9
9
|
import { promisify, styleText } from 'node:util';
|
|
10
10
|
import { BRANCH_PREFIX_MATCH_OPTION_DESCRIPTION, BRANCH_PREFIX_OPTION_DESCRIPTION, INVALID_BRANCH_PREFIX_MESSAGE, resolveBranchPrefixOption, } from './branch-prefix.js';
|
|
11
|
-
import { acquirePortLease, aggregateCosts, appendKpiHistoryLine, applyRepoConfig, buildPhase, checkPhase, clearProxyState,
|
|
12
|
-
import { acquirePidFile, createCheckResumeStore,
|
|
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, resolveIngestConfig, 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, LEGACY_BRANCH_PREFIX, createDockerEngine, createFactorydServer, createRunRuntime, createShipExecutor, createGithubQueue, createLocalSmallDryRun, createOctokitFilingClient, createOctokitGreenPrClient, createOctokitQueueClient, daemonRuntimePaths, DEFAULT_FACTORYD_PORT, 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';
|
|
13
13
|
import { runTui } from '@on-par/factory-tui';
|
|
14
14
|
import { Command } from 'commander';
|
|
15
15
|
import { admissionStateReaderFor, runQueueReconcile } from './admission.js';
|
|
16
16
|
import { cmdClassifierBacktest, ClassifierBacktestInputError, } from './classifier-backtest.js';
|
|
17
|
+
import { findOpenPRForIssue, landIssue, waitForMerge } from './land.js';
|
|
18
|
+
import { AwaitingReviewError, CiFailedError, CiUnverifiedError, getPullRequestLandState, LandConflictError, LandFailureError, MergeGatedError, } from './land-pr.js';
|
|
19
|
+
import { setLandRuntime } from './land-runtime.js';
|
|
20
|
+
import { shipIssue } from './ship.js';
|
|
21
|
+
import { setShipRuntime } from './ship-runtime.js';
|
|
17
22
|
import { runQueueClear } from './queue-clear.js';
|
|
18
23
|
import { applyStateRootOption } from './state-root.js';
|
|
19
24
|
import { buildCostJson } from './cost-json.js';
|
|
@@ -40,8 +45,8 @@ import { ensureFactoryExcluded, writeSampleQueue } from './init-files.js';
|
|
|
40
45
|
import { cmdLogs } from './logs.js';
|
|
41
46
|
import { applyHelpGroups } from './help-groups.js';
|
|
42
47
|
import { mergeScopeNotice } from './merge-scope.js';
|
|
43
|
-
import {
|
|
44
|
-
import { createFactoryOctokit
|
|
48
|
+
import { selectOctokit } from './github-stub.js';
|
|
49
|
+
import { createFactoryOctokit } from './octokit.js';
|
|
45
50
|
import { DeepCheckError, createDeepModelRunner, runIssueCheck } from './ready-check.js';
|
|
46
51
|
import { childRunSucceeded, formatChildRunSummary, runChildrenInOrder } from './run-children.js';
|
|
47
52
|
import { createBaseHoldGate, formatBaseHoldCleared, formatBaseHoldStatus, readBaseHold, writeBaseHold, } from './base-hold.js';
|
|
@@ -292,6 +297,42 @@ export function errorDetail(err) {
|
|
|
292
297
|
return e.message;
|
|
293
298
|
return String(err);
|
|
294
299
|
}
|
|
300
|
+
// The land flow (land.ts, land-pr.ts) reads these live, so per-call deps injection reaches it (#2784).
|
|
301
|
+
setLandRuntime(() => ({
|
|
302
|
+
configLoaders,
|
|
303
|
+
gitOps,
|
|
304
|
+
internalOps,
|
|
305
|
+
shellOps,
|
|
306
|
+
getOctokit: () => getOctokit(),
|
|
307
|
+
errorDetail,
|
|
308
|
+
worktreePathFor,
|
|
309
|
+
LaneParkError,
|
|
310
|
+
}));
|
|
311
|
+
// The ship flow (ship.ts, ship-steps.ts) reads these live, so per-call deps injection reaches it (#2788).
|
|
312
|
+
setShipRuntime(() => ({
|
|
313
|
+
resolveConfigLoaders: (overrides) => resolveConfigLoaders(overrides),
|
|
314
|
+
resolveGitOps: (overrides) => resolveGitOps(overrides),
|
|
315
|
+
resolveShellOps: (overrides) => resolveShellOps(overrides),
|
|
316
|
+
resolveCoreOps: (overrides) => resolveCoreOps(overrides),
|
|
317
|
+
resolvePhaseOps: (overrides) => resolvePhaseOps(overrides),
|
|
318
|
+
getRepoRoot,
|
|
319
|
+
getGitHubRepo,
|
|
320
|
+
getOctokit,
|
|
321
|
+
worktreePathFor,
|
|
322
|
+
repoConfigFileFor,
|
|
323
|
+
isCommandAvailableViaShell,
|
|
324
|
+
readTextFileOrEmpty,
|
|
325
|
+
readActiveProduct,
|
|
326
|
+
errorDetail,
|
|
327
|
+
resolveEnvironmentAcquirer,
|
|
328
|
+
resolveLaneBaseUrl,
|
|
329
|
+
defaultWriteLocalRunReport: () => coreOps.writeLocalRunReport,
|
|
330
|
+
LaneParkError,
|
|
331
|
+
LaneEnvironmentPauseError,
|
|
332
|
+
IssueSkippedError,
|
|
333
|
+
IssueDecomposedError,
|
|
334
|
+
}));
|
|
335
|
+
export { shipIssue };
|
|
295
336
|
export function hasGitHubToken(env = process.env, tryToken) {
|
|
296
337
|
if (env.GITHUB_TOKEN || env.GH_TOKEN)
|
|
297
338
|
return true;
|
|
@@ -1579,480 +1620,6 @@ export function resolveEnvironmentAcquirer(opts) {
|
|
|
1579
1620
|
};
|
|
1580
1621
|
};
|
|
1581
1622
|
}
|
|
1582
|
-
export async function shipIssue(issueNum, opts, ctx, deps = {}) {
|
|
1583
|
-
const loaders = resolveConfigLoaders(deps);
|
|
1584
|
-
const git = resolveGitOps(deps);
|
|
1585
|
-
const shell = resolveShellOps(deps);
|
|
1586
|
-
const core = resolveCoreOps(deps);
|
|
1587
|
-
const phases = resolvePhaseOps(deps);
|
|
1588
|
-
// FACTORY_GITHUB=stub (#2652): record each `git push` ship runs in the stub's calls.jsonl.
|
|
1589
|
-
let shipPhaseForMode = phases.shipPhase;
|
|
1590
|
-
if (resolveGitHubMode(process.env) === 'stub') {
|
|
1591
|
-
const base = phases.shipPhase;
|
|
1592
|
-
const stubCalls = join(resolve(process.env.FACTORY_GITHUB_STUB_DIR ?? ''), GITHUB_STUB_CALLS_FILE);
|
|
1593
|
-
const defaultShipRunner = promisify(execCb);
|
|
1594
|
-
const wrapped = (o) => base({ ...o, run: recordGitPushes(o.run ?? defaultShipRunner, stubCalls, o.branch) });
|
|
1595
|
-
shipPhaseForMode = wrapped;
|
|
1596
|
-
}
|
|
1597
|
-
const repoRoot = ctx?.repoRoot ?? (await getRepoRoot(shell));
|
|
1598
|
-
const ghRepo = ctx?.ghRepo ?? (await getGitHubRepo(shell));
|
|
1599
|
-
const paths = ctx?.paths ?? getFactoryPaths(repoRoot);
|
|
1600
|
-
// GitHub REST deprecations seen on this run's client become one warn event per route (#2218).
|
|
1601
|
-
const octokit = (deps.octokit ??
|
|
1602
|
-
(() => getOctokit(shell, {
|
|
1603
|
-
log: (d) => logEvent(paths.events, 'github_api_deprecated', issueNum, formatDeprecation(d), { lane: ctx?.lane }),
|
|
1604
|
-
})))();
|
|
1605
|
-
const [ghOwner, ghName] = ghRepo.split('/');
|
|
1606
|
-
const repoConfig = loaders.loadRepoConfig(repoRoot, paths.root);
|
|
1607
|
-
const factoryConfig = loaders.loadFactoryConfigForRepo(paths.config);
|
|
1608
|
-
const classifierPolicy = resolvePrClassifierPolicy(factoryConfig, process.env, { classifier: opts.prClassifier });
|
|
1609
|
-
const timeouts = loaders.resolveTimeouts(factoryConfig);
|
|
1610
|
-
const failoverSettings = resolveAutoFailover(factoryConfig);
|
|
1611
|
-
const breaker = new ProviderBreaker(paths.breaker);
|
|
1612
|
-
const reworkHistory = new ReworkHistory(paths.reworkHistory);
|
|
1613
|
-
const laneFileGuard = new LaneFileGuard(paths.laneFiles);
|
|
1614
|
-
const efficiency = resolveEfficiencyPolicy(repoConfig);
|
|
1615
|
-
const policy = {
|
|
1616
|
-
models: applyRepoConfig(loaders.loadModelsConfig(), repoConfig),
|
|
1617
|
-
routes: loaders.loadRoutesConfig(),
|
|
1618
|
-
sandbox: factoryConfig.sandbox,
|
|
1619
|
-
budget: { perIssueCapUsd: efficiency.perIssueCapUsd },
|
|
1620
|
-
effective: opts.branchPrefix === undefined
|
|
1621
|
-
? resolveEffectiveConfig(repoConfig)
|
|
1622
|
-
: { ...resolveEffectiveConfig(repoConfig), branchPrefix: opts.branchPrefix },
|
|
1623
|
-
};
|
|
1624
|
-
const router = new core.ModelRouter(policy.models, policy.routes, false, undefined, policy.effective.allowExperimental, policy.effective.localOnly);
|
|
1625
|
-
router.setLocalAuthSink?.(recordLocalAuthFailure);
|
|
1626
|
-
router.skipMissingHarnessClis(core.isCommandAvailable);
|
|
1627
|
-
let issueSpend = 0;
|
|
1628
|
-
let laneSandboxRuntime = 'none';
|
|
1629
|
-
const laneWorkspaceBackend = factoryConfig.workspace?.backend === 'disposable-docker' ? 'disposable-docker' : 'worktree';
|
|
1630
|
-
router.setCostSink((entry) => {
|
|
1631
|
-
issueSpend += entry.cost ?? 0;
|
|
1632
|
-
logCost(paths.costs, {
|
|
1633
|
-
...entry,
|
|
1634
|
-
issue: String(issueNum),
|
|
1635
|
-
sandboxRuntime: laneSandboxRuntime,
|
|
1636
|
-
reworkRoundCount: laneReworkRounds,
|
|
1637
|
-
workspaceBackend: laneWorkspaceBackend,
|
|
1638
|
-
});
|
|
1639
|
-
});
|
|
1640
|
-
const modelPins = loaders.resolveEffectiveModelPins(router.registryRef, repoConfig);
|
|
1641
|
-
const codexOff = resolveCodexDisabled(repoConfig);
|
|
1642
|
-
const constitutionLoader = new core.ConstitutionLoader();
|
|
1643
|
-
const product = opts.product ?? readActiveProduct(paths.product);
|
|
1644
|
-
const autoRework = opts.autoRework ?? true;
|
|
1645
|
-
const work = ctx?.workRequest ??
|
|
1646
|
-
(await createDefaultWorkSourceRegistry({ octokit }).resolve(GITHUB_ISSUE_SOURCE, {
|
|
1647
|
-
repo: ghRepo,
|
|
1648
|
-
issue: issueNum,
|
|
1649
|
-
}));
|
|
1650
|
-
const issueTitle = work.title;
|
|
1651
|
-
const sizeGateMode = opts.sizeGate ?? resolveSizeGateMode(factoryConfig);
|
|
1652
|
-
const maxSlices = opts.maxSlices ?? resolveSizeGateMaxSlices(factoryConfig);
|
|
1653
|
-
// ADR-0147: slice k >= 2 builds on its own branch, so read the slice plan before the worktree exists.
|
|
1654
|
-
let sliceIndex;
|
|
1655
|
-
// ADR-0161: stack runs as slice until stacked PRs are built.
|
|
1656
|
-
if ((sizeGateMode === 'slice' || sizeGateMode === 'stack') &&
|
|
1657
|
-
!ctx?.localOnly &&
|
|
1658
|
-
!ctx?.resumeFrom &&
|
|
1659
|
-
work.kind === GITHUB_ISSUE_SOURCE) {
|
|
1660
|
-
try {
|
|
1661
|
-
const lookup = await findSlicePlanComment({ octokit, repo: ghRepo, issue: issueNum });
|
|
1662
|
-
if (lookup.status === 'found')
|
|
1663
|
-
sliceIndex = currentSlice(lookup.plan)?.index;
|
|
1664
|
-
}
|
|
1665
|
-
catch (err) {
|
|
1666
|
-
throw new LaneParkError(`slice plan lookup failed for #${issueNum}: ${errorDetail(err)} — parked before creating a worktree`, 'escalate');
|
|
1667
|
-
}
|
|
1668
|
-
}
|
|
1669
|
-
const branch = ctx?.resumeFrom?.branch ?? branchFor(issueNum, issueTitle, policy.effective.branchPrefix, sliceIndex);
|
|
1670
|
-
const worktree = ctx?.resumeFrom
|
|
1671
|
-
? ctx.resumeFrom.worktree
|
|
1672
|
-
: ctx?.localOnly
|
|
1673
|
-
? ctx.localOnly.workspace
|
|
1674
|
-
: worktreePathFor(repoRoot, ghRepo, issueNum, policy.effective.branchPrefix, loaders);
|
|
1675
|
-
const specPath = resolve(paths.plans, `issue-${issueNum}.md`);
|
|
1676
|
-
const runStartedAt = new Date().toISOString();
|
|
1677
|
-
const lane = ctx?.lane;
|
|
1678
|
-
// Captures the message of the last terminal event runIssue emits (ready/fail/
|
|
1679
|
-
// escalate/held/conflict/ci-failed/timeout) so the LaneParkError this adapter
|
|
1680
|
-
// raises below carries the same diagnostic text shipIssue used to throw with —
|
|
1681
|
-
// RunOutcome's parked variant carries only the ParkReason enum, not free text.
|
|
1682
|
-
const TERMINAL_EVENT_KINDS = new Set([
|
|
1683
|
-
'ready',
|
|
1684
|
-
'environment-released',
|
|
1685
|
-
'fail',
|
|
1686
|
-
'escalate',
|
|
1687
|
-
'held',
|
|
1688
|
-
'conflict',
|
|
1689
|
-
'config',
|
|
1690
|
-
'ci-failed',
|
|
1691
|
-
'ci-infra',
|
|
1692
|
-
'timeout',
|
|
1693
|
-
]);
|
|
1694
|
-
let terminalMessage;
|
|
1695
|
-
let laneReworkRounds = 0;
|
|
1696
|
-
const mkLog = (phase) => (type, msg, extra) => {
|
|
1697
|
-
if (TERMINAL_EVENT_KINDS.has(type))
|
|
1698
|
-
terminalMessage = msg;
|
|
1699
|
-
if (type === 'rework')
|
|
1700
|
-
laneReworkRounds++;
|
|
1701
|
-
// checkPhase logs each skipped checker as a 'check' event prefixed "SKIPPED: " —
|
|
1702
|
-
// echo it to the terminal the way shipIssue used to print it directly.
|
|
1703
|
-
if (type === 'check' && msg.startsWith('SKIPPED: ')) {
|
|
1704
|
-
console.error(styleText('yellow', ` SKIP: ${msg.slice('SKIPPED: '.length)}`));
|
|
1705
|
-
}
|
|
1706
|
-
// Likewise for each failing checker, so a parked run still names which checker
|
|
1707
|
-
// failed and why — the parked RunOutcome carries only an aggregate count (#675).
|
|
1708
|
-
if (type === 'check' && msg.startsWith('FAILED: ')) {
|
|
1709
|
-
console.error(styleText('red', ` FAIL: ${msg.slice('FAILED: '.length)}`));
|
|
1710
|
-
}
|
|
1711
|
-
logEvent(paths.events, type, issueNum, msg, { ...extra, lane, phase });
|
|
1712
|
-
// Best-effort lastEvent summary (#1327) for factory.run.snapshot — an
|
|
1713
|
-
// observability side channel, so a write failure is swallowed, not logged (that
|
|
1714
|
-
// would re-enter this same function for every event this chokepoint fires on).
|
|
1715
|
-
touchLastEvent(phaseSnapshotFile(paths.runs, issueNum), summarizeEvent(type, msg)).catch(() => { });
|
|
1716
|
-
};
|
|
1717
|
-
const log = mkLog();
|
|
1718
|
-
log('issue-title', issueTitle);
|
|
1719
|
-
if (modelPins.plan) {
|
|
1720
|
-
const source = modelPins.sources.plan === 'repo' ? '.factory/config.json' : 'FACTORY_PLAN_MODEL';
|
|
1721
|
-
log('model-override', `plan model pinned to ${modelPins.plan} (${source})`);
|
|
1722
|
-
}
|
|
1723
|
-
if (modelPins.build) {
|
|
1724
|
-
const source = modelPins.sources.build === 'repo' ? '.factory/config.json' : 'FACTORY_BUILD_MODEL';
|
|
1725
|
-
log('model-override', `build model pinned to ${modelPins.build} (${source})`);
|
|
1726
|
-
}
|
|
1727
|
-
const skipReason = closedWorkSkipReason(work);
|
|
1728
|
-
if (skipReason) {
|
|
1729
|
-
log('skipped-already-closed', skipReason);
|
|
1730
|
-
console.log(styleText('yellow', `skipped: ${skipReason}`));
|
|
1731
|
-
throw new IssueSkippedError(skipReason, 'already-closed');
|
|
1732
|
-
}
|
|
1733
|
-
const sandboxPolicy = resolveSandboxPolicy(policy.sandbox, {
|
|
1734
|
-
worktree,
|
|
1735
|
-
repoRoot,
|
|
1736
|
-
cliDisabled: opts.sandbox === false,
|
|
1737
|
-
laneId: lane,
|
|
1738
|
-
isAvailable: (cmd) => isCommandAvailableViaShell(shell, cmd),
|
|
1739
|
-
});
|
|
1740
|
-
laneSandboxRuntime = sandboxPolicy?.runtime ?? 'none';
|
|
1741
|
-
const worktreeSandbox = worktreeSandboxFor(sandboxPolicy?.runtime, {
|
|
1742
|
-
allowHosts: sandboxPolicy?.allowHosts ?? [],
|
|
1743
|
-
});
|
|
1744
|
-
let activeSandboxPolicy;
|
|
1745
|
-
if (opts.sandbox === false) {
|
|
1746
|
-
console.error(styleText('yellow', 'factory: sandbox disabled by --no-sandbox — agent runs are UNCONTAINED'));
|
|
1747
|
-
log('sandbox-disabled', 'sandbox disabled by --no-sandbox');
|
|
1748
|
-
}
|
|
1749
|
-
else if (!sandboxPolicy) {
|
|
1750
|
-
log('sandbox-disabled', 'sandbox disabled by config/FACTORY_SANDBOX');
|
|
1751
|
-
}
|
|
1752
|
-
else if (sandboxPolicy.runtime === 'none') {
|
|
1753
|
-
log('sandbox-unavailable', 'no sandbox runtime found (sandbox-exec/firejail) — running uncontained');
|
|
1754
|
-
}
|
|
1755
|
-
else if (sandboxPolicy.runtime === 'docker-sandbox') {
|
|
1756
|
-
// docker-sandbox's containment is the microVM created by worktreeWorkspace below
|
|
1757
|
-
// (#653), not a command-prefix wrap — activeSandboxPolicy stays undefined because
|
|
1758
|
-
// wrapCommandInSandbox remains a no-op for this runtime. createMicroVm logs the
|
|
1759
|
-
// actual outcome ('sandbox' on success, 'sandbox-unavailable' on fallback), so no
|
|
1760
|
-
// speculative log is emitted here.
|
|
1761
|
-
}
|
|
1762
|
-
else {
|
|
1763
|
-
activeSandboxPolicy = sandboxPolicy;
|
|
1764
|
-
if (sandboxPolicy.allowHosts.length > 0) {
|
|
1765
|
-
log('sandbox-degraded', `host-level egress filtering unavailable in v1; intended allowlist: ${sandboxPolicy.allowHosts.join(', ')}`);
|
|
1766
|
-
}
|
|
1767
|
-
}
|
|
1768
|
-
// Setup worktree FIRST — PLAN needs cwd=worktree to run claude. Worktree cleanup
|
|
1769
|
-
// stays a CLI concern (not called here, matching today — a worktree persists past
|
|
1770
|
-
// a successful run for GC/sweep commands to reclaim later).
|
|
1771
|
-
let workspace;
|
|
1772
|
-
if (ctx?.resumeFrom) {
|
|
1773
|
-
workspace = { path: worktree, dispose: async () => { } };
|
|
1774
|
-
log('worktree', `reusing kept worktree at ${worktree} (resume --from check)`);
|
|
1775
|
-
}
|
|
1776
|
-
else if (!ctx?.localOnly) {
|
|
1777
|
-
workspace = await worktreeWorkspace({
|
|
1778
|
-
repoRoot,
|
|
1779
|
-
branch,
|
|
1780
|
-
worktreePath: worktree,
|
|
1781
|
-
log,
|
|
1782
|
-
sandbox: worktreeSandbox,
|
|
1783
|
-
findOpenPr: (br) => findOpenPR(octokit, ghOwner, ghName, br),
|
|
1784
|
-
setup: (root, br, wt, sp, sandbox, setupLog, setupOpts) => withGitLock(root, () => withFileLock(paths.gitLock,
|
|
1785
|
-
// setupWorktree fetches origin itself before creating the worktree (#1167),
|
|
1786
|
-
// so no explicit gitFetch here — still under the git + file locks.
|
|
1787
|
-
() => git.setupWorktree(root, br, wt, sp, sandbox, setupLog, setupOpts), { onSteal: (pid) => log('lock-stolen', `stole ${paths.gitLock} from dead holder pid ${pid ?? 'unknown'}`) })),
|
|
1788
|
-
});
|
|
1789
|
-
log('worktree', `Worktree ready at ${worktree}`);
|
|
1790
|
-
}
|
|
1791
|
-
else {
|
|
1792
|
-
workspace = localOnlyWorkspace(ctx.localOnly);
|
|
1793
|
-
log('workspace', `local-only: using caller-provided workspace ${worktree} (no factory worktree created)`);
|
|
1794
|
-
}
|
|
1795
|
-
const planApprovalEnabled = opts.approvePlan ?? resolvePlanApproval(factoryConfig);
|
|
1796
|
-
const processGroupGraceMs = resolveProcessGroupGraceMs(factoryConfig);
|
|
1797
|
-
const portsSettings = resolveEnvironmentPorts(factoryConfig);
|
|
1798
|
-
if (!portsSettings.enabled) {
|
|
1799
|
-
log('environment_lease', 'port leasing disabled (environment.ports.enabled=false or FACTORY_ENV_PORTS=0)');
|
|
1800
|
-
}
|
|
1801
|
-
// acquireEnvironment.recordPgid/release only persist/deallocate the lease — process-
|
|
1802
|
-
// group tracking and killing is runIssue's own unconditional tracker (Invariant 3),
|
|
1803
|
-
// so a lease-disabled/failed run still tears down every spawned agent process.
|
|
1804
|
-
const acquireEnvironment = portsSettings.enabled
|
|
1805
|
-
? resolveEnvironmentAcquirer({
|
|
1806
|
-
laneSandboxRuntime: sandboxPolicy?.runtime ?? 'none',
|
|
1807
|
-
paths,
|
|
1808
|
-
worktree,
|
|
1809
|
-
branch,
|
|
1810
|
-
range: portsSettings.range,
|
|
1811
|
-
processGroupGraceMs,
|
|
1812
|
-
log,
|
|
1813
|
-
findPortListeners: core.defaultFindPortListeners,
|
|
1814
|
-
reapOrphanProcesses: core.reapOrphanProcesses,
|
|
1815
|
-
})
|
|
1816
|
-
: undefined;
|
|
1817
|
-
const proxySettings = resolveEnvironmentProxy(factoryConfig);
|
|
1818
|
-
const resolveBaseUrl = (appPort) => resolveLaneBaseUrl(paths, proxySettings, worktree, appPort);
|
|
1819
|
-
const request = {
|
|
1820
|
-
issue: issueNum,
|
|
1821
|
-
repo: ghRepo,
|
|
1822
|
-
branch,
|
|
1823
|
-
specPath,
|
|
1824
|
-
work,
|
|
1825
|
-
workSource: ctx?.workSource,
|
|
1826
|
-
product,
|
|
1827
|
-
startedAt: runStartedAt,
|
|
1828
|
-
lane,
|
|
1829
|
-
options: {
|
|
1830
|
-
interactive: Boolean(opts.interactive),
|
|
1831
|
-
autoRework,
|
|
1832
|
-
approvePlan: planApprovalEnabled,
|
|
1833
|
-
sandboxDisabled: opts.sandbox === false,
|
|
1834
|
-
},
|
|
1835
|
-
localOnly: Boolean(ctx?.localOnly),
|
|
1836
|
-
blockUnresolvedRegressions: resolveDesignRegressionBlock(factoryConfig),
|
|
1837
|
-
readinessUpdateIssueBody: resolveReadinessUpdateIssueBody(factoryConfig),
|
|
1838
|
-
publishFromBuild: resolveBuildPublish(factoryConfig),
|
|
1839
|
-
sizeGateMode,
|
|
1840
|
-
maxSlices,
|
|
1841
|
-
prClassifier: classifierPolicy.enabled
|
|
1842
|
-
? {
|
|
1843
|
-
rules: resolveReviewFloorRules(repoConfig),
|
|
1844
|
-
gateLabel: resolveFilingPolicy(factoryConfig).selfFixLabel,
|
|
1845
|
-
modelPin: repoConfig?.models?.pins?.classifier,
|
|
1846
|
-
}
|
|
1847
|
-
: undefined,
|
|
1848
|
-
timeouts,
|
|
1849
|
-
modelPins,
|
|
1850
|
-
codexDisabled: codexOff,
|
|
1851
|
-
skipCI: loaders.resolveSkipCI(factoryConfig),
|
|
1852
|
-
failover: failoverSettings,
|
|
1853
|
-
efficiency: { maxReworkRounds: efficiency.maxReworkRounds, fastPath: efficiency.fastPath },
|
|
1854
|
-
sandboxPolicy: activeSandboxPolicy,
|
|
1855
|
-
processGroupGraceMs,
|
|
1856
|
-
preferredRoute: repoConfig?.route,
|
|
1857
|
-
...(ctx?.resumeFrom
|
|
1858
|
-
? {
|
|
1859
|
-
resumeFrom: {
|
|
1860
|
-
phase: 'check',
|
|
1861
|
-
diffBase: ctx.resumeFrom.diffBase,
|
|
1862
|
-
route: ctx.resumeFrom.route,
|
|
1863
|
-
model: ctx.resumeFrom.model,
|
|
1864
|
-
buildMinutes: ctx.resumeFrom.buildMinutes,
|
|
1865
|
-
},
|
|
1866
|
-
}
|
|
1867
|
-
: {}),
|
|
1868
|
-
eventsFile: paths.events,
|
|
1869
|
-
logsDir: paths.logs,
|
|
1870
|
-
baselineCachePath: paths.baselineCache,
|
|
1871
|
-
repoConfigFile: repoConfigFileFor(repoRoot, paths.config),
|
|
1872
|
-
flakyLedgerPath: paths.flakyLedger,
|
|
1873
|
-
};
|
|
1874
|
-
const ports = {
|
|
1875
|
-
router,
|
|
1876
|
-
octokit,
|
|
1877
|
-
workspace,
|
|
1878
|
-
events: mkLog,
|
|
1879
|
-
acquireEnvironment,
|
|
1880
|
-
resolveBaseUrl,
|
|
1881
|
-
getIssueSpend: () => issueSpend,
|
|
1882
|
-
breaker,
|
|
1883
|
-
planReuse: ctx?.localOnly ? undefined : createPlanReuseStore(specPath),
|
|
1884
|
-
checkResume: ctx?.localOnly ? undefined : createCheckResumeStore(specPath),
|
|
1885
|
-
planPhase: phases.planPhase,
|
|
1886
|
-
buildPhase: phases.buildPhase,
|
|
1887
|
-
checkPhase: phases.checkPhase,
|
|
1888
|
-
shipPhase: shipPhaseForMode,
|
|
1889
|
-
checkBaseHealth: deps.checkBaseHealth ?? checkBaseHealth,
|
|
1890
|
-
resolveConstitution: () =>
|
|
1891
|
-
// Resolved once here — runIssue calls this exactly once and reuses the value for
|
|
1892
|
-
// every phase, so the build worker can never author the standards it is graded by.
|
|
1893
|
-
// A local-only workspace is caller-provided, not a checkout of repoRoot, so it keeps its own constitution.
|
|
1894
|
-
constitutionLoader.resolve(worktree, product, factoryConfig.paths?.constitution, ctx?.localOnly ? undefined : repoRoot),
|
|
1895
|
-
createApprovalGate: () => createFileApprovalGate({ dir: paths.approvals, timeoutMs: timeouts.approval * 1000 }),
|
|
1896
|
-
drainSteering: () => drainSteering(paths.steering, issueNum, worktree),
|
|
1897
|
-
reworkHistory,
|
|
1898
|
-
laneFileGuard,
|
|
1899
|
-
recordPhase: (phase) => {
|
|
1900
|
-
const now = new Date().toISOString();
|
|
1901
|
-
return writePhaseSnapshot(phaseSnapshotFile(paths.runs, issueNum), {
|
|
1902
|
-
issue: issueNum,
|
|
1903
|
-
phase,
|
|
1904
|
-
updatedAt: now,
|
|
1905
|
-
lastActivityAt: now,
|
|
1906
|
-
});
|
|
1907
|
-
},
|
|
1908
|
-
onActivity: () => touchRunActivity(phaseSnapshotFile(paths.runs, issueNum), new Date().toISOString()),
|
|
1909
|
-
onDecomposed: (childIssues) => {
|
|
1910
|
-
const childList = childIssues.map((n) => `#${n}`).join(', ');
|
|
1911
|
-
const planLog = mkLog('plan');
|
|
1912
|
-
try {
|
|
1913
|
-
const before = readTextFileOrEmpty(paths.queue);
|
|
1914
|
-
const rewrite = rewriteQueueForDecomposition(before, { issue: issueNum, childIssues });
|
|
1915
|
-
if (rewrite.changed) {
|
|
1916
|
-
writeFileSync(paths.queue, rewrite.content);
|
|
1917
|
-
planLog('decompose_filed', `queue entry for #${issueNum} replaced with ${childList}`);
|
|
1918
|
-
}
|
|
1919
|
-
else {
|
|
1920
|
-
planLog('decompose_filed', `#${issueNum} had no queue entry to replace — continuing the lane with ${childList}`);
|
|
1921
|
-
}
|
|
1922
|
-
}
|
|
1923
|
-
catch (err) {
|
|
1924
|
-
planLog('decompose_file_failed', `queue rewrite for #${issueNum} failed: ${errorDetail(err)}`);
|
|
1925
|
-
}
|
|
1926
|
-
throw new IssueDecomposedError(`issue #${issueNum} decomposed into ${childList}`, childIssues);
|
|
1927
|
-
},
|
|
1928
|
-
writeLocalRunReport: (info) => maybeWriteLocalRunReport({
|
|
1929
|
-
issueNum,
|
|
1930
|
-
paths,
|
|
1931
|
-
startedAt: runStartedAt,
|
|
1932
|
-
outcome: info.outcome,
|
|
1933
|
-
branch: info.branch,
|
|
1934
|
-
worktree,
|
|
1935
|
-
specPath,
|
|
1936
|
-
route: info.route,
|
|
1937
|
-
reason: info.reason,
|
|
1938
|
-
writeLocalRunReport: core.writeLocalRunReport,
|
|
1939
|
-
}),
|
|
1940
|
-
writeBenchmarkArtifacts: (info) => maybeWriteBenchmarkArtifacts({
|
|
1941
|
-
issueNum,
|
|
1942
|
-
paths,
|
|
1943
|
-
ctx,
|
|
1944
|
-
startedAt: runStartedAt,
|
|
1945
|
-
outcome: info.outcome === 'parked' ? 'failed' : info.outcome,
|
|
1946
|
-
branch: info.branch,
|
|
1947
|
-
specPath,
|
|
1948
|
-
route: info.route,
|
|
1949
|
-
checkSummary: info.checkSummary,
|
|
1950
|
-
reworkRounds: info.reworkRounds,
|
|
1951
|
-
failure: info.failure,
|
|
1952
|
-
reportPath: info.reportPath,
|
|
1953
|
-
diffBase: info.diffBase,
|
|
1954
|
-
log,
|
|
1955
|
-
}),
|
|
1956
|
-
};
|
|
1957
|
-
// #2214: report-only per-lane egress proxy for sandbox-exec. Started here, after worktree setup,
|
|
1958
|
-
// so the finally below is the single place that stops it on every exit path.
|
|
1959
|
-
const proxied = shouldProxyEgress(request.sandboxPolicy) ? request.sandboxPolicy : undefined;
|
|
1960
|
-
const egressProxy = proxied
|
|
1961
|
-
? await startEgressProxy({
|
|
1962
|
-
allowHosts: proxied.allowHosts,
|
|
1963
|
-
blockTelemetry: proxied.blockTelemetry,
|
|
1964
|
-
onHost: (host, allowed) => log('egress_host', egressHostMessage(host, allowed)),
|
|
1965
|
-
})
|
|
1966
|
-
: undefined;
|
|
1967
|
-
let outcome;
|
|
1968
|
-
try {
|
|
1969
|
-
outcome = await runIssue(proxied && egressProxy ? { ...request, sandboxPolicy: { ...proxied, proxyUrl: egressProxy.url } } : request, policy, ports);
|
|
1970
|
-
}
|
|
1971
|
-
finally {
|
|
1972
|
-
if (proxied && egressProxy) {
|
|
1973
|
-
log('egress_summary', egressSummaryMessage(egressProxy.hosts(), proxied.allowHosts));
|
|
1974
|
-
await egressProxy.close();
|
|
1975
|
-
}
|
|
1976
|
-
}
|
|
1977
|
-
ctx?.onOutcome?.(outcome);
|
|
1978
|
-
if (opts.interactive && !ctx?.localOnly && (outcome.state === 'ready' || outcome.state === 'shipped')) {
|
|
1979
|
-
const leftover = listQueuedSteering(paths.steering, issueNum);
|
|
1980
|
-
if (leftover.length > 0) {
|
|
1981
|
-
log('steering_unconsumed', `${leftover.length} steering message(s) not consumed (no worker phase remained)`);
|
|
1982
|
-
}
|
|
1983
|
-
}
|
|
1984
|
-
if (outcome.state === 'ready' || outcome.state === 'shipped') {
|
|
1985
|
-
if (ctx?.localOnly) {
|
|
1986
|
-
console.log(styleText('green', `✅ Local-only run complete in ${worktree} (no PR — publishing disabled)`));
|
|
1987
|
-
}
|
|
1988
|
-
else {
|
|
1989
|
-
console.log(styleText('green', `✅ Issue #${issueNum} → ${terminalMessage ?? 'ready'}`));
|
|
1990
|
-
}
|
|
1991
|
-
return outcome.branch;
|
|
1992
|
-
}
|
|
1993
|
-
if (outcome.state === 'released') {
|
|
1994
|
-
throw new LaneEnvironmentPauseError(terminalMessage ?? 'environment failure on base', {
|
|
1995
|
-
baseSha: outcome.baseSha,
|
|
1996
|
-
failingChecks: outcome.failingChecks,
|
|
1997
|
-
failureSignature: outcome.failureSignature,
|
|
1998
|
-
buildPreserved: outcome.buildPreserved,
|
|
1999
|
-
});
|
|
2000
|
-
}
|
|
2001
|
-
const reason = outcome.state === 'escalated' ? 'escalate' : outcome.reason;
|
|
2002
|
-
const message = outcome.state === 'escalated' ? outcome.reason : (terminalMessage ?? `run parked: ${outcome.reason}`);
|
|
2003
|
-
throw new LaneParkError(message, reason, outcome.state === 'parked'
|
|
2004
|
-
? { failureSignature: outcome.failureSignature, failingChecks: outcome.failingChecks }
|
|
2005
|
-
: undefined);
|
|
2006
|
-
}
|
|
2007
|
-
async function maybeWriteLocalRunReport(opts) {
|
|
2008
|
-
if (process.env.FACTORY_LOCAL_ONLY !== '1')
|
|
2009
|
-
return undefined;
|
|
2010
|
-
const report = await (opts.writeLocalRunReport ?? coreOps.writeLocalRunReport)({
|
|
2011
|
-
issue: opts.issueNum,
|
|
2012
|
-
eventsFile: opts.paths.events,
|
|
2013
|
-
reportsDir: opts.paths.reports,
|
|
2014
|
-
startedAt: opts.startedAt,
|
|
2015
|
-
outcome: opts.outcome,
|
|
2016
|
-
profile: 'local-only',
|
|
2017
|
-
branch: opts.branch,
|
|
2018
|
-
worktree: opts.worktree,
|
|
2019
|
-
specPath: opts.specPath,
|
|
2020
|
-
route: opts.route,
|
|
2021
|
-
reason: opts.reason,
|
|
2022
|
-
});
|
|
2023
|
-
console.log(styleText('cyan', `local-only report: ${report.path}`));
|
|
2024
|
-
return report.path;
|
|
2025
|
-
}
|
|
2026
|
-
async function maybeWriteBenchmarkArtifacts(opts) {
|
|
2027
|
-
if (!opts.ctx?.localOnly || !opts.ctx.artifactsDir)
|
|
2028
|
-
return;
|
|
2029
|
-
try {
|
|
2030
|
-
const { manifestPath } = await writeBenchmarkArtifacts({
|
|
2031
|
-
issue: opts.issueNum,
|
|
2032
|
-
artifactsDir: opts.ctx.artifactsDir,
|
|
2033
|
-
eventsFile: opts.paths.events,
|
|
2034
|
-
costsFile: opts.paths.costs,
|
|
2035
|
-
startedAt: opts.startedAt,
|
|
2036
|
-
outcome: opts.outcome,
|
|
2037
|
-
workspace: opts.ctx.localOnly.workspace,
|
|
2038
|
-
branch: opts.branch,
|
|
2039
|
-
specPath: opts.specPath,
|
|
2040
|
-
route: opts.route,
|
|
2041
|
-
request: opts.ctx.workRequest,
|
|
2042
|
-
checkSummary: opts.checkSummary,
|
|
2043
|
-
reworkRounds: opts.reworkRounds,
|
|
2044
|
-
failure: opts.failure,
|
|
2045
|
-
reportPath: opts.reportPath,
|
|
2046
|
-
diffBase: opts.diffBase,
|
|
2047
|
-
});
|
|
2048
|
-
opts.log('benchmark-artifacts', `manifest written to ${manifestPath}`);
|
|
2049
|
-
console.log(styleText('cyan', `benchmark artifacts: ${manifestPath}`));
|
|
2050
|
-
}
|
|
2051
|
-
catch (err) {
|
|
2052
|
-
// Never let artifact emission mask the run outcome.
|
|
2053
|
-
opts.log('benchmark-artifacts-failed', `could not write benchmark artifacts: ${err.message}`);
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
1623
|
/** The autoGcOnRun-gated worktree sweep every pipeline entry point runs before shipping
|
|
2057
1624
|
* (`factory run`, and since #1756 the one-shot `factory ship` / `factory run-issue`). Never
|
|
2058
1625
|
* throws — a GC failure is a warn, never a reason to abort the command. */
|
|
@@ -2589,135 +2156,6 @@ export async function cmdLand(issueNum, opts = {}, deps = {}) {
|
|
|
2589
2156
|
octokitFactory = previousOctokitFactory;
|
|
2590
2157
|
}
|
|
2591
2158
|
}
|
|
2592
|
-
async function landIssue(issueNum, repoRoot, ghRepo, paths, octokit, skipCI, branchPrefix, opts = {}) {
|
|
2593
|
-
const [owner, repoName] = ghRepo.split('/');
|
|
2594
|
-
const log = (type, msg, extra) => logEvent(paths.events, type, issueNum, msg, extra);
|
|
2595
|
-
// The issue title may have been edited since the PR was opened, so a
|
|
2596
|
-
// freshly-derived branch name can drift from the branch the PR actually
|
|
2597
|
-
// lives on (same failure mode fixed for waitForMerge in #51). Guess the
|
|
2598
|
-
// branch from the current title first, but fall back to matching the open
|
|
2599
|
-
// PR that references this issue directly and use its real head branch.
|
|
2600
|
-
// Legacy-prefix (LEGACY_BRANCH_PREFIX) branches are guessed too, after the resolved prefix (#1708).
|
|
2601
|
-
const resolved = branchPrefix ?? resolveEffectiveConfig(configLoaders.loadRepoConfig(repoRoot)).branchPrefix;
|
|
2602
|
-
const title = await getIssueTitle(ghRepo, issueNum, octokit);
|
|
2603
|
-
// ADR-0161: a stack layer lands by its exact branch, never by title guess or the Closes PR (the top layer).
|
|
2604
|
-
const guesses = opts.branch
|
|
2605
|
-
? [opts.branch]
|
|
2606
|
-
: factoryBranchPrefixes(resolved).map((p) => branchFor(issueNum, title, p));
|
|
2607
|
-
const guessedBranch = guesses[0];
|
|
2608
|
-
let branch = guessedBranch;
|
|
2609
|
-
let prNumber;
|
|
2610
|
-
try {
|
|
2611
|
-
const findByGuess = async () => {
|
|
2612
|
-
for (const guess of guesses) {
|
|
2613
|
-
const found = await findOpenPRNumber(octokit, owner, repoName, guess);
|
|
2614
|
-
if (found) {
|
|
2615
|
-
branch = guess;
|
|
2616
|
-
return found;
|
|
2617
|
-
}
|
|
2618
|
-
}
|
|
2619
|
-
return undefined;
|
|
2620
|
-
};
|
|
2621
|
-
[, prNumber] = await Promise.all([gitOps.gitFetch(repoRoot), findByGuess()]);
|
|
2622
|
-
if (!prNumber && !opts.branch) {
|
|
2623
|
-
const fallback = await findOpenPRForIssue(octokit, owner, repoName, issueNum);
|
|
2624
|
-
if (fallback) {
|
|
2625
|
-
branch = fallback.branch;
|
|
2626
|
-
prNumber = fallback.number;
|
|
2627
|
-
}
|
|
2628
|
-
}
|
|
2629
|
-
}
|
|
2630
|
-
catch (err) {
|
|
2631
|
-
const failure = prLookupFailure(issueNum, guessedBranch, err);
|
|
2632
|
-
log('fail', failure.message);
|
|
2633
|
-
throw failure;
|
|
2634
|
-
}
|
|
2635
|
-
if (!prNumber) {
|
|
2636
|
-
log('fail', `no open PR for ${guessedBranch}`);
|
|
2637
|
-
throw new LandFailureError(`no open PR for issue #${issueNum} (${guessedBranch})`, 1);
|
|
2638
|
-
}
|
|
2639
|
-
const landFactoryConfig = configLoaders.loadFactoryConfigForRepo(paths.config);
|
|
2640
|
-
// Every merge path honors the no-auto-merge gate on the issue and on the PR; unreadable labels fail closed (#1720).
|
|
2641
|
-
const filingPolicy = resolveFilingPolicy(landFactoryConfig);
|
|
2642
|
-
const gateLabel = filingPolicy.selfFixLabel;
|
|
2643
|
-
if (opts.allowGated) {
|
|
2644
|
-
console.log(`AUDIT factory land #${issueNum}: --allow-gated override — merging PR #${prNumber} past the ${gateLabel} gate`);
|
|
2645
|
-
log('merge-gated-override', `--allow-gated: merging PR #${prNumber} past ${gateLabel} gate (human override)`);
|
|
2646
|
-
}
|
|
2647
|
-
else {
|
|
2648
|
-
let where;
|
|
2649
|
-
try {
|
|
2650
|
-
const [issueLabels, prLabels] = await Promise.all([
|
|
2651
|
-
defaultListIssueLabels(octokit, owner, repoName, issueNum),
|
|
2652
|
-
defaultListIssueLabels(octokit, owner, repoName, prNumber),
|
|
2653
|
-
]);
|
|
2654
|
-
if (isAutoMergeBlocked(issueLabels, filingPolicy))
|
|
2655
|
-
where = `issue #${issueNum} is labelled ${gateLabel}`;
|
|
2656
|
-
else if (isAutoMergeBlocked(prLabels, filingPolicy))
|
|
2657
|
-
where = `PR #${prNumber} is labelled ${gateLabel}`;
|
|
2658
|
-
}
|
|
2659
|
-
catch (err) {
|
|
2660
|
-
where = `labels could not be read (${errorDetail(err)}) — failing closed on the ${gateLabel} gate`;
|
|
2661
|
-
}
|
|
2662
|
-
if (where) {
|
|
2663
|
-
log('merge-gated', `land refused: ${where}`);
|
|
2664
|
-
throw new MergeGatedError(`not merging PR #${prNumber} for issue #${issueNum}: ${where} (human approval required). Re-run \`factory land ${issueNum} --allow-gated\` to merge it deliberately.`, issueNum, prNumber, gateLabel);
|
|
2665
|
-
}
|
|
2666
|
-
}
|
|
2667
|
-
const worktree = worktreePathFor(repoRoot, ghRepo, issueNum, branch.startsWith(`${LEGACY_BRANCH_PREFIX}/`) ? LEGACY_BRANCH_PREFIX : resolved);
|
|
2668
|
-
const landSandboxPolicy = resolveSandboxPolicy(landFactoryConfig.sandbox, {
|
|
2669
|
-
worktree,
|
|
2670
|
-
repoRoot,
|
|
2671
|
-
});
|
|
2672
|
-
const worktreeSandbox = worktreeSandboxFor(landSandboxPolicy?.runtime, {
|
|
2673
|
-
allowHosts: landSandboxPolicy?.allowHosts ?? [],
|
|
2674
|
-
});
|
|
2675
|
-
const withLandLock = (fn) => withGitLock(repoRoot, () => withFileLock(paths.mergeLock, fn, {
|
|
2676
|
-
onSteal: (pid) => log('lock-stolen', `stole ${paths.mergeLock} from dead holder pid ${pid ?? 'unknown'}`),
|
|
2677
|
-
}));
|
|
2678
|
-
try {
|
|
2679
|
-
try {
|
|
2680
|
-
await landOpenPullRequest({
|
|
2681
|
-
octokit,
|
|
2682
|
-
owner,
|
|
2683
|
-
repoName,
|
|
2684
|
-
ghRepo,
|
|
2685
|
-
repoRoot,
|
|
2686
|
-
issue: issueNum,
|
|
2687
|
-
branch,
|
|
2688
|
-
worktree,
|
|
2689
|
-
prNumber: prNumber,
|
|
2690
|
-
log,
|
|
2691
|
-
skipCI,
|
|
2692
|
-
adminMerge: resolveMergePolicy(landFactoryConfig).admin,
|
|
2693
|
-
withLock: withLandLock,
|
|
2694
|
-
ensureWorktree: async () => {
|
|
2695
|
-
if (!existsSync(worktree)) {
|
|
2696
|
-
await gitOps.setupWorktree(repoRoot, branch, worktree, `origin/${branch}`, worktreeSandbox, log);
|
|
2697
|
-
}
|
|
2698
|
-
},
|
|
2699
|
-
});
|
|
2700
|
-
}
|
|
2701
|
-
catch (err) {
|
|
2702
|
-
if (err instanceof AwaitingReviewError || err instanceof CiFailedError) {
|
|
2703
|
-
await withLandLock(() => gitOps.cleanupWorktree(repoRoot, worktree, log, worktreeSandbox));
|
|
2704
|
-
}
|
|
2705
|
-
throw err;
|
|
2706
|
-
}
|
|
2707
|
-
log('merged', `squash-merged PR #${prNumber}`);
|
|
2708
|
-
await withLandLock(() => gitOps.cleanupWorktree(repoRoot, worktree, log, worktreeSandbox));
|
|
2709
|
-
}
|
|
2710
|
-
catch (err) {
|
|
2711
|
-
if (err instanceof LandConflictError || err instanceof AwaitingReviewError || err instanceof CiFailedError)
|
|
2712
|
-
throw err;
|
|
2713
|
-
log('fail', `merge failed: ${err.message}`);
|
|
2714
|
-
throw new LandFailureError(`merge failed for issue #${issueNum}: ${err.message}`, 5);
|
|
2715
|
-
}
|
|
2716
|
-
return { branch, prNumber };
|
|
2717
|
-
}
|
|
2718
|
-
export function prLookupFailure(issueNum, branch, err) {
|
|
2719
|
-
return new LandFailureError(`PR lookup failed for issue #${issueNum} (${branch}): ${errorDetail(err)}`, 5);
|
|
2720
|
-
}
|
|
2721
2159
|
async function cmdTriage(opts) {
|
|
2722
2160
|
const repoRoot = await getRepoRoot();
|
|
2723
2161
|
const ghRepo = await getGitHubRepo();
|
|
@@ -3950,99 +3388,6 @@ export async function runLane(lane, issues, repoRoot, ghRepo, paths, deps = {})
|
|
|
3950
3388
|
}
|
|
3951
3389
|
emitEvent(paths.events, 'lane-done', lane, `lane complete (${merged} merged, ${awaitingReview} awaiting review, ${skipped} skipped, ${decomposed} decomposed, ${parked} parked)`, { lane });
|
|
3952
3390
|
}
|
|
3953
|
-
export async function isPrMerged(octokit, owner, repoName, branch) {
|
|
3954
|
-
const { data: prs } = await octokit.rest.pulls.list({
|
|
3955
|
-
owner,
|
|
3956
|
-
repo: repoName,
|
|
3957
|
-
state: 'all',
|
|
3958
|
-
head: `${owner}:${branch}`,
|
|
3959
|
-
});
|
|
3960
|
-
if (prs.length === 0)
|
|
3961
|
-
return false;
|
|
3962
|
-
// Ship-it branch names get reused across separate runs for the same issue
|
|
3963
|
-
// (e.g. a "verify-and-close" pass recreates the exact branch name an
|
|
3964
|
-
// earlier, already-merged PR used) — GitHub PR numbers only increase, so
|
|
3965
|
-
// the highest-numbered PR under this head is always the current one.
|
|
3966
|
-
// Checking "was ANY PR ever merged under this branch name" (the old
|
|
3967
|
-
// `state: 'closed'` + `.some()` behavior) matched the stale prior PR and
|
|
3968
|
-
// falsely reported a brand-new, still-open PR under the same name as
|
|
3969
|
-
// merged — waitForMerge then logged "landed" and skipped calling land(),
|
|
3970
|
-
// leaving the real PR open forever while the lane believed it was done.
|
|
3971
|
-
const latest = prs.reduce((newest, pr) => (pr.number > newest.number ? pr : newest));
|
|
3972
|
-
return Boolean(latest.merged_at);
|
|
3973
|
-
}
|
|
3974
|
-
export async function findOpenPRNumber(octokit, owner, repoName, branch) {
|
|
3975
|
-
const { data: prs } = await octokit.rest.pulls.list({
|
|
3976
|
-
owner,
|
|
3977
|
-
repo: repoName,
|
|
3978
|
-
state: 'open',
|
|
3979
|
-
head: `${owner}:${branch}`,
|
|
3980
|
-
});
|
|
3981
|
-
return prs[0]?.number;
|
|
3982
|
-
}
|
|
3983
|
-
export async function findOpenPRForIssue(octokit, owner, repoName, issueNum) {
|
|
3984
|
-
const perPage = 100;
|
|
3985
|
-
const matches = new RegExp(`\\bcloses\\s+#${issueNum}\\b`, 'i');
|
|
3986
|
-
for (let page = 1;; page++) {
|
|
3987
|
-
const { data: prs } = await octokit.rest.pulls.list({
|
|
3988
|
-
owner,
|
|
3989
|
-
repo: repoName,
|
|
3990
|
-
state: 'open',
|
|
3991
|
-
per_page: perPage,
|
|
3992
|
-
page,
|
|
3993
|
-
});
|
|
3994
|
-
const pr = prs.find((p) => matches.test(p.body ?? ''));
|
|
3995
|
-
if (pr) {
|
|
3996
|
-
const headRepoFullName = pr.head.repo?.full_name ?? null;
|
|
3997
|
-
if (headRepoFullName !== `${owner}/${repoName}`) {
|
|
3998
|
-
return {
|
|
3999
|
-
number: pr.number,
|
|
4000
|
-
branch: pr.head.ref,
|
|
4001
|
-
headSha: pr.head.sha,
|
|
4002
|
-
headRepoFullName,
|
|
4003
|
-
};
|
|
4004
|
-
}
|
|
4005
|
-
return { number: pr.number, branch: pr.head.ref, headSha: pr.head.sha, headRepoFullName };
|
|
4006
|
-
}
|
|
4007
|
-
if (prs.length < perPage)
|
|
4008
|
-
return undefined;
|
|
4009
|
-
}
|
|
4010
|
-
}
|
|
4011
|
-
export async function squashMergeAndDelete(octokit, owner, repoName, branch, prNumber,
|
|
4012
|
-
/** `sha`: the CI-verified head commit. GitHub refuses the merge if the PR head has moved. */
|
|
4013
|
-
opts = {}) {
|
|
4014
|
-
const { sha } = opts;
|
|
4015
|
-
if (opts.admin) {
|
|
4016
|
-
const run = opts.run ?? shellOps.exec;
|
|
4017
|
-
const pin = sha ? ` --match-head-commit ${shellEscape(sha)}` : '';
|
|
4018
|
-
await run(`gh pr merge ${prNumber} --repo ${shellEscape(`${owner}/${repoName}`)} --admin --squash --delete-branch${pin}`);
|
|
4019
|
-
return;
|
|
4020
|
-
}
|
|
4021
|
-
await octokit.rest.pulls.merge({
|
|
4022
|
-
owner,
|
|
4023
|
-
repo: repoName,
|
|
4024
|
-
pull_number: prNumber,
|
|
4025
|
-
merge_method: 'squash',
|
|
4026
|
-
...(sha ? { sha } : {}),
|
|
4027
|
-
});
|
|
4028
|
-
// Best-effort branch delete: the merge is the source of truth.
|
|
4029
|
-
await octokit.rest.git.deleteRef({ owner, repo: repoName, ref: `heads/${branch}` }).catch(() => { });
|
|
4030
|
-
}
|
|
4031
|
-
export class MergeGatedError extends Error {
|
|
4032
|
-
issue;
|
|
4033
|
-
prNumber;
|
|
4034
|
-
label;
|
|
4035
|
-
constructor(message, issue, prNumber, label) {
|
|
4036
|
-
super(message);
|
|
4037
|
-
this.issue = issue;
|
|
4038
|
-
this.prNumber = prNumber;
|
|
4039
|
-
this.label = label;
|
|
4040
|
-
this.name = 'MergeGatedError';
|
|
4041
|
-
}
|
|
4042
|
-
}
|
|
4043
|
-
export class LandConflictError extends Error {
|
|
4044
|
-
parkReason = 'conflict';
|
|
4045
|
-
}
|
|
4046
3391
|
/** The target issue was already resolved before the run started — a clean skip,
|
|
4047
3392
|
* not a park. Callers advance to the next queue entry. (#681) */
|
|
4048
3393
|
export class IssueSkippedError extends Error {
|
|
@@ -4052,13 +3397,6 @@ export class IssueSkippedError extends Error {
|
|
|
4052
3397
|
this.reason = reason;
|
|
4053
3398
|
}
|
|
4054
3399
|
}
|
|
4055
|
-
export class AwaitingReviewError extends Error {
|
|
4056
|
-
prNumber;
|
|
4057
|
-
constructor(message, prNumber) {
|
|
4058
|
-
super(message);
|
|
4059
|
-
this.prNumber = prNumber;
|
|
4060
|
-
}
|
|
4061
|
-
}
|
|
4062
3400
|
/** The target issue tripped the PLAN pre-flight size gate and was decomposed into real
|
|
4063
3401
|
* filed sub-issues; the queue was rewritten. Not a park — the lane continues with the
|
|
4064
3402
|
* children in place of the oversized issue. (#823) */
|
|
@@ -4069,191 +3407,6 @@ export class IssueDecomposedError extends Error {
|
|
|
4069
3407
|
this.childIssues = childIssues;
|
|
4070
3408
|
}
|
|
4071
3409
|
}
|
|
4072
|
-
export class LandFailureError extends Error {
|
|
4073
|
-
code;
|
|
4074
|
-
constructor(message, code) {
|
|
4075
|
-
super(message);
|
|
4076
|
-
this.code = code;
|
|
4077
|
-
}
|
|
4078
|
-
}
|
|
4079
|
-
/** Thrown when watchChecks reports a confirmed CI failure — a completed check run with a
|
|
4080
|
-
* non-passing conclusion. A confirmed failing check must never be merged, admin override
|
|
4081
|
-
* or not. See CiUnverifiedError for the no-verdict case. */
|
|
4082
|
-
export class CiFailedError extends Error {
|
|
4083
|
-
prNumber;
|
|
4084
|
-
parkReason = 'ci-failed';
|
|
4085
|
-
constructor(message, prNumber) {
|
|
4086
|
-
super(message);
|
|
4087
|
-
this.prNumber = prNumber;
|
|
4088
|
-
}
|
|
4089
|
-
}
|
|
4090
|
-
/** Thrown when the CI watch never reached a green verdict — a deadline timeout, no check runs ever
|
|
4091
|
-
* registering, or repeated API failures. A subclass of CiFailedError so every existing guard
|
|
4092
|
-
* (parkReasonFor → 'ci-failed', worktree cleanup, cmdLand's exit-0 path) treats "we don't know"
|
|
4093
|
-
* exactly like "we know it's red": never merge. */
|
|
4094
|
-
export class CiUnverifiedError extends CiFailedError {
|
|
4095
|
-
}
|
|
4096
|
-
/** Thrown when the one infra rerun (#2365) also failed for infrastructure reasons on the same head SHA (#2366).
|
|
4097
|
-
* A CiFailedError subclass so every guard still leaves the PR open and unmerged, but it parks as
|
|
4098
|
-
* 'ci-infra', which the ADR-0155 steward skips. */
|
|
4099
|
-
export class CiInfraError extends CiFailedError {
|
|
4100
|
-
parkReason = 'ci-infra';
|
|
4101
|
-
}
|
|
4102
|
-
const MAX_MERGE_ATTEMPTS = 5;
|
|
4103
|
-
const MERGE_RETRY_BASE_MS = 5_000;
|
|
4104
|
-
/** True when GitHub refused a SHA-pinned merge because the PR head moved past the verified
|
|
4105
|
-
* commit (REST 409, or gh's "Head branch was modified"). Retrying as-is can never succeed. */
|
|
4106
|
-
export function isHeadModifiedMergeError(err) {
|
|
4107
|
-
if (err?.status === 409)
|
|
4108
|
-
return true;
|
|
4109
|
-
const msg = `${err instanceof Error ? err.message : ''}\n${errorDetail(err)}`;
|
|
4110
|
-
return /head branch was modified/i.test(msg);
|
|
4111
|
-
}
|
|
4112
|
-
export function isReviewRequiredMergeError(err, state) {
|
|
4113
|
-
const msg = err instanceof Error ? err.message : String(err);
|
|
4114
|
-
if (/approving review/i.test(msg) || /review is required/i.test(msg))
|
|
4115
|
-
return true;
|
|
4116
|
-
return state.mergeStateStatus === 'BLOCKED' && state.reviewDecision === 'REVIEW_REQUIRED';
|
|
4117
|
-
}
|
|
4118
|
-
export async function getPullRequestLandState(octokit, owner, repoName, prNumber) {
|
|
4119
|
-
const result = await octokit.graphql(`query PullRequestLandState($owner: String!, $repo: String!, $number: Int!) {
|
|
4120
|
-
repository(owner: $owner, name: $repo) {
|
|
4121
|
-
pullRequest(number: $number) {
|
|
4122
|
-
id
|
|
4123
|
-
isDraft
|
|
4124
|
-
mergeStateStatus
|
|
4125
|
-
reviewDecision
|
|
4126
|
-
}
|
|
4127
|
-
}
|
|
4128
|
-
}`, { owner, repo: repoName, number: prNumber });
|
|
4129
|
-
return result.repository?.pullRequest ?? {};
|
|
4130
|
-
}
|
|
4131
|
-
export async function markPullRequestReady(octokit, pullRequestId) {
|
|
4132
|
-
await octokit.graphql(`mutation MarkPullRequestReady($id: ID!) {
|
|
4133
|
-
markPullRequestReadyForReview(input: { pullRequestId: $id }) {
|
|
4134
|
-
pullRequest { isDraft }
|
|
4135
|
-
}
|
|
4136
|
-
}`, { id: pullRequestId });
|
|
4137
|
-
}
|
|
4138
|
-
export async function rebaseDirtyPullRequest(opts) {
|
|
4139
|
-
const { issue, branch, worktree, prNumber, log, run = shellOps.exec, pathExists = existsSync } = opts;
|
|
4140
|
-
if (!pathExists(worktree)) {
|
|
4141
|
-
const msg = `PR #${prNumber} DIRTY on ${branch} and worktree gone`;
|
|
4142
|
-
log('conflict', msg);
|
|
4143
|
-
throw new LandConflictError(msg);
|
|
4144
|
-
}
|
|
4145
|
-
try {
|
|
4146
|
-
await run('git rebase origin/main', { cwd: worktree });
|
|
4147
|
-
await run(`git push --force-with-lease origin ${shellEscape(branch)}`, { cwd: worktree });
|
|
4148
|
-
}
|
|
4149
|
-
catch {
|
|
4150
|
-
// Best-effort cleanup: the conflict logged below is the error we surface.
|
|
4151
|
-
await run('git rebase --abort', { cwd: worktree }).catch(() => { });
|
|
4152
|
-
const msg = `rebase conflict on ${branch} — parked`;
|
|
4153
|
-
log('conflict', msg);
|
|
4154
|
-
throw new LandConflictError(`issue #${issue}: ${msg}`);
|
|
4155
|
-
}
|
|
4156
|
-
}
|
|
4157
|
-
export async function landOpenPullRequest(opts) {
|
|
4158
|
-
const { octokit, owner, repoName, issue, branch, worktree, prNumber, log, run, pathExists, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), skipCI = false, adminMerge = process.env.FACTORY_MERGE_ADMIN === '1', watch = internalOps.watchChecks, rerun, withLock = (fn) => fn(), ensureWorktree, } = opts;
|
|
4159
|
-
/** Watches CI on the PR's current head commit and returns that SHA once it is green, so the
|
|
4160
|
-
* merge can be pinned to exactly the commit CI verified. */
|
|
4161
|
-
const watchCi = async () => {
|
|
4162
|
-
let headSha;
|
|
4163
|
-
let outcome;
|
|
4164
|
-
try {
|
|
4165
|
-
const { data: pr } = await octokit.rest.pulls.get({ owner, repo: repoName, pull_number: prNumber });
|
|
4166
|
-
headSha = pr.head.sha;
|
|
4167
|
-
outcome = await watchChecksWithInfraRerun({
|
|
4168
|
-
octokit,
|
|
4169
|
-
owner,
|
|
4170
|
-
repo: repoName,
|
|
4171
|
-
ref: headSha,
|
|
4172
|
-
watch,
|
|
4173
|
-
rerun,
|
|
4174
|
-
onRescued: ({ runIds }) => log('ci-rescued', `CI rescued for PR #${prNumber} on ${branch}: reran infra-failed run(s) ${runIds.join(', ')} once and the re-watch is green`),
|
|
4175
|
-
onRescueFailed: ({ runIds, outcome: failedOutcome }) => log('ci-rescue-failed', `CI rescue failed for PR #${prNumber} on ${branch}: reran infra-failed run(s) ${runIds.join(', ')} once and the re-watch ended ${failedOutcome}`),
|
|
4176
|
-
});
|
|
4177
|
-
}
|
|
4178
|
-
catch (err) {
|
|
4179
|
-
const msg = `CI watch for ${branch} failed: ${errorDetail(err)} — refusing to merge without a green verdict`;
|
|
4180
|
-
log('ci-failed', msg);
|
|
4181
|
-
throw new CiUnverifiedError(msg, prNumber);
|
|
4182
|
-
}
|
|
4183
|
-
if (outcome === 'success')
|
|
4184
|
-
return headSha;
|
|
4185
|
-
if (outcome === 'infra-failure') {
|
|
4186
|
-
const msg = `CI failed again for infrastructure reasons for PR #${prNumber} on ${branch} after one rerun — parked as ci-infra, not merged`;
|
|
4187
|
-
log('ci-infra', msg);
|
|
4188
|
-
throw new CiInfraError(msg, prNumber);
|
|
4189
|
-
}
|
|
4190
|
-
if (outcome === 'failure') {
|
|
4191
|
-
const msg = `CI failed for PR #${prNumber} on ${branch} — refusing to merge with a failing check`;
|
|
4192
|
-
log('ci-failed', msg);
|
|
4193
|
-
throw new CiFailedError(msg, prNumber);
|
|
4194
|
-
}
|
|
4195
|
-
const msg = `CI watch for ${branch} ended ${outcome} — refusing to merge without a green verdict`;
|
|
4196
|
-
log('ci-failed', msg);
|
|
4197
|
-
throw new CiUnverifiedError(msg, prNumber);
|
|
4198
|
-
};
|
|
4199
|
-
// The CI watch is a 10-20 minute wait, not a mutation: it must never run under the land
|
|
4200
|
-
// locks, or every other lane's setupWorktree serializes behind it (#645).
|
|
4201
|
-
let verifiedSha = skipCI ? undefined : await watchCi();
|
|
4202
|
-
let state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4203
|
-
if (state.mergeStateStatus === 'DIRTY') {
|
|
4204
|
-
const rebased = await withLock(async () => {
|
|
4205
|
-
// Re-check under the lock: a sibling lane may have landed (or rebased this branch)
|
|
4206
|
-
// while we waited for it, so the DIRTY verdict read above can be stale.
|
|
4207
|
-
state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4208
|
-
if (state.mergeStateStatus !== 'DIRTY')
|
|
4209
|
-
return false;
|
|
4210
|
-
await ensureWorktree?.();
|
|
4211
|
-
await rebaseDirtyPullRequest({ issue, branch, worktree, prNumber, log, run, pathExists });
|
|
4212
|
-
return true;
|
|
4213
|
-
});
|
|
4214
|
-
if (rebased) {
|
|
4215
|
-
if (!skipCI) {
|
|
4216
|
-
verifiedSha = await watchCi();
|
|
4217
|
-
}
|
|
4218
|
-
state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4219
|
-
}
|
|
4220
|
-
}
|
|
4221
|
-
for (let attempt = 1;; attempt++) {
|
|
4222
|
-
try {
|
|
4223
|
-
await withLock(async () => {
|
|
4224
|
-
// Same rule as the rebase section: whatever we read before taking the lock (or before
|
|
4225
|
-
// the backoff sleep) may be stale, so the attempt decides from a fresh read.
|
|
4226
|
-
state = await getPullRequestLandState(octokit, owner, repoName, prNumber);
|
|
4227
|
-
if (state.isDraft && state.id) {
|
|
4228
|
-
log('land', `PR #${prNumber} still a draft — re-issuing ready-for-review (attempt ${attempt})`);
|
|
4229
|
-
await markPullRequestReady(octokit, state.id).catch((err) => log('warn', `ready-for-review flip failed for PR #${prNumber}: ${errorDetail(err)}`));
|
|
4230
|
-
}
|
|
4231
|
-
await squashMergeAndDelete(octokit, owner, repoName, branch, prNumber, {
|
|
4232
|
-
admin: adminMerge,
|
|
4233
|
-
run,
|
|
4234
|
-
sha: verifiedSha,
|
|
4235
|
-
});
|
|
4236
|
-
});
|
|
4237
|
-
return;
|
|
4238
|
-
}
|
|
4239
|
-
catch (err) {
|
|
4240
|
-
if (verifiedSha && isHeadModifiedMergeError(err)) {
|
|
4241
|
-
const msg = `PR #${prNumber} head moved past CI-verified commit ${verifiedSha} — refusing to merge an unverified head`;
|
|
4242
|
-
log('ci-failed', msg);
|
|
4243
|
-
throw new CiUnverifiedError(msg, prNumber);
|
|
4244
|
-
}
|
|
4245
|
-
if (isReviewRequiredMergeError(err, state)) {
|
|
4246
|
-
log('awaiting-review', `PR #${prNumber} blocked on human review — leaving open for approval`);
|
|
4247
|
-
throw new AwaitingReviewError(`PR #${prNumber} awaiting review: ${err.message}`, prNumber);
|
|
4248
|
-
}
|
|
4249
|
-
if (attempt >= MAX_MERGE_ATTEMPTS)
|
|
4250
|
-
throw err;
|
|
4251
|
-
log('land', `merge attempt ${attempt}/${MAX_MERGE_ATTEMPTS} failed (${err.message}); mergeStateStatus=${state.mergeStateStatus ?? 'unknown'} — retrying with backoff`);
|
|
4252
|
-
// Backoff outside the lock: waiting is not a mutation (#645).
|
|
4253
|
-
await sleep(MERGE_RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
4254
|
-
}
|
|
4255
|
-
}
|
|
4256
|
-
}
|
|
4257
3410
|
export function issueFromFactoryBranch(branch, prefix) {
|
|
4258
3411
|
return factoryBranchIssue(branch, prefix) ?? undefined;
|
|
4259
3412
|
}
|
|
@@ -4335,108 +3488,6 @@ export async function cmdResumeApproved(opts = {}) {
|
|
|
4335
3488
|
throw new CliExitError(`factory: resume-approved: ${result.failed.length} PR(s) failed to land`, 5);
|
|
4336
3489
|
}
|
|
4337
3490
|
}
|
|
4338
|
-
/** Consecutive merged-state check failures tolerated before waitForMerge parks the lane.
|
|
4339
|
-
* At the shipped 120s poll that is ~20 minutes of 100% failure (ADR: sustained-failure park). */
|
|
4340
|
-
export const MERGE_CHECK_MAX_CONSECUTIVE_FAILURES = 10;
|
|
4341
|
-
/** Wall-clock backstop: continuous merged-state check failure for this long parks the lane even
|
|
4342
|
-
* if fewer than MERGE_CHECK_MAX_CONSECUTIVE_FAILURES attempts were made (slower poll cadence). */
|
|
4343
|
-
export const MERGE_CHECK_FAILURE_BUDGET_MS = 2 * 60 * 60 * 1000;
|
|
4344
|
-
/** GitHub signals both primary and secondary rate limits with 403 — the same status it uses for a
|
|
4345
|
-
* token that genuinely lacks the scope. Only a 403 with no rate-limit evidence is permanent. */
|
|
4346
|
-
function looksRateLimited(err) {
|
|
4347
|
-
const headers = err?.response?.headers ?? {};
|
|
4348
|
-
const remaining = headers['x-ratelimit-remaining'];
|
|
4349
|
-
if (typeof remaining === 'string' && remaining.trim() === '0')
|
|
4350
|
-
return true;
|
|
4351
|
-
if (typeof remaining === 'number' && remaining === 0)
|
|
4352
|
-
return true;
|
|
4353
|
-
if (headers['retry-after'] !== undefined)
|
|
4354
|
-
return true;
|
|
4355
|
-
return /rate limit|secondary rate|abuse detection/i.test(errorDetail(err));
|
|
4356
|
-
}
|
|
4357
|
-
/** True when a merged-state check failure cannot recover by retrying: an expired/revoked token
|
|
4358
|
-
* (401) or a token missing `pulls:read` (403 with no rate-limit evidence). */
|
|
4359
|
-
export function isPermanentMergeCheckError(err) {
|
|
4360
|
-
const status = err?.status;
|
|
4361
|
-
if (status === 401)
|
|
4362
|
-
return true;
|
|
4363
|
-
if (status !== 403)
|
|
4364
|
-
return false;
|
|
4365
|
-
return !looksRateLimited(err);
|
|
4366
|
-
}
|
|
4367
|
-
async function defaultListIssueLabels(octokit, owner, repo, issue) {
|
|
4368
|
-
const { data } = await octokit.rest.issues.listLabelsOnIssue({ owner, repo, issue_number: issue });
|
|
4369
|
-
return data.map((label) => label.name);
|
|
4370
|
-
}
|
|
4371
|
-
export async function waitForMerge(issue, branch, repoRoot, ghRepo, paths, deps = {}) {
|
|
4372
|
-
const { createOctokit = getOctokit, pathExists = existsSync, checkMerged = isPrMerged, loadConfig = configLoaders.loadFactoryConfigForRepo, land = landIssue, listIssueLabels = defaultListIssueLabels, sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, mergeEnabled, writeLine = (line) => console.log(line), now = () => Date.now(), mergeOverrides, landBranch, } = deps;
|
|
4373
|
-
const factoryConfig = loadConfig(paths.config);
|
|
4374
|
-
const isMergeEnabled = mergeEnabled ?? (() => resolveMergePolicy(factoryConfig, process.env, mergeOverrides).auto);
|
|
4375
|
-
const skipCI = configLoaders.resolveSkipCI(factoryConfig);
|
|
4376
|
-
const filingPolicy = resolveFilingPolicy(factoryConfig);
|
|
4377
|
-
const octokit = createOctokit();
|
|
4378
|
-
const [owner, repoName] = ghRepo.split('/');
|
|
4379
|
-
emitEvent(paths.events, 'await-merge', issue, `waiting to merge ${branch}`);
|
|
4380
|
-
let consecutiveFailures = 0;
|
|
4381
|
-
let firstFailureAt = null;
|
|
4382
|
-
while (!pathExists(paths.stop)) {
|
|
4383
|
-
// The merge wait logs nothing on its happy path, so each poll bumps the per-issue heartbeat:
|
|
4384
|
-
// status/TUI staleness rules (ADR-0086, ADR-0093) otherwise read a live wait as a dead run.
|
|
4385
|
-
if (paths.runs)
|
|
4386
|
-
touchRunActivity(phaseSnapshotFile(paths.runs, issue), new Date().toISOString()).catch(() => { });
|
|
4387
|
-
let merged = false;
|
|
4388
|
-
try {
|
|
4389
|
-
merged = await checkMerged(octokit, owner, repoName, branch);
|
|
4390
|
-
consecutiveFailures = 0;
|
|
4391
|
-
firstFailureAt = null;
|
|
4392
|
-
}
|
|
4393
|
-
catch (err) {
|
|
4394
|
-
consecutiveFailures++;
|
|
4395
|
-
firstFailureAt ??= now();
|
|
4396
|
-
const detail = errorDetail(err);
|
|
4397
|
-
emitEvent(paths.events, 'warn', issue, `merged-state check failed (treating as not merged): ${detail}`);
|
|
4398
|
-
const permanent = isPermanentMergeCheckError(err);
|
|
4399
|
-
const elapsedMs = now() - firstFailureAt;
|
|
4400
|
-
const budgetSpent = elapsedMs >= MERGE_CHECK_FAILURE_BUDGET_MS;
|
|
4401
|
-
const streakSpent = consecutiveFailures >= MERGE_CHECK_MAX_CONSECUTIVE_FAILURES;
|
|
4402
|
-
if (permanent || streakSpent || budgetSpent) {
|
|
4403
|
-
const trigger = permanent
|
|
4404
|
-
? 'permanent error (auth/scope — retrying cannot help)'
|
|
4405
|
-
: `${consecutiveFailures} consecutive failures over ${Math.round(elapsedMs / 1000)}s`;
|
|
4406
|
-
const msg = `merged-state check for ${branch} is not recovering — parking lane after ${trigger}: ${detail}`;
|
|
4407
|
-
emitEvent(paths.events, 'escalate', issue, msg);
|
|
4408
|
-
throw new LaneParkError(msg, 'escalate');
|
|
4409
|
-
}
|
|
4410
|
-
}
|
|
4411
|
-
if (merged) {
|
|
4412
|
-
emitEvent(paths.events, 'landed', issue, 'PR merged');
|
|
4413
|
-
return;
|
|
4414
|
-
}
|
|
4415
|
-
if (isMergeEnabled()) {
|
|
4416
|
-
let labels = [];
|
|
4417
|
-
try {
|
|
4418
|
-
labels = await listIssueLabels(octokit, owner, repoName, issue);
|
|
4419
|
-
}
|
|
4420
|
-
catch (err) {
|
|
4421
|
-
emitEvent(paths.events, 'warn', issue, `label check failed (treating as blocked): ${errorDetail(err)}`);
|
|
4422
|
-
labels = [filingPolicy.selfFixLabel];
|
|
4423
|
-
}
|
|
4424
|
-
if (isAutoMergeBlocked(labels, filingPolicy)) {
|
|
4425
|
-
emitEvent(paths.events, 'merge-gated', issue, `auto-merge blocked by ${filingPolicy.selfFixLabel} — awaiting human approval`);
|
|
4426
|
-
writeLine(`[factory] #${issue} auto-merge gated (${filingPolicy.selfFixLabel}); awaiting human merge (poll 120s)`);
|
|
4427
|
-
await sleep(120_000);
|
|
4428
|
-
continue;
|
|
4429
|
-
}
|
|
4430
|
-
if (landBranch)
|
|
4431
|
-
await land(issue, repoRoot, ghRepo, paths, octokit, skipCI, undefined, { branch: landBranch });
|
|
4432
|
-
else
|
|
4433
|
-
await land(issue, repoRoot, ghRepo, paths, octokit, skipCI);
|
|
4434
|
-
return;
|
|
4435
|
-
}
|
|
4436
|
-
writeLine(`[factory] #${issue} awaiting human merge (poll 120s)`);
|
|
4437
|
-
await sleep(120_000);
|
|
4438
|
-
}
|
|
4439
|
-
}
|
|
4440
3491
|
const USAGE_UNAVAILABLE_LINE = '[factory] supervise: usage signal unavailable — proceeding without resume gate (set FACTORY_USAGE_ESTIMATOR=1 to gate on the heuristic)';
|
|
4441
3492
|
export async function superviseLoop(deps) {
|
|
4442
3493
|
const { cap, resumeAt, pollMs, watch = true, watchSource = 'default', resumeAtSource = 'default', pollSource = 'default', estimator = false, stopFile, eventsFile, now, runQueue, readUsageFn = () => readUsage({ cap, estimator }), pathExists = existsSync, clearStop = (path) => rmSync(path, { force: true }), sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)), emitEvent = logEvent, writeLine = (line) => console.log(line), ingest, } = deps;
|