audit-tools 0.32.19 → 0.32.22
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/audit/clarification/blastRadius.d.ts +23 -0
- package/dist/audit/clarification/blastRadius.d.ts.map +1 -0
- package/dist/audit/clarification/blastRadius.js +65 -0
- package/dist/audit/clarification/blastRadius.js.map +1 -0
- package/dist/audit/clarification/dials.d.ts +28 -0
- package/dist/audit/clarification/dials.d.ts.map +1 -0
- package/dist/audit/clarification/dials.js +37 -0
- package/dist/audit/clarification/dials.js.map +1 -0
- package/dist/audit/clarification/partition.d.ts +25 -0
- package/dist/audit/clarification/partition.d.ts.map +1 -0
- package/dist/audit/clarification/partition.js +77 -0
- package/dist/audit/clarification/partition.js.map +1 -0
- package/dist/audit/clarification/riskGate.d.ts +24 -0
- package/dist/audit/clarification/riskGate.d.ts.map +1 -0
- package/dist/audit/clarification/riskGate.js +38 -0
- package/dist/audit/clarification/riskGate.js.map +1 -0
- package/dist/audit/clarification/voiQueue.d.ts +17 -0
- package/dist/audit/clarification/voiQueue.d.ts.map +1 -0
- package/dist/audit/clarification/voiQueue.js +37 -0
- package/dist/audit/clarification/voiQueue.js.map +1 -0
- package/dist/audit/cli/auditStep.d.ts +2 -0
- package/dist/audit/cli/auditStep.d.ts.map +1 -1
- package/dist/audit/cli/auditStep.js +9 -1
- package/dist/audit/cli/auditStep.js.map +1 -1
- package/dist/audit/cli/charterClarificationPrompt.d.ts +19 -0
- package/dist/audit/cli/charterClarificationPrompt.d.ts.map +1 -0
- package/dist/audit/cli/charterClarificationPrompt.js +60 -0
- package/dist/audit/cli/charterClarificationPrompt.js.map +1 -0
- package/dist/audit/cli/nextStepCommand.d.ts.map +1 -1
- package/dist/audit/cli/nextStepCommand.js +74 -0
- package/dist/audit/cli/nextStepCommand.js.map +1 -1
- package/dist/audit/cli/nextStepHelpers.d.ts +62 -0
- package/dist/audit/cli/nextStepHelpers.d.ts.map +1 -1
- package/dist/audit/cli/nextStepHelpers.js +129 -0
- package/dist/audit/cli/nextStepHelpers.js.map +1 -1
- package/dist/audit/cli/steps.d.ts +4 -4
- package/dist/audit/cli/steps.d.ts.map +1 -1
- package/dist/audit/cli/steps.js +2 -0
- package/dist/audit/cli/steps.js.map +1 -1
- package/dist/audit/io/artifacts.d.ts +6 -0
- package/dist/audit/io/artifacts.d.ts.map +1 -1
- package/dist/audit/io/artifacts.js +2 -0
- package/dist/audit/io/artifacts.js.map +1 -1
- package/dist/audit/orchestrator/advance.d.ts +23 -0
- package/dist/audit/orchestrator/advance.d.ts.map +1 -1
- package/dist/audit/orchestrator/advance.js +122 -4
- package/dist/audit/orchestrator/advance.js.map +1 -1
- package/dist/audit/orchestrator/advanceTypes.d.ts +22 -1
- package/dist/audit/orchestrator/advanceTypes.d.ts.map +1 -1
- package/dist/audit/orchestrator/charterClarificationExecutor.d.ts +28 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.d.ts.map +1 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.js +130 -0
- package/dist/audit/orchestrator/charterClarificationExecutor.js.map +1 -0
- package/dist/audit/orchestrator/dependencyMap.d.ts +2 -0
- package/dist/audit/orchestrator/dependencyMap.d.ts.map +1 -1
- package/dist/audit/orchestrator/dependencyMap.js +24 -0
- package/dist/audit/orchestrator/dependencyMap.js.map +1 -1
- package/dist/audit/orchestrator/designReviewPrompt.d.ts +17 -0
- package/dist/audit/orchestrator/designReviewPrompt.d.ts.map +1 -1
- package/dist/audit/orchestrator/designReviewPrompt.js +50 -0
- package/dist/audit/orchestrator/designReviewPrompt.js.map +1 -1
- package/dist/audit/orchestrator/executorRunners.d.ts.map +1 -1
- package/dist/audit/orchestrator/executorRunners.js +4 -0
- package/dist/audit/orchestrator/executorRunners.js.map +1 -1
- package/dist/audit/orchestrator/executors.d.ts.map +1 -1
- package/dist/audit/orchestrator/executors.js +20 -0
- package/dist/audit/orchestrator/executors.js.map +1 -1
- package/dist/audit/orchestrator/nextStep.d.ts +11 -1
- package/dist/audit/orchestrator/nextStep.d.ts.map +1 -1
- package/dist/audit/orchestrator/nextStep.js +6 -2
- package/dist/audit/orchestrator/nextStep.js.map +1 -1
- package/dist/audit/orchestrator/staleness.d.ts +27 -1
- package/dist/audit/orchestrator/staleness.d.ts.map +1 -1
- package/dist/audit/orchestrator/staleness.js +33 -16
- package/dist/audit/orchestrator/staleness.js.map +1 -1
- package/dist/audit/orchestrator/state.d.ts +12 -1
- package/dist/audit/orchestrator/state.d.ts.map +1 -1
- package/dist/audit/orchestrator/state.js +41 -2
- package/dist/audit/orchestrator/state.js.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.d.ts.map +1 -1
- package/dist/audit/orchestrator/synthesisExecutors.js +1 -0
- package/dist/audit/orchestrator/synthesisExecutors.js.map +1 -1
- package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts +25 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.d.ts.map +1 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.js +107 -0
- package/dist/audit/orchestrator/systemicChallengeExecutor.js.map +1 -0
- package/dist/audit/reporting/mergeFindings.d.ts +2 -1
- package/dist/audit/reporting/mergeFindings.d.ts.map +1 -1
- package/dist/audit/reporting/mergeFindings.js +6 -1
- package/dist/audit/reporting/mergeFindings.js.map +1 -1
- package/dist/audit/reporting/synthesis.d.ts +2 -0
- package/dist/audit/reporting/synthesis.d.ts.map +1 -1
- package/dist/audit/reporting/synthesis.js +1 -1
- package/dist/audit/reporting/synthesis.js.map +1 -1
- package/dist/audit/systemic/aggregateMetricsDigest.d.ts +11 -0
- package/dist/audit/systemic/aggregateMetricsDigest.d.ts.map +1 -0
- package/dist/audit/systemic/aggregateMetricsDigest.js +84 -0
- package/dist/audit/systemic/aggregateMetricsDigest.js.map +1 -0
- package/dist/audit/systemic/metricsDigestTypes.d.ts +35 -0
- package/dist/audit/systemic/metricsDigestTypes.d.ts.map +1 -0
- package/dist/audit/systemic/metricsDigestTypes.js +10 -0
- package/dist/audit/systemic/metricsDigestTypes.js.map +1 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts +16 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.d.ts.map +1 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.js +85 -0
- package/dist/audit/systemic/secondOrderAdversaryPrompt.js.map +1 -0
- package/dist/audit/systemic/systemicChallengeLoop.d.ts +48 -0
- package/dist/audit/systemic/systemicChallengeLoop.d.ts.map +1 -0
- package/dist/audit/systemic/systemicChallengeLoop.js +107 -0
- package/dist/audit/systemic/systemicChallengeLoop.js.map +1 -0
- package/dist/audit/types/charterClarification.d.ts +52 -0
- package/dist/audit/types/charterClarification.d.ts.map +1 -0
- package/dist/audit/types/charterClarification.js +2 -0
- package/dist/audit/types/charterClarification.js.map +1 -0
- package/dist/audit/types/systemicChallenge.d.ts +69 -0
- package/dist/audit/types/systemicChallenge.d.ts.map +1 -0
- package/dist/audit/types/systemicChallenge.js +2 -0
- package/dist/audit/types/systemicChallenge.js.map +1 -0
- package/dist/remediate/phases/triage.d.ts.map +1 -1
- package/dist/remediate/phases/triage.js +16 -1
- package/dist/remediate/phases/triage.js.map +1 -1
- package/dist/remediate/steps/dispatch/acceptNode.d.ts +178 -0
- package/dist/remediate/steps/dispatch/acceptNode.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/acceptNode.js +351 -0
- package/dist/remediate/steps/dispatch/acceptNode.js.map +1 -0
- package/dist/remediate/steps/dispatch/common.d.ts +123 -0
- package/dist/remediate/steps/dispatch/common.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/common.js +236 -0
- package/dist/remediate/steps/dispatch/common.js.map +1 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.d.ts +37 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.js +19 -0
- package/dist/remediate/steps/dispatch/dagNodeFields.js.map +1 -0
- package/dist/remediate/steps/dispatch/implementPrompt.d.ts +70 -0
- package/dist/remediate/steps/dispatch/implementPrompt.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/implementPrompt.js +577 -0
- package/dist/remediate/steps/dispatch/implementPrompt.js.map +1 -0
- package/dist/remediate/steps/dispatch/marshal.d.ts +32 -0
- package/dist/remediate/steps/dispatch/marshal.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/marshal.js +935 -0
- package/dist/remediate/steps/dispatch/marshal.js.map +1 -0
- package/dist/remediate/steps/dispatch/verifyCommands.d.ts +97 -0
- package/dist/remediate/steps/dispatch/verifyCommands.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/verifyCommands.js +204 -0
- package/dist/remediate/steps/dispatch/verifyCommands.js.map +1 -0
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts +90 -0
- package/dist/remediate/steps/dispatch/waveScheduling.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/waveScheduling.js +352 -0
- package/dist/remediate/steps/dispatch/waveScheduling.js.map +1 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts +189 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.js +643 -0
- package/dist/remediate/steps/dispatch/worktreeLifecycle.js.map +1 -0
- package/dist/remediate/steps/dispatch/writeScope.d.ts +151 -0
- package/dist/remediate/steps/dispatch/writeScope.d.ts.map +1 -0
- package/dist/remediate/steps/dispatch/writeScope.js +376 -0
- package/dist/remediate/steps/dispatch/writeScope.js.map +1 -0
- package/dist/remediate/steps/dispatch.d.ts +16 -863
- package/dist/remediate/steps/dispatch.d.ts.map +1 -1
- package/dist/remediate/steps/dispatch.js +18 -3613
- package/dist/remediate/steps/dispatch.js.map +1 -1
- package/dist/shared/decompose/charterClarification.d.ts +80 -0
- package/dist/shared/decompose/charterClarification.d.ts.map +1 -0
- package/dist/shared/decompose/charterClarification.js +105 -0
- package/dist/shared/decompose/charterClarification.js.map +1 -0
- package/dist/shared/decompose/systemicChallenge.d.ts +326 -0
- package/dist/shared/decompose/systemicChallenge.d.ts.map +1 -0
- package/dist/shared/decompose/systemicChallenge.js +30 -0
- package/dist/shared/decompose/systemicChallenge.js.map +1 -0
- package/dist/shared/index.d.ts +10 -5
- package/dist/shared/index.d.ts.map +1 -1
- package/dist/shared/index.js +6 -4
- package/dist/shared/index.js.map +1 -1
- package/dist/shared/opencodePermissions.d.ts +73 -0
- package/dist/shared/opencodePermissions.d.ts.map +1 -1
- package/dist/shared/opencodePermissions.js +172 -0
- package/dist/shared/opencodePermissions.js.map +1 -1
- package/dist/shared/tooling/exec.d.ts +10 -1
- package/dist/shared/tooling/exec.d.ts.map +1 -1
- package/dist/shared/tooling/exec.js +13 -1
- package/dist/shared/tooling/exec.js.map +1 -1
- package/dist/shared/types/charter.d.ts +89 -0
- package/dist/shared/types/charter.d.ts.map +1 -1
- package/dist/shared/types/charter.js +68 -0
- package/dist/shared/types/charter.js.map +1 -1
- package/dist/shared/types/intentCheckpoint.d.ts +15 -0
- package/dist/shared/types/intentCheckpoint.d.ts.map +1 -1
- package/dist/shared/types/intentCheckpoint.js +11 -0
- package/dist/shared/types/intentCheckpoint.js.map +1 -1
- package/dist/shared/validation/charterGate.d.ts +20 -1
- package/dist/shared/validation/charterGate.d.ts.map +1 -1
- package/dist/shared/validation/charterGate.js +23 -0
- package/dist/shared/validation/charterGate.js.map +1 -1
- package/docs/audit-pkg/contracts.md +1 -1
- package/docs/audit-pkg/development.md +8 -7
- package/docs/audit-pkg/product.md +7 -4
- package/docs/audit-pkg/release.md +4 -2
- package/opencode.json +36 -20
- package/package.json +1 -1
- package/scripts/postinstall.mjs +2 -1
- package/wrapper/audit-code-wrapper-opencode.mjs +38 -12
- package/wrapper/remediate-code-wrapper-opencode.mjs +38 -12
|
@@ -1,3615 +1,20 @@
|
|
|
1
|
-
import { mkdir, rename } from "node:fs/promises";
|
|
2
|
-
import { cpSync, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
|
3
|
-
import { join, relative, dirname, resolve, isAbsolute } from "node:path";
|
|
4
|
-
import { OwnershipRegistry } from "../dispatch/ownershipRegistry.js";
|
|
5
|
-
import { routeAmendmentRequest } from "../dispatch/amendmentClaim.js";
|
|
6
|
-
import { toBlockId, fromBlockId } from "../contractPipeline/idRegistry.js";
|
|
7
|
-
import { readContractArtifact } from "../contractPipeline/artifactStore.js";
|
|
8
|
-
import { verifyPairingForFinding } from "../contractPipeline/changeClassification.js";
|
|
9
|
-
import { StateStore } from "../state/store.js";
|
|
10
|
-
import { REMEDIATION_STEP, isClarificationCategory, } from "../state/types.js";
|
|
11
|
-
import { computeDispatchAdmission, createReservationLedger, tierRank, spawnSyncHidden } from "audit-tools/shared";
|
|
12
|
-
import { deriveCostRank, lookupConfirmedPosition, readConfirmedCostPositions } from "audit-tools/shared";
|
|
13
|
-
import { scheduleWave as computePoolWaveSchedule } from "audit-tools/shared";
|
|
14
|
-
import { withFileLock } from "audit-tools/shared";
|
|
15
|
-
import { captureStepBoundaryFriction } from "audit-tools/shared";
|
|
16
|
-
import { findingLead, renderFindingBadgeBody } from "audit-tools/shared";
|
|
17
|
-
import { runShellCommand, runCommand } from "../utils/commands.js";
|
|
18
|
-
import { mergedBaseCheckArgv } from "./gateCommands.js";
|
|
19
|
-
import { AGENT_FEEDBACK_FILENAME, readJsonFile, readOptionalJsonFile, writeJsonFile, writeTextFile, withFsRetry, formatValidationIssues, detectRepoConventions, formatRepoConventions, toPromptPathToken, estimateTokensFromBytes, buildQuotaSource, HostSessionQuotaSource, severityRank, findingNeedsVerificationBeforeFix, compareTier, mostCapableTier, normalizeRepoPath, buildSourcePools, buildHostModelPools, resolveHostProviderName, isDemotableInProcessProvider, } from "audit-tools/shared";
|
|
20
|
-
import { validateImplementWorkerResult } from "../validation/artifacts.js";
|
|
21
|
-
import { REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION, REMEDIATION_DISPATCH_QUOTA_CONTRACT_VERSION, REMEDIATION_WORKER_RESULT_CONTRACT_VERSION, } from "./types.js";
|
|
22
|
-
import { classifyFindingRisk, specIndicatesNoChange, hasExecutableEvidence, dependencyVerifiedComplete, } from "./stepUtils.js";
|
|
23
|
-
import { isTerminalStatus, isVerifiedCompleteStatus, isSkipStatus, } from "../state/itemStatus.js";
|
|
24
|
-
import { resnapshotAffectedFileHashes } from "../utils/fileIntegrity.js";
|
|
25
|
-
import { computeDispatchCapacity, resolveHostActiveSubagentLimit, readQuotaState, buildProviderModelKey, computeBackoffCooldownMs, computeBackoffFailureWeight, summarizeDispatchCapacityPools, } from "../quota/index.js";
|
|
26
|
-
export { resolveHostActiveSubagentLimit };
|
|
27
|
-
export { detectHostActiveSubagentLimit as detectHostConcurrencyFromEnv, } from "../quota/hostLimits.js";
|
|
28
|
-
const DEFAULT_WAVE_SIZE = 5;
|
|
29
|
-
export function normalizeSlotTokens(tokens, count) {
|
|
30
|
-
if (!tokens || tokens.length === 0)
|
|
31
|
-
return new Array(count).fill(0);
|
|
32
|
-
if (tokens.length > count)
|
|
33
|
-
return tokens.slice(0, count);
|
|
34
|
-
if (tokens.length < count)
|
|
35
|
-
return [...tokens, ...new Array(count - tokens.length).fill(0)];
|
|
36
|
-
return tokens;
|
|
37
|
-
}
|
|
38
|
-
export function resolveHostConcurrencyLimit(options) {
|
|
39
|
-
return resolveHostActiveSubagentLimit({
|
|
40
|
-
explicitLimit: options.hostMaxConcurrent,
|
|
41
|
-
sessionConfig: options.sessionConfig ?? {},
|
|
42
|
-
env: options.env,
|
|
43
|
-
});
|
|
44
|
-
}
|
|
45
|
-
function _capacityPoolSummary(poolId, slots, schedule) {
|
|
46
|
-
return {
|
|
47
|
-
pool_id: poolId,
|
|
48
|
-
slots,
|
|
49
|
-
model: schedule.model,
|
|
50
|
-
confidence: schedule.confidence,
|
|
51
|
-
source: schedule.source,
|
|
52
|
-
resolved_limits: schedule.resolved_limits,
|
|
53
|
-
host_concurrency_limit: schedule.host_concurrency_limit,
|
|
54
|
-
cooldown_until: schedule.cooldown_until,
|
|
55
|
-
estimated_wave_tokens: schedule.estimated_wave_tokens,
|
|
56
|
-
binding_cap: schedule.binding_cap ?? "none",
|
|
57
|
-
quota_source_snapshot: schedule.quota_source_snapshot ?? null,
|
|
58
|
-
};
|
|
59
|
-
}
|
|
60
|
-
/**
|
|
61
|
-
* Most-capable rank first, so the largest pending items land on the rank with
|
|
62
|
-
* the largest window. Ordering comes from the single shared tier-rank authority
|
|
63
|
-
* (`compareTier`, negated for descending) — no local {small,standard,deep} copy.
|
|
64
|
-
*/
|
|
65
|
-
function sortRosterMostCapableFirst(roster) {
|
|
66
|
-
return [...roster].sort((a, b) => compareTier(b.rank, a.rank));
|
|
67
|
-
}
|
|
68
|
-
async function buildHostPoolPreamble(input) {
|
|
69
|
-
const sessionConfig = input.sessionConfig ?? {};
|
|
70
|
-
const providerName = input.providerName ??
|
|
71
|
-
sessionConfig.provider ??
|
|
72
|
-
"claude-code";
|
|
73
|
-
const hostModel = input.hostModel ??
|
|
74
|
-
sessionConfig.block_quota
|
|
75
|
-
?.host_model ??
|
|
76
|
-
null;
|
|
77
|
-
// Quota-key identity: resolved model name, else the host's opaque id, else
|
|
78
|
-
// null → `provider/*`. Per-roster-rank `model_id` overrides per pool below.
|
|
79
|
-
const quotaModelKeySegment = hostModel ?? input.hostModelId ?? null;
|
|
80
|
-
const roster = input.hostModels?.length
|
|
81
|
-
? sortRosterMostCapableFirst(input.hostModels)
|
|
82
|
-
: null;
|
|
83
|
-
const hostLimit = resolveHostConcurrencyLimit({
|
|
84
|
-
hostMaxConcurrent: input.hostMaxConcurrent,
|
|
85
|
-
sessionConfig,
|
|
86
|
-
env: input.env,
|
|
87
|
-
});
|
|
88
|
-
// The capability handshake: the host reported its dispatch model's real
|
|
89
|
-
// context/output window this session (the roster's most capable entry under
|
|
90
|
-
// the multi-rank handshake). Carried into the pool's discoveredLimits so the
|
|
91
|
-
// shared discovered_capability rung sizes the budget to the real window
|
|
92
|
-
// instead of the conservative 32k floor. RPM/TPM stay null and fill from the
|
|
93
|
-
// learned quota state.
|
|
94
|
-
const hostContextTokens = input.hostContextTokens ?? roster?.[0]?.context_tokens ?? null;
|
|
95
|
-
const hostOutputTokens = input.hostOutputTokens ?? roster?.[0]?.output_tokens ?? null;
|
|
96
|
-
const hostCapabilityLimits = hostContextTokens != null || hostOutputTokens != null
|
|
97
|
-
? { context_tokens: hostContextTokens, output_tokens: hostOutputTokens }
|
|
98
|
-
: null;
|
|
99
|
-
let quotaEntries = {};
|
|
100
|
-
try {
|
|
101
|
-
const state = await readQuotaState();
|
|
102
|
-
quotaEntries = state.entries;
|
|
103
|
-
}
|
|
104
|
-
catch (err) {
|
|
105
|
-
process.stderr.write(`[waveScheduler] readQuotaState failed; degrading to no learned quota entry. ${err instanceof Error ? err.message : String(err)}\n`);
|
|
106
|
-
}
|
|
107
|
-
// The proactive quota snapshot (Claude OAuth source, then learned) so the
|
|
108
|
-
// scheduler can throttle/cooldown from live remaining quota — mirrors
|
|
109
|
-
// audit-code's buildDispatchPool. PREPEND the host-session source keyed on
|
|
110
|
-
// this host pool's own (provider, model) key — first-class PRE-WALL source
|
|
111
|
-
// (graduated remaining_pct → LOW/CRITICAL throttle before a 429), gating on the
|
|
112
|
-
// exact key so it never masks the proactive/learned sources.
|
|
113
|
-
const quotaSource = buildQuotaSource({
|
|
114
|
-
halfLifeHours: sessionConfig.quota
|
|
115
|
-
?.empirical_half_life_hours,
|
|
116
|
-
hostSession: input.hostSession ??
|
|
117
|
-
new HostSessionQuotaSource({
|
|
118
|
-
providerModelKey: buildProviderModelKey(providerName, quotaModelKeySegment),
|
|
119
|
-
}),
|
|
120
|
-
});
|
|
121
|
-
// One capacity pool per reported roster rank (most capable first), each with
|
|
122
|
-
// its own discovered window and quota key; a single pool for the scalar/absent
|
|
123
|
-
// handshake. Built via the shared host-pool-from-roster core so the pool shape
|
|
124
|
-
// + account-keyed pool ids can't drift across the two consumers.
|
|
125
|
-
const primaryPools = await buildHostModelPools({
|
|
126
|
-
providerName,
|
|
127
|
-
hostModel,
|
|
128
|
-
hostConcurrencyLimit: hostLimit,
|
|
129
|
-
quotaSource,
|
|
130
|
-
quotaEntries,
|
|
131
|
-
roster,
|
|
132
|
-
resolve: (entry) => ({
|
|
133
|
-
poolKey: buildProviderModelKey(providerName, entry?.model_id ?? quotaModelKeySegment),
|
|
134
|
-
discoveredLimits: entry
|
|
135
|
-
? { context_tokens: entry.context_tokens, output_tokens: entry.output_tokens }
|
|
136
|
-
: hostCapabilityLimits,
|
|
137
|
-
}),
|
|
138
|
-
});
|
|
139
|
-
return {
|
|
140
|
-
sessionConfig,
|
|
141
|
-
providerName,
|
|
142
|
-
hostModel,
|
|
143
|
-
quotaModelKeySegment,
|
|
144
|
-
roster,
|
|
145
|
-
hostLimit,
|
|
146
|
-
hostContextTokens,
|
|
147
|
-
hostOutputTokens,
|
|
148
|
-
hostCapabilityLimits,
|
|
149
|
-
quotaEntries,
|
|
150
|
-
quotaSource,
|
|
151
|
-
primaryPools,
|
|
152
|
-
};
|
|
153
|
-
}
|
|
154
|
-
export async function scheduleWave(input) {
|
|
155
|
-
const sessionConfig = input.sessionConfig ?? {};
|
|
156
|
-
const providerName = input.providerName ?? sessionConfig.provider ?? "claude-code";
|
|
157
|
-
const hostModel = input.hostModel ?? sessionConfig.block_quota?.host_model ?? null;
|
|
158
|
-
// Quota-key identity: resolved model name, else the host's opaque id, else
|
|
159
|
-
// null → `provider/*`. Per-roster-rank `model_id` overrides per pool below.
|
|
160
|
-
const quotaModelKeySegment = hostModel ?? input.hostModelId ?? null;
|
|
161
|
-
const roster = input.hostModels?.length
|
|
162
|
-
? sortRosterMostCapableFirst(input.hostModels)
|
|
163
|
-
: null;
|
|
164
|
-
const hostLimit = resolveHostConcurrencyLimit({
|
|
165
|
-
hostMaxConcurrent: input.hostMaxConcurrent,
|
|
166
|
-
sessionConfig,
|
|
167
|
-
env: input.env,
|
|
168
|
-
});
|
|
169
|
-
// The capability handshake: the host reported its dispatch model's real
|
|
170
|
-
// context/output window this session (the roster's most capable entry under
|
|
171
|
-
// the multi-rank handshake). Carried into the pool's discoveredLimits so the
|
|
172
|
-
// shared discovered_capability rung sizes the budget to the real window
|
|
173
|
-
// instead of the conservative 32k floor. RPM/TPM stay null and fill from the
|
|
174
|
-
// learned quota state.
|
|
175
|
-
const hostContextTokens = input.hostContextTokens ?? roster?.[0]?.context_tokens ?? null;
|
|
176
|
-
const hostOutputTokens = input.hostOutputTokens ?? roster?.[0]?.output_tokens ?? null;
|
|
177
|
-
const quota = sessionConfig.quota;
|
|
178
|
-
if (!quota || quota.enabled === false) {
|
|
179
|
-
const cap = hostLimit?.active_subagents ?? DEFAULT_WAVE_SIZE;
|
|
180
|
-
const waveSize = Math.max(1, Math.min(cap, input.itemCount));
|
|
181
|
-
// Single-source the quota-off schedule math (wave-token formula + confidence +
|
|
182
|
-
// source) through the shared scheduler so the two orchestrators can't drift —
|
|
183
|
-
// remediate previously re-implemented it with a flat average + confidence:"low",
|
|
184
|
-
// disagreeing with shared's sumTopN + "high". The wave is already capped to
|
|
185
|
-
// `waveSize`, so shared (`requestedConcurrency: waveSize`, no quota) returns it
|
|
186
|
-
// verbatim. Only the roster-honoring window and the DEFAULT_WAVE_SIZE-aware
|
|
187
|
-
// binding_cap are remediate-specific and layered back on top.
|
|
188
|
-
const base = computePoolWaveSchedule({
|
|
189
|
-
providerName,
|
|
190
|
-
sessionConfig,
|
|
191
|
-
hostModel,
|
|
192
|
-
requestedConcurrency: waveSize,
|
|
193
|
-
estimatedSlotTokens: input.estimatedSlotTokens,
|
|
194
|
-
hostConcurrencyLimit: hostLimit,
|
|
195
|
-
});
|
|
196
|
-
const schedule = {
|
|
197
|
-
...base,
|
|
198
|
-
resolved_limits: {
|
|
199
|
-
// Honor a host-reported window even with quota disabled; fall back to the
|
|
200
|
-
// shared scheduler's default only when nothing was discovered.
|
|
201
|
-
context_tokens: hostContextTokens ?? base.resolved_limits.context_tokens,
|
|
202
|
-
output_tokens: hostOutputTokens ?? base.resolved_limits.output_tokens,
|
|
203
|
-
requests_per_minute: null,
|
|
204
|
-
input_tokens_per_minute: null,
|
|
205
|
-
output_tokens_per_minute: null,
|
|
206
|
-
},
|
|
207
|
-
host_concurrency_limit: hostLimit,
|
|
208
|
-
// hostLimit-gated: a DEFAULT_WAVE_SIZE cap below itemCount is NOT a host
|
|
209
|
-
// concurrency bind (there is no host limit), so it stays "none".
|
|
210
|
-
binding_cap: hostLimit && waveSize < input.itemCount ? "host_concurrency" : "none",
|
|
211
|
-
};
|
|
212
|
-
return {
|
|
213
|
-
...schedule,
|
|
214
|
-
capacity_pools: [_capacityPoolSummary(buildProviderModelKey(providerName, quotaModelKeySegment), waveSize, schedule)],
|
|
215
|
-
};
|
|
216
|
-
}
|
|
217
|
-
const preamble = await buildHostPoolPreamble({
|
|
218
|
-
sessionConfig: input.sessionConfig,
|
|
219
|
-
providerName: input.providerName,
|
|
220
|
-
hostModel: input.hostModel,
|
|
221
|
-
hostMaxConcurrent: input.hostMaxConcurrent,
|
|
222
|
-
hostContextTokens: input.hostContextTokens,
|
|
223
|
-
hostOutputTokens: input.hostOutputTokens,
|
|
224
|
-
hostModels: input.hostModels,
|
|
225
|
-
hostModelId: input.hostModelId,
|
|
226
|
-
env: input.env,
|
|
227
|
-
});
|
|
228
|
-
const capacity = computeDispatchCapacity({
|
|
229
|
-
pools: preamble.primaryPools,
|
|
230
|
-
sessionConfig,
|
|
231
|
-
pendingItemTokens: normalizeSlotTokens(input.estimatedSlotTokens, input.itemCount),
|
|
232
|
-
});
|
|
233
|
-
return {
|
|
234
|
-
...capacity.primary.schedule,
|
|
235
|
-
capacity_pools: summarizeDispatchCapacityPools(capacity),
|
|
236
|
-
};
|
|
237
|
-
}
|
|
238
|
-
/**
|
|
239
|
-
* Build the confirmed `CapacityPool[]` for a dispatch — one pool per reported
|
|
240
|
-
* roster rank (each with its own discovered window + quota key), or a single
|
|
241
|
-
* conservative pool for the scalar/absent handshake. This is the same pool shape
|
|
242
|
-
* `scheduleWave` constructs internally; it is exposed so the rolling dispatch
|
|
243
|
-
* engine (which is fed `confirmedPools` directly) sizes concurrency from the
|
|
244
|
-
* identical quota inputs, never from a raw host flag. Reused by
|
|
245
|
-
* `driveRollingImplementDispatch`.
|
|
246
|
-
*/
|
|
247
|
-
export async function buildConfirmedPools(input) {
|
|
248
|
-
// Defect-1: the ACTUAL configured backend (used to build the demoted source pool) vs
|
|
249
|
-
// the HOST-pool identity. When an attended host demotes a headless backend to a source
|
|
250
|
-
// (codex/opencode/openai-compatible), the host pool must key to the CONVERSATION HOST
|
|
251
|
-
// (claude-code), not the backend — otherwise the host fan-out is charged against the
|
|
252
|
-
// backend's meter AND collides with the demoted source pool ([[capability-is-per-auditor-not-per-audit]]).
|
|
253
|
-
const actualProviderName = resolveHostProviderName(input.sessionConfig);
|
|
254
|
-
const demoteHostIdentity = input.demotePrimaryInProcess === true && isDemotableInProcessProvider(actualProviderName);
|
|
255
|
-
const hostProviderName = demoteHostIdentity
|
|
256
|
-
? "claude-code"
|
|
257
|
-
: actualProviderName;
|
|
258
|
-
// Resolve identity/limits and build the per-rank host-model pools via the
|
|
259
|
-
// SAME preamble `scheduleWave` uses — the two no longer keep parallel copies.
|
|
260
|
-
const { sessionConfig, quotaSource, quotaEntries, primaryPools } = await buildHostPoolPreamble({
|
|
261
|
-
sessionConfig: input.sessionConfig,
|
|
262
|
-
providerName: hostProviderName,
|
|
263
|
-
hostMaxConcurrent: input.hostMaxConcurrent,
|
|
264
|
-
hostContextTokens: input.hostContextTokens,
|
|
265
|
-
hostOutputTokens: input.hostOutputTokens,
|
|
266
|
-
hostModels: input.hostModels,
|
|
267
|
-
hostModelId: input.hostModelId,
|
|
268
|
-
env: input.env,
|
|
269
|
-
hostSession: input.hostSession,
|
|
270
|
-
});
|
|
271
|
-
// Every configured dispatchable backend source (any non-IDE source: NIM/vLLM API,
|
|
272
|
-
// a CLI pool, …) becomes a CapacityPool alongside the primary, so the scheduler's
|
|
273
|
-
// proactive cross-pool spill (INV-QD-14) and the A-8 coordinator can route work to
|
|
274
|
-
// them. Single-sourced in shared (`buildSourcePools`) so audit and remediate surface
|
|
275
|
-
// the IDENTICAL pool shapes — the spill topology can't drift. `primaryProviderName`
|
|
276
|
-
// is the ACTUAL configured backend (not the demoted host identity) so the demoted
|
|
277
|
-
// source is built for the real provider.
|
|
278
|
-
const sourcePools = await buildSourcePools({
|
|
279
|
-
sessionConfig,
|
|
280
|
-
primaryProviderName: actualProviderName,
|
|
281
|
-
quotaSource,
|
|
282
|
-
quotaEntries,
|
|
283
|
-
demotePrimaryInProcess: input.demotePrimaryInProcess,
|
|
284
|
-
});
|
|
285
|
-
primaryPools.push(...sourcePools);
|
|
286
|
-
return primaryPools;
|
|
287
|
-
}
|
|
288
|
-
/**
|
|
289
|
-
* Build the `AdmissionPool[]` the admission loop admits against from a schedule's
|
|
290
|
-
* per-pool capacity summaries — the remediate analog of audit's `finalizeDispatchQuota`
|
|
291
|
-
* pool mapping (both feed the single-sourced `computeDispatchAdmission`, so the two
|
|
292
|
-
* orchestrators can't drift). Budget = the pool's live remaining token budget
|
|
293
|
-
* (null ⇒ optimistic +Inf); declared cap = its host in-flight cap passed VERBATIM
|
|
294
|
-
* (null ⇒ none — the only place an explicit agent-count exists); cost/capability rank
|
|
295
|
-
* from its tier; capacity = its context window (a packet's input+output envelope must fit).
|
|
296
|
-
*/
|
|
297
|
-
function admissionPoolsFromSchedule(schedule, confirmedCostPositions) {
|
|
298
|
-
return (schedule.capacity_pools ?? []).map((pool) => {
|
|
299
|
-
// costRank is a REAL cost axis (blended $/Mtok via the shared cost-first
|
|
300
|
-
// engine), decoupled from capabilityRank (still the tier ordinal). See
|
|
301
|
-
// spec/cost-first-routing.md. Rung 1 (operator-confirmed) via the model-keyed
|
|
302
|
-
// map; absent ⇒ price (rung 2) ⇒ tier (rung 3).
|
|
303
|
-
return {
|
|
304
|
-
poolId: pool.pool_id,
|
|
305
|
-
resourceKey: pool.pool_id,
|
|
306
|
-
budget: pool.remaining_token_budget ?? Number.POSITIVE_INFINITY,
|
|
307
|
-
declaredCap: pool.host_concurrency_limit?.active_subagents ?? null,
|
|
308
|
-
costRank: deriveCostRank({
|
|
309
|
-
model: pool.model,
|
|
310
|
-
tier: pool.rank,
|
|
311
|
-
confirmedPosition: lookupConfirmedPosition(confirmedCostPositions, pool.model),
|
|
312
|
-
}),
|
|
313
|
-
capabilityRank: tierRank(pool.rank),
|
|
314
|
-
capacityTokens: pool.resolved_limits.context_tokens,
|
|
315
|
-
};
|
|
316
|
-
});
|
|
317
|
-
}
|
|
318
|
-
export async function buildDispatchQuota(runId, phase, schedule, admissionPackets,
|
|
319
|
-
/**
|
|
320
|
-
* Whether to LEASE the granted set against the shared reservation ledger. The
|
|
321
|
-
* host-subagent path passes `true` (the host dispatches the grant across processes,
|
|
322
|
-
* so the tool reserves-before-dispatch and reconciles at accept-node). The
|
|
323
|
-
* in-process rolling engine passes `false`: it admits + leases per-packet itself, so
|
|
324
|
-
* a host-grant lease here would double-count the same work. Mirrors audit's
|
|
325
|
-
* `finalizeDispatchQuota` grantLeases parameterization.
|
|
326
|
-
*/
|
|
327
|
-
grantLeases, quotaStateEntry,
|
|
328
|
-
/**
|
|
329
|
-
* Operator-confirmed cost ordering (rung 1 of costRank; spec/cost-first-routing.md),
|
|
330
|
-
* keyed by model id → 0-based confirmed position. Absent ⇒ price then tier.
|
|
331
|
-
*/
|
|
332
|
-
confirmedCostPositions) {
|
|
333
|
-
let backoffState = null;
|
|
334
|
-
const count = quotaStateEntry?.consecutive_429_count ?? 0;
|
|
335
|
-
if (count > 0) {
|
|
336
|
-
backoffState = {
|
|
337
|
-
consecutive_429_count: count,
|
|
338
|
-
current_cooldown_ms: computeBackoffCooldownMs(count),
|
|
339
|
-
current_failure_weight: computeBackoffFailureWeight(count),
|
|
340
|
-
};
|
|
341
|
-
}
|
|
342
|
-
// Admission control: instead of a computed `max_concurrent_agents`, GRANT the
|
|
343
|
-
// affordable admitted set (cost-first-capable, ledger-leased). Per-packet reservation
|
|
344
|
-
// = input estimate + output envelope (declared output cap; the learned ratio refines
|
|
345
|
-
// it once a provider reports usage — dormant on the always-on claude-code host).
|
|
346
|
-
const admission = await computeDispatchAdmission({
|
|
347
|
-
packets: admissionPackets,
|
|
348
|
-
pools: admissionPoolsFromSchedule(schedule, confirmedCostPositions),
|
|
349
|
-
outputCap: schedule.resolved_limits.output_tokens,
|
|
350
|
-
grantLeases,
|
|
351
|
-
ledger: createReservationLedger(),
|
|
352
|
-
});
|
|
353
|
-
return {
|
|
354
|
-
contract_version: REMEDIATION_DISPATCH_QUOTA_CONTRACT_VERSION,
|
|
355
|
-
run_id: runId,
|
|
356
|
-
phase,
|
|
357
|
-
host_concurrency_limit: schedule.host_concurrency_limit,
|
|
358
|
-
admission,
|
|
359
|
-
estimated_wave_tokens: schedule.estimated_wave_tokens,
|
|
360
|
-
model: schedule.model,
|
|
361
|
-
confidence: schedule.confidence,
|
|
362
|
-
source: schedule.source,
|
|
363
|
-
resolved_limits: schedule.resolved_limits,
|
|
364
|
-
cooldown_until: schedule.cooldown_until,
|
|
365
|
-
binding_cap: schedule.binding_cap ?? "none",
|
|
366
|
-
capacity_pools: schedule.capacity_pools,
|
|
367
|
-
quota_source_snapshot: schedule.quota_source_snapshot ?? null,
|
|
368
|
-
backoff_state: backoffState,
|
|
369
|
-
};
|
|
370
|
-
}
|
|
371
1
|
// ---------------------------------------------------------------------------
|
|
372
|
-
//
|
|
373
|
-
//
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
function estimateImplementSlotTokens(readFiles, root) {
|
|
391
|
-
const absPaths = readFiles.map((f) => f.startsWith("/") || /^[A-Za-z]:[/\\]/.test(f) ? f : join(root, f));
|
|
392
|
-
const bytes = sumFileSizes(absPaths);
|
|
393
|
-
return estimateTokensFromBytes(bytes) + PROMPT_OVERHEAD_TOKENS;
|
|
394
|
-
}
|
|
395
|
-
// ---------------------------------------------------------------------------
|
|
396
|
-
// detectRepoConventions cache (one call per repo root per process)
|
|
397
|
-
// ---------------------------------------------------------------------------
|
|
398
|
-
/** Module-level cache: repo root → formatted conventions string. */
|
|
399
|
-
export const detectRepoConventionsCache = new Map();
|
|
400
|
-
function getCachedConventions(root) {
|
|
401
|
-
if (detectRepoConventionsCache.has(root)) {
|
|
402
|
-
return detectRepoConventionsCache.get(root);
|
|
403
|
-
}
|
|
404
|
-
const result = formatRepoConventions(detectRepoConventions(root));
|
|
405
|
-
detectRepoConventionsCache.set(root, result);
|
|
406
|
-
return result;
|
|
407
|
-
}
|
|
408
|
-
/**
|
|
409
|
-
* The git top-level directory containing `cwd`, or `null` when `cwd` is not
|
|
410
|
-
* inside a git working tree (or git is unavailable). `git rev-parse
|
|
411
|
-
* --show-toplevel` emits a forward-slash absolute path on every platform.
|
|
412
|
-
*/
|
|
413
|
-
export function gitTopLevel(cwd) {
|
|
414
|
-
const result = spawnSyncHidden("git", ["rev-parse", "--show-toplevel"], {
|
|
415
|
-
cwd,
|
|
416
|
-
encoding: "utf8",
|
|
417
|
-
shell: false,
|
|
418
|
-
});
|
|
419
|
-
if (result.status !== 0)
|
|
420
|
-
return null;
|
|
421
|
-
const top = (result.stdout ?? "").trim();
|
|
422
|
-
return top.length > 0 ? top : null;
|
|
423
|
-
}
|
|
424
|
-
/**
|
|
425
|
-
* Canonical comparison key for a filesystem path. `realpathSync` resolves
|
|
426
|
-
* symlinks and platform short-names so it matches git's `--show-toplevel`
|
|
427
|
-
* output (macOS TMPDIR `/var`→`/private/var`, Windows 8.3 names); falls back to
|
|
428
|
-
* `resolve` for paths that don't exist on disk (e.g. mocked unit tests).
|
|
429
|
-
*/
|
|
430
|
-
function canonicalPathKey(p) {
|
|
431
|
-
try {
|
|
432
|
-
return normalizeRepoPath(realpathSync(p));
|
|
433
|
-
}
|
|
434
|
-
catch {
|
|
435
|
-
return normalizeRepoPath(resolve(p));
|
|
436
|
-
}
|
|
437
|
-
}
|
|
438
|
-
/**
|
|
439
|
-
* Create an isolated git worktree on a fresh branch at HEAD. Throws on non-zero exit.
|
|
440
|
-
*
|
|
441
|
-
* Refuses when `root` is not ITSELF a git top-level: a bare `git worktree add`
|
|
442
|
-
* with `cwd: root` walks UP to the nearest enclosing repo and silently creates
|
|
443
|
-
* the worktree/branch in that ancestor (observed polluting the monorepo with
|
|
444
|
-
* leaked `remediate-*` branches during the rolling_engine flip). The resolved
|
|
445
|
-
* top-level must equal the target root, or we refuse rather than escape.
|
|
446
|
-
*/
|
|
447
|
-
export function createWorktree(root, worktreePath, branchName) {
|
|
448
|
-
const top = gitTopLevel(root);
|
|
449
|
-
if (top === null) {
|
|
450
|
-
throw new Error(`Refusing to create a worktree: ${root} is not inside a git repository ` +
|
|
451
|
-
`(git rev-parse --show-toplevel failed). Rolling dispatch requires the target root to be a git repo.`);
|
|
452
|
-
}
|
|
453
|
-
if (canonicalPathKey(top) !== canonicalPathKey(root)) {
|
|
454
|
-
throw new Error(`Refusing to create a worktree: the git top-level for ${root} is ${top}, not the target root ` +
|
|
455
|
-
`itself, so 'git worktree add' would escape to an ancestor repo. Initialize a git repo at the ` +
|
|
456
|
-
`target root before rolling dispatch.`);
|
|
457
|
-
}
|
|
458
|
-
const result = spawnSyncHidden("git", ["worktree", "add", "-b", branchName, worktreePath, "HEAD"], { cwd: root, encoding: "utf8", shell: false });
|
|
459
|
-
if (result.status !== 0) {
|
|
460
|
-
const stderr = (result.stderr ?? "").trim();
|
|
461
|
-
const stdout = (result.stdout ?? "").trim();
|
|
462
|
-
throw new Error(`git worktree add failed (exit ${result.status ?? "unknown"}):\n${stderr || stdout}`);
|
|
463
|
-
}
|
|
464
|
-
// Link the main checkout's (gitignored → absent-in-worktree) node_modules so
|
|
465
|
-
// per-node verify can resolve deps. Folded into creation, not left to each
|
|
466
|
-
// caller to remember — a fresh worktree without it silently fails verify with
|
|
467
|
-
// missing-module errors. Best-effort + idempotent (see ensureWorktreeNodeModules).
|
|
468
|
-
ensureWorktreeNodeModules(root, worktreePath);
|
|
469
|
-
}
|
|
470
|
-
/**
|
|
471
|
-
* Materialize into a fresh worktree any of the node's declared target paths that
|
|
472
|
-
* exist in the main tree but are absent from the worktree — i.e. git-untracked or
|
|
473
|
-
* gitignored files that `git worktree add HEAD` does not bring over. Without this
|
|
474
|
-
* a node whose scope names an untracked config file (the dogfood hit
|
|
475
|
-
* `opencode.json` and an uncommitted `.gemini/commands/*.toml`) cannot see its own
|
|
476
|
-
* target, so the edit silently no-ops. The "absent in worktree" test is the
|
|
477
|
-
* discriminator: a tracked path is already materialized from HEAD, so only the
|
|
478
|
-
* genuinely-missing untracked/ignored declarations are copied — a tracked-but-dirty
|
|
479
|
-
* file keeps its clean-from-HEAD worktree content and is never clobbered. Paths are
|
|
480
|
-
* repo-relative (the declared scope contract); absolute/escaping paths are skipped.
|
|
481
|
-
* Best-effort: a copy failure must not abort the dispatch (logged, not thrown).
|
|
482
|
-
*/
|
|
483
|
-
export function seedUntrackedDeclaredPaths(root, worktreeRoot, declaredPaths) {
|
|
484
|
-
for (const rel of new Set(declaredPaths)) {
|
|
485
|
-
if (!rel || isAbsolute(rel))
|
|
486
|
-
continue;
|
|
487
|
-
// Reject paths that escape the root (defence-in-depth; declared scope is
|
|
488
|
-
// repo-relative and never `..`-prefixed in practice).
|
|
489
|
-
const dst = join(worktreeRoot, rel);
|
|
490
|
-
const src = join(root, rel);
|
|
491
|
-
if (relative(worktreeRoot, dst).startsWith(".."))
|
|
492
|
-
continue;
|
|
493
|
-
if (!existsSync(src) || existsSync(dst))
|
|
494
|
-
continue;
|
|
495
|
-
try {
|
|
496
|
-
mkdirSync(dirname(dst), { recursive: true });
|
|
497
|
-
cpSync(src, dst, { recursive: true });
|
|
498
|
-
}
|
|
499
|
-
catch (err) {
|
|
500
|
-
process.stderr.write(`[remediate-code] worktree seed: could not copy untracked declared path ${rel}: ${err.message}\n`);
|
|
501
|
-
}
|
|
502
|
-
}
|
|
503
|
-
}
|
|
504
|
-
/** Remove a git worktree. Best-effort: logs but does not throw on failure. */
|
|
505
|
-
export function removeWorktree(root, worktreePath) {
|
|
506
|
-
// Already-absent worktree = silent no-op: no spawn, no stderr, no throw. A
|
|
507
|
-
// path that still exists but fails git-remove for another reason is surfaced
|
|
508
|
-
// exactly as before. We do NOT match on the 'not a working tree' stderr text.
|
|
509
|
-
if (!existsSync(worktreePath))
|
|
510
|
-
return;
|
|
511
|
-
const result = spawnSyncHidden("git", ["worktree", "remove", "--force", worktreePath], { cwd: root, encoding: "utf8", shell: false });
|
|
512
|
-
if (result.status !== 0) {
|
|
513
|
-
const stderr = (result.stderr ?? "").trim();
|
|
514
|
-
process.stderr.write(`[remediate-code] worktree remove failed (exit ${result.status ?? "unknown"}): ${stderr}\n`);
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
/**
|
|
518
|
-
* Fully reset a node's isolated worktree + branch so a fresh `createWorktree -b`
|
|
519
|
-
* can run, even when a prior attempt left either behind. This is the idempotent
|
|
520
|
-
* cleanup the in-process driver needs across a `rate_limited` re-queue: the
|
|
521
|
-
* engine re-enters the dispatcher for the SAME block while its branch (and maybe
|
|
522
|
-
* a stale worktree admin entry) still exist, and `git worktree add -b <branch>`
|
|
523
|
-
* would otherwise fail with "branch already exists". Removing the worktree,
|
|
524
|
-
* pruning stale admin records, then force-deleting the branch makes every
|
|
525
|
-
* (re-)dispatch start clean from HEAD. All steps are best-effort (a missing
|
|
526
|
-
* worktree/branch is the expected first-attempt case, not an error). Any partial
|
|
527
|
-
* edits from a throttled prior attempt are intentionally discarded — the
|
|
528
|
-
* re-dispatch redoes the node from HEAD.
|
|
529
|
-
*/
|
|
530
|
-
export function resetNodeWorktreeAndBranch(root, worktreePath, branchName) {
|
|
531
|
-
removeWorktree(root, worktreePath);
|
|
532
|
-
// Prune stale worktree admin entries (e.g. a dir deleted out from under git),
|
|
533
|
-
// otherwise `git worktree add` can refuse a path it still thinks is registered.
|
|
534
|
-
spawnSyncHidden("git", ["worktree", "prune"], { cwd: root, shell: false });
|
|
535
|
-
// Force-delete the leftover branch from a prior attempt so `-b` recreates it.
|
|
536
|
-
spawnSyncHidden("git", ["branch", "-D", branchName], { cwd: root, shell: false });
|
|
537
|
-
// Force-remove a leftover worktree DIRECTORY: when a prior attempt's worktree
|
|
538
|
-
// became an orphaned dir (registered admin entry gone but files remain),
|
|
539
|
-
// `git worktree remove` no-ops ("is not a working tree") and `git worktree add`
|
|
540
|
-
// then refuses because the path already exists. Deleting the dir makes the
|
|
541
|
-
// re-create succeed. Best-effort.
|
|
542
|
-
if (existsSync(worktreePath)) {
|
|
543
|
-
rmSync(worktreePath, { recursive: true, force: true });
|
|
544
|
-
}
|
|
545
|
-
}
|
|
546
|
-
/**
|
|
547
|
-
* Run each targeted command in the worktree directory. Returns pass/fail and
|
|
548
|
-
* combined output.
|
|
549
|
-
*
|
|
550
|
-
* `targeted_commands` are opaque host-authored command *strings* (e.g.
|
|
551
|
-
* `npm run build`, `grep -c '/packages/' .gitignore`, anything with pipes,
|
|
552
|
-
* quotes, or redirections), NOT pre-tokenized argv. They are run through the
|
|
553
|
-
* platform shell (`runShellCommand` → `spawnSync(..., { shell: true })`, the
|
|
554
|
-
* same path `close.ts` uses for `test_command`/`e2e_command`) so the shell — not
|
|
555
|
-
* a word-split + `spawnSync(shell:false)` — resolves the verb. That is what
|
|
556
|
-
* makes this OS-agnostic: on win32 `cmd.exe` natively execs `.cmd` shims (npm,
|
|
557
|
-
* npx, …) and resolves PATH commands; on darwin/linux `/bin/sh` does. The prior
|
|
558
|
-
* argv path ENOENT'd the *spawn itself* for any verb that wasn't a bare
|
|
559
|
-
* executable (e.g. `grep` on Windows), turning a correct fix into a phantom
|
|
560
|
-
* contract failure that burned the retry budget.
|
|
561
|
-
*/
|
|
562
|
-
export function verifyNodeInWorktree(worktreePath, targetedCommands) {
|
|
563
|
-
const outputs = [];
|
|
564
|
-
for (const cmd of targetedCommands) {
|
|
565
|
-
const r = runShellCommand(cmd, {
|
|
566
|
-
cwd: worktreePath,
|
|
567
|
-
encoding: "utf8",
|
|
568
|
-
});
|
|
569
|
-
if (r.error) {
|
|
570
|
-
// Shell itself failed to spawn — surface it as a verify failure with the
|
|
571
|
-
// error text rather than a silent status.
|
|
572
|
-
outputs.push(`$ ${cmd}\n${r.error.message}`);
|
|
573
|
-
return { passed: false, output: outputs.join("\n---\n") };
|
|
574
|
-
}
|
|
575
|
-
const combined = [r.stdout ?? "", r.stderr ?? ""].filter(Boolean).join("\n");
|
|
576
|
-
outputs.push(`$ ${cmd}\n${combined}`);
|
|
577
|
-
if (r.status !== 0) {
|
|
578
|
-
return { passed: false, output: outputs.join("\n---\n") };
|
|
579
|
-
}
|
|
580
|
-
}
|
|
581
|
-
return { passed: true, output: outputs.join("\n---\n") };
|
|
582
|
-
}
|
|
583
|
-
/** Merge the worktree branch into the current HEAD via cherry-pick. On failure, removes the worktree and returns the error. */
|
|
584
|
-
export function mergeWorktree(root, worktreePath, branchName) {
|
|
585
|
-
// Get the tip commit of the worktree branch
|
|
586
|
-
const revResult = spawnSyncHidden("git", ["rev-parse", branchName], {
|
|
587
|
-
cwd: root,
|
|
588
|
-
encoding: "utf8",
|
|
589
|
-
shell: false,
|
|
590
|
-
});
|
|
591
|
-
if (revResult.status !== 0) {
|
|
592
|
-
const errMsg = (revResult.stderr ?? "").trim();
|
|
593
|
-
removeWorktree(root, worktreePath);
|
|
594
|
-
return { success: false, error: `Failed to resolve worktree branch ${branchName}: ${errMsg}` };
|
|
595
|
-
}
|
|
596
|
-
const worktreeTip = revResult.stdout.trim();
|
|
597
|
-
const mergeResult = spawnSyncHidden("git", ["cherry-pick", worktreeTip], {
|
|
598
|
-
cwd: root,
|
|
599
|
-
encoding: "utf8",
|
|
600
|
-
shell: false,
|
|
601
|
-
});
|
|
602
|
-
if (mergeResult.status !== 0) {
|
|
603
|
-
const stderr = (mergeResult.stderr ?? "").trim();
|
|
604
|
-
// Abort the cherry-pick so the main tree stays clean
|
|
605
|
-
spawnSyncHidden("git", ["cherry-pick", "--abort"], { cwd: root, shell: false });
|
|
606
|
-
removeWorktree(root, worktreePath);
|
|
607
|
-
return { success: false, error: `cherry-pick failed: ${stderr}` };
|
|
608
|
-
}
|
|
609
|
-
removeWorktree(root, worktreePath);
|
|
610
|
-
return { success: true };
|
|
611
|
-
}
|
|
612
|
-
/**
|
|
613
|
-
* Repo-relative tracked paths in the MAIN checkout that have uncommitted changes
|
|
614
|
-
* AND collide with a path the node's branch edits. A pre-existing dirty tracked
|
|
615
|
-
* file the cherry-pick would touch makes `git cherry-pick` abort with the opaque
|
|
616
|
-
* "Your local changes to the following files would be overwritten by merge" — a
|
|
617
|
-
* condition the node itself cannot fix and that identically re-fails every
|
|
618
|
-
* auto-retry (observed: a docs-only node routed to human triage over unrelated
|
|
619
|
-
* uncommitted WIP on the same file). Detected here so `acceptNodeWorktree` can
|
|
620
|
-
* surface the actionable cause (which file, commit-or-stash) instead of the raw
|
|
621
|
-
* git error. Best-effort: an unavailable diff / status probe returns `[]` (fall
|
|
622
|
-
* through to the normal cherry-pick, which reproduces the original behaviour).
|
|
623
|
-
*/
|
|
624
|
-
export function dirtyMainTreeCollisions(root, branch) {
|
|
625
|
-
const edited = gitEditedFilesForBranch(root, branch);
|
|
626
|
-
if (!edited.available || edited.files.size === 0)
|
|
627
|
-
return [];
|
|
628
|
-
const status = spawnSyncHidden("git", ["status", "--porcelain", "--", ...edited.files], { cwd: root, encoding: "utf8", shell: false });
|
|
629
|
-
if (status.error || status.status !== 0)
|
|
630
|
-
return [];
|
|
631
|
-
const dirty = [];
|
|
632
|
-
for (const line of (status.stdout ?? "").split(/\r?\n/)) {
|
|
633
|
-
// Porcelain v1: two status chars + a space, then the path. Rename entries
|
|
634
|
-
// ("R old -> new") keep the destination after the arrow.
|
|
635
|
-
const raw = line.slice(3).trim();
|
|
636
|
-
if (raw.length === 0)
|
|
637
|
-
continue;
|
|
638
|
-
const arrow = raw.lastIndexOf(" -> ");
|
|
639
|
-
const p = (arrow >= 0 ? raw.slice(arrow + 4) : raw).replace(/\\/g, "/");
|
|
640
|
-
if (p.length > 0)
|
|
641
|
-
dirty.push(p);
|
|
642
|
-
}
|
|
643
|
-
return dirty;
|
|
644
|
-
}
|
|
645
|
-
/**
|
|
646
|
-
* Rebase a node's worktree branch onto the main checkout's current HEAD (the
|
|
647
|
-
* remediation branch tip) so a sibling that merged AFTER this worktree was created
|
|
648
|
-
* is folded in before this node verifies and merges. Additive edits to a shared
|
|
649
|
-
* file merge automatically (git's per-commit 3-way); a true hunk conflict is a
|
|
650
|
-
* genuine seam that aborts cleanly so the node routes to triage instead of landing
|
|
651
|
-
* a broken merge. The branch is checked out in the worktree, so the rebase runs
|
|
652
|
-
* there. A no-op (branch already on HEAD — the common, no-sibling-merged case)
|
|
653
|
-
* succeeds. Leaves the branch on its pre-rebase commit on abort (so the failed
|
|
654
|
-
* node's work can still be quarantined).
|
|
655
|
-
*/
|
|
656
|
-
export function rebaseBranchOntoHead(root, worktreePath, branch) {
|
|
657
|
-
const head = spawnSyncHidden("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8", shell: false });
|
|
658
|
-
if (head.error || head.status !== 0) {
|
|
659
|
-
const detail = (head.stderr ?? head.error?.message ?? "git rev-parse failed").toString().trim();
|
|
660
|
-
return { ok: false, error: `could not resolve remediation HEAD for rebase: ${detail}` };
|
|
661
|
-
}
|
|
662
|
-
const target = head.stdout.trim();
|
|
663
|
-
const rebase = spawnSyncHidden("git", ["rebase", target], {
|
|
664
|
-
cwd: worktreePath,
|
|
665
|
-
encoding: "utf8",
|
|
666
|
-
shell: false,
|
|
667
|
-
});
|
|
668
|
-
if (rebase.error || rebase.status !== 0) {
|
|
669
|
-
const detail = [rebase.stdout ?? "", rebase.stderr ?? "", rebase.error?.message ?? ""]
|
|
670
|
-
.filter(Boolean)
|
|
671
|
-
.join("\n")
|
|
672
|
-
.trim();
|
|
673
|
-
// Leave a clean tree: abort the in-progress rebase before the worktree is dropped.
|
|
674
|
-
spawnSyncHidden("git", ["rebase", "--abort"], { cwd: worktreePath, shell: false });
|
|
675
|
-
return {
|
|
676
|
-
ok: false,
|
|
677
|
-
error: `rebase onto the current remediation HEAD conflicted (a real seam — two ` +
|
|
678
|
-
`nodes edited the same lines): ${detail}`,
|
|
679
|
-
};
|
|
680
|
-
}
|
|
681
|
-
return { ok: true };
|
|
682
|
-
}
|
|
683
|
-
/** Worktree path for a remediation block. */
|
|
684
|
-
export function worktreePath(root, blockId, runId) {
|
|
685
|
-
return join(root, ".audit-tools", "worktrees", `remediate-${blockId}-${runId}`);
|
|
686
|
-
}
|
|
687
|
-
/**
|
|
688
|
-
* Deterministic name of the dedicated remediation branch for a run. Derived from
|
|
689
|
-
* the stable run id (= the plan id, constant for the whole remediation) so every
|
|
690
|
-
* wave and the final report resolve the SAME branch without persisting it. Ref-safe:
|
|
691
|
-
* any character outside [A-Za-z0-9._-] collapses to '-'. Distinct from the per-node
|
|
692
|
-
* worktree branches (`remediate-<blockId>-<runId>`) — this uses a `remediation/` ref
|
|
693
|
-
* namespace so the two never collide.
|
|
694
|
-
*/
|
|
695
|
-
function refSafeSegment(s, fallback) {
|
|
696
|
-
return (s
|
|
697
|
-
.replace(/[^A-Za-z0-9._-]+/g, "-")
|
|
698
|
-
.replace(/\.{2,}/g, ".") // ".." is invalid in a git ref name
|
|
699
|
-
.replace(/^[-.]+|[-.]+$/g, "") || fallback);
|
|
700
|
-
}
|
|
701
|
-
export function remediationBranchName(runId) {
|
|
702
|
-
return `remediation/${refSafeSegment(runId, "run")}`;
|
|
703
|
-
}
|
|
704
|
-
/**
|
|
705
|
-
* Lock path for the base-mutating accept critical section (INV-2/CE-001), keyed on
|
|
706
|
-
* the base repo root + run's remediation branch. DISTINCT from the per-run
|
|
707
|
-
* `rolling-session.lock` (`withFileLock` is non-reentrant — an exclusive `wx`
|
|
708
|
-
* create — so the base lock MUST be a different path than the session lock
|
|
709
|
-
* `advanceHostRolling` already holds, or the nested acquire would self-deadlock).
|
|
710
|
-
* Both rolling drivers (host-subagent + in-process) serialize the rebase →
|
|
711
|
-
* cherry-pick → cross-package check → reset sequence through this single lock.
|
|
712
|
-
*/
|
|
713
|
-
export function baseBranchLockPath(root, runId) {
|
|
714
|
-
return join(root, ".audit-tools", "remediation", "runs", refSafeSegment(runId, "run"), "base-branch.lock");
|
|
715
|
-
}
|
|
716
|
-
/** Durable ref under which a failed-but-committed node's commit is preserved. */
|
|
717
|
-
export function quarantineRef(runId, blockId) {
|
|
718
|
-
return `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/${refSafeSegment(blockId, "node")}`;
|
|
719
|
-
}
|
|
720
|
-
/**
|
|
721
|
-
* Preserve a failed-but-committed node's work so it can never be lost. A node that
|
|
722
|
-
* committed real edits to its worktree branch but then failed verify / the
|
|
723
|
-
* write-scope gate / the cherry-pick is about to have its worktree removed and (on
|
|
724
|
-
* the next re-dispatch) its branch force-deleted — orphaning the commit. The dogfood
|
|
725
|
-
* lost a verified fix exactly this way (the worktree was pruned before recovery).
|
|
726
|
-
* Point a durable ref at the branch tip: a ref under refs/remediation-quarantine/
|
|
727
|
-
* survives `git branch -D` and `git worktree prune`, so the work stays reachable for
|
|
728
|
-
* a manual `git cherry-pick`. Best-effort; returns the ref + commit, or null.
|
|
729
|
-
*/
|
|
730
|
-
export function quarantineFailedNodeCommit(root, branch, runId, blockId) {
|
|
731
|
-
const rev = spawnSyncHidden("git", ["rev-parse", "--verify", "--quiet", `${branch}^{commit}`], {
|
|
732
|
-
cwd: root,
|
|
733
|
-
encoding: "utf8",
|
|
734
|
-
shell: false,
|
|
735
|
-
});
|
|
736
|
-
if (rev.status !== 0)
|
|
737
|
-
return null;
|
|
738
|
-
const commit = (rev.stdout ?? "").trim();
|
|
739
|
-
const ref = quarantineRef(runId, blockId);
|
|
740
|
-
const upd = spawnSyncHidden("git", ["update-ref", ref, commit], {
|
|
741
|
-
cwd: root,
|
|
742
|
-
encoding: "utf8",
|
|
743
|
-
shell: false,
|
|
744
|
-
});
|
|
745
|
-
if (upd.status !== 0) {
|
|
746
|
-
process.stderr.write(`[remediate-code] could not quarantine ${branch}: ${(upd.stderr ?? "").trim()}\n`);
|
|
747
|
-
return null;
|
|
748
|
-
}
|
|
749
|
-
process.stderr.write(`[remediate-code] preserved failed node ${blockId} commit ${commit.slice(0, 8)} at ${ref} for recovery\n`);
|
|
750
|
-
return { ref, commit };
|
|
751
|
-
}
|
|
752
|
-
/**
|
|
753
|
-
* Preserve a failed-but-UNCOMMITTED node's worktree edits before the worktree is
|
|
754
|
-
* removed. The verify / scope / merge-fail paths quarantine an already-committed
|
|
755
|
-
* branch tip; this is the missing twin for the commit-REFUSAL path: when
|
|
756
|
-
* `commitWorktree` fails loudly (e.g. a generated artifact landed under the write
|
|
757
|
-
* scope — the genuine CE-003 fail-loud), the worker's real source edits are still
|
|
758
|
-
* sitting uncommitted in the worktree and would be destroyed by `removeWorktree`.
|
|
759
|
-
* Stage everything and land a preservation commit on the node's ISOLATED branch
|
|
760
|
-
* (never cherry-picked into main — it exists only so a durable quarantine ref can
|
|
761
|
-
* point at the otherwise-lost work), then quarantine that commit. A guard that
|
|
762
|
-
* destroys good work is worse than the bug it guards. Best-effort; returns the
|
|
763
|
-
* quarantine ref + commit, or null when there was nothing to preserve / it failed.
|
|
764
|
-
*/
|
|
765
|
-
export function quarantineUncommittedWorktreeEdits(root, worktreeRoot, branch, runId, blockId) {
|
|
766
|
-
// Stage tracked modifications + new (non-ignored) source files. `git add -A`
|
|
767
|
-
// honours .gitignore, so the incidental ignored churn (node_modules/dist) and
|
|
768
|
-
// the offending generated artifact stay out — what we preserve is the worker's
|
|
769
|
-
// real source work.
|
|
770
|
-
const add = spawnSyncHidden("git", ["add", "-A"], {
|
|
771
|
-
cwd: worktreeRoot,
|
|
772
|
-
encoding: "utf8",
|
|
773
|
-
shell: false,
|
|
774
|
-
});
|
|
775
|
-
if (add.status !== 0)
|
|
776
|
-
return null;
|
|
777
|
-
// Nothing staged → no uncommitted edits to preserve.
|
|
778
|
-
const staged = spawnSyncHidden("git", ["diff", "--cached", "--quiet"], {
|
|
779
|
-
cwd: worktreeRoot,
|
|
780
|
-
shell: false,
|
|
781
|
-
});
|
|
782
|
-
if (staged.status === 0)
|
|
783
|
-
return null;
|
|
784
|
-
const commit = spawnSyncHidden("git", ["commit", "-m", `remediate-quarantine ${blockId} (${runId}) — fail-loud preserve`], { cwd: worktreeRoot, encoding: "utf8", shell: false });
|
|
785
|
-
if (commit.status !== 0) {
|
|
786
|
-
process.stderr.write(`[remediate-code] could not preserve uncommitted edits for ${blockId}: ${(commit.stderr ?? "").trim()}\n`);
|
|
787
|
-
return null;
|
|
788
|
-
}
|
|
789
|
-
return quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
790
|
-
}
|
|
791
|
-
/** Clear a node's quarantine ref (e.g. once a later re-dispatch landed successfully). Best-effort. */
|
|
792
|
-
export function clearQuarantinedCommit(root, runId, blockId) {
|
|
793
|
-
spawnSyncHidden("git", ["update-ref", "-d", quarantineRef(runId, blockId)], {
|
|
794
|
-
cwd: root,
|
|
795
|
-
encoding: "utf8",
|
|
796
|
-
shell: false,
|
|
797
|
-
});
|
|
798
|
-
}
|
|
799
|
-
/** Quarantined failed-node commits still preserved for a run, for recovery surfacing in the report. */
|
|
800
|
-
export function listQuarantinedCommits(root, runId) {
|
|
801
|
-
const prefix = `refs/remediation-quarantine/${refSafeSegment(runId, "run")}/`;
|
|
802
|
-
const res = spawnSyncHidden("git", ["for-each-ref", "--format=%(refname) %(objectname)", prefix], {
|
|
803
|
-
cwd: root,
|
|
804
|
-
encoding: "utf8",
|
|
805
|
-
shell: false,
|
|
806
|
-
});
|
|
807
|
-
if (res.status !== 0 || !res.stdout)
|
|
808
|
-
return [];
|
|
809
|
-
const out = [];
|
|
810
|
-
for (const line of res.stdout.split("\n")) {
|
|
811
|
-
const [ref, commit] = line.trim().split(/\s+/);
|
|
812
|
-
if (!ref || !commit)
|
|
813
|
-
continue;
|
|
814
|
-
out.push({ block: ref.slice(prefix.length), ref, commit });
|
|
815
|
-
}
|
|
816
|
-
return out;
|
|
817
|
-
}
|
|
818
|
-
/**
|
|
819
|
-
* Ensure the main checkout is on the dedicated remediation branch BEFORE any node
|
|
820
|
-
* commit is cherry-picked, so accepted work lands there and the user's base branch
|
|
821
|
-
* is NEVER modified — the run leaves a feature branch for review (it does not merge
|
|
822
|
-
* back). Idempotent across waves: creates the branch from the current HEAD (the base)
|
|
823
|
-
* the first time, checks it out on later waves. Best-effort on a non-git root (the
|
|
824
|
-
* worktree dispatch flow can't run there anyway): returns null without throwing so
|
|
825
|
-
* non-git callers/tests are unaffected. Returns the branch name on success.
|
|
826
|
-
*/
|
|
827
|
-
/** Sidecar recording the base branch the run was launched from (B5). */
|
|
828
|
-
export function remediationBaseBranchPath(artifactsDir) {
|
|
829
|
-
return join(artifactsDir, "remediation-base-branch.json");
|
|
830
|
-
}
|
|
831
|
-
/**
|
|
832
|
-
* The base branch this run was launched from, recorded when the remediation
|
|
833
|
-
* branch was first created. `null` when unrecorded (e.g. a detached HEAD at
|
|
834
|
-
* launch, or a branch created by a prior run) — the opt-in merge-to-base
|
|
835
|
-
* closing action degrades to "merge manually" rather than guessing a target.
|
|
836
|
-
*/
|
|
837
|
-
export function readRemediationBaseBranch(artifactsDir) {
|
|
838
|
-
try {
|
|
839
|
-
const parsed = JSON.parse(readFileSync(remediationBaseBranchPath(artifactsDir), "utf8"));
|
|
840
|
-
return typeof parsed?.base_branch === "string" && parsed.base_branch.length > 0
|
|
841
|
-
? parsed.base_branch
|
|
842
|
-
: null;
|
|
843
|
-
}
|
|
844
|
-
catch {
|
|
845
|
-
return null;
|
|
846
|
-
}
|
|
847
|
-
}
|
|
848
|
-
export function ensureRemediationBranchCheckedOut(root, runId, artifactsDir) {
|
|
849
|
-
const top = gitTopLevel(root);
|
|
850
|
-
if (top === null || canonicalPathKey(top) !== canonicalPathKey(root))
|
|
851
|
-
return null;
|
|
852
|
-
const branch = remediationBranchName(runId);
|
|
853
|
-
const current = spawnSyncHidden("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
854
|
-
cwd: root,
|
|
855
|
-
encoding: "utf8",
|
|
856
|
-
shell: false,
|
|
857
|
-
});
|
|
858
|
-
if (current.status === 0 && (current.stdout ?? "").trim() === branch)
|
|
859
|
-
return branch;
|
|
860
|
-
const branchExisted = gitBranchExists(root, branch);
|
|
861
|
-
const args = branchExisted ? ["checkout", branch] : ["checkout", "-b", branch];
|
|
862
|
-
const co = spawnSyncHidden("git", args, { cwd: root, encoding: "utf8", shell: false });
|
|
863
|
-
if (co.status !== 0) {
|
|
864
|
-
process.stderr.write(`[remediate-code] could not switch to remediation branch ${branch}: ${(co.stderr ?? "").trim()}\n`);
|
|
865
|
-
return null;
|
|
866
|
-
}
|
|
867
|
-
// First creation: record the branch we came from as the merge-back target (B5).
|
|
868
|
-
// Only a real branch name — a detached HEAD reports "HEAD" and is skipped.
|
|
869
|
-
if (!branchExisted && artifactsDir) {
|
|
870
|
-
const base = current.status === 0 ? (current.stdout ?? "").trim() : "";
|
|
871
|
-
if (base && base !== "HEAD" && base !== branch) {
|
|
872
|
-
try {
|
|
873
|
-
writeFileSync(remediationBaseBranchPath(artifactsDir), JSON.stringify({ base_branch: base }, null, 2));
|
|
874
|
-
}
|
|
875
|
-
catch {
|
|
876
|
-
// best-effort: an unrecorded base degrades to manual merge, never throws
|
|
877
|
-
}
|
|
878
|
-
}
|
|
879
|
-
}
|
|
880
|
-
process.stderr.write(`[remediate-code] remediation changes land on branch ${branch} (base branch left untouched)\n`);
|
|
881
|
-
return branch;
|
|
882
|
-
}
|
|
883
|
-
/**
|
|
884
|
-
* Stage and commit all of a worktree's edits onto its branch. The TOOL owns this
|
|
885
|
-
* commit (never the worker/host) so that the branch has a real commit for two
|
|
886
|
-
* downstream invariants: `gitEditedFilesForBranch` (the write-scope ground truth,
|
|
887
|
-
* `HEAD...<branch>`) and `mergeWorktree`'s cherry-pick both operate on the worker's
|
|
888
|
-
* changes rather than an empty diff against HEAD. Gitignored paths (node_modules,
|
|
889
|
-
* .audit-tools artifacts, the result file written to the main artifacts dir) are
|
|
890
|
-
* excluded by `git add -A` honoring .gitignore, so the commit captures exactly the
|
|
891
|
-
* source edits. Returns `committed:false` (not an error) when the worker made no
|
|
892
|
-
* tracked edits — there is then nothing to verify or merge.
|
|
893
|
-
*/
|
|
894
|
-
/**
|
|
895
|
-
* Source-file extensions a worker is allowed to CREATE as a brand-new untracked
|
|
896
|
-
* file that `.gitignore` happens to shadow. A new source file (a .ts the build
|
|
897
|
-
* compiles, a .js shim, a source .json fixture/config) is real work that MUST land
|
|
898
|
-
* even though `git add -A` honours `.gitignore` and would silently drop it
|
|
899
|
-
* (CE-003/CE-004 — the dogfood lost a gitignored friction-dir file exactly this
|
|
900
|
-
* way). A new
|
|
901
|
-
* file with any OTHER suffix under write scope is a GENERATED artifact (`.tsbuildinfo`,
|
|
902
|
-
* an emitted `.d.ts`, a coverage dump, …) — committing it would land a stale build
|
|
903
|
-
* output, so those FAIL LOUDLY rather than commit a half-change.
|
|
904
|
-
*/
|
|
905
|
-
const SOURCE_NEW_FILE_EXTENSIONS = new Set([
|
|
906
|
-
".ts",
|
|
907
|
-
".tsx",
|
|
908
|
-
".mts",
|
|
909
|
-
".cts",
|
|
910
|
-
".js",
|
|
911
|
-
".jsx",
|
|
912
|
-
".mjs",
|
|
913
|
-
".cjs",
|
|
914
|
-
".json",
|
|
915
|
-
]);
|
|
916
|
-
/**
|
|
917
|
-
* A new untracked-ignored file is a SOURCE file (force-add it) iff its extension
|
|
918
|
-
* is in {@link SOURCE_NEW_FILE_EXTENSIONS} AND it is not a generated declaration
|
|
919
|
-
* (`.d.ts` / `.d.mts` / `.d.cts`) — a generated `.d.ts` has a `.ts` suffix but is
|
|
920
|
-
* build output, so it must be excluded from the source set.
|
|
921
|
-
*/
|
|
922
|
-
function isSourceNewFile(rel) {
|
|
923
|
-
const lower = rel.toLowerCase();
|
|
924
|
-
if (/\.d\.[cm]?ts$/.test(lower))
|
|
925
|
-
return false;
|
|
926
|
-
const dot = lower.lastIndexOf(".");
|
|
927
|
-
if (dot < 0)
|
|
928
|
-
return false;
|
|
929
|
-
return SOURCE_NEW_FILE_EXTENSIONS.has(lower.slice(dot));
|
|
930
|
-
}
|
|
931
|
-
/**
|
|
932
|
-
* Whether a repo-relative path falls UNDER one of the block's declared write
|
|
933
|
-
* paths. A declared path matches a new file when it is the file itself, a
|
|
934
|
-
* directory prefix of it (declared `src/x` matches `src/x/new.ts`), or a glob
|
|
935
|
-
* whose pre-wildcard directory prefix contains it (a leading-wildcard glob like
|
|
936
|
-
* a friction-dir pattern matches `src/a/friction/new.ts`). Declared paths and the
|
|
937
|
-
* candidate are normalized to
|
|
938
|
-
* repo-relative forward-slash form so the comparison is OS-agnostic.
|
|
939
|
-
*/
|
|
940
|
-
function isUnderWritePaths(rel, declaredWritePaths, root) {
|
|
941
|
-
const target = toRepoRelative(rel, root);
|
|
942
|
-
for (const raw of declaredWritePaths) {
|
|
943
|
-
const declared = toRepoRelative(raw, root);
|
|
944
|
-
if (declared === target)
|
|
945
|
-
return true;
|
|
946
|
-
// Glob: reduce a wildcard pattern to the leading literal dir segment(s)
|
|
947
|
-
// before the first wildcard and treat that as a containing prefix.
|
|
948
|
-
const wildcard = declared.search(/[*?[]/);
|
|
949
|
-
const literalPrefix = (wildcard >= 0 ? declared.slice(0, wildcard) : declared)
|
|
950
|
-
.replace(/\/+$/, "");
|
|
951
|
-
if (literalPrefix.length === 0) {
|
|
952
|
-
// A leading-wildcard glob (a friction-dir style pattern) — match on the
|
|
953
|
-
// trailing literal segment appearing anywhere in the candidate's path.
|
|
954
|
-
const trailing = declared.replace(/^[*?/]+/, "").replace(/\/+$/, "");
|
|
955
|
-
if (trailing.length > 0 && (target === trailing || target.includes(`/${trailing}/`) || target.includes(`${trailing}/`))) {
|
|
956
|
-
return true;
|
|
957
|
-
}
|
|
958
|
-
continue;
|
|
959
|
-
}
|
|
960
|
-
if (target === literalPrefix || target.startsWith(`${literalPrefix}/`))
|
|
961
|
-
return true;
|
|
962
|
-
}
|
|
963
|
-
return false;
|
|
964
|
-
}
|
|
965
|
-
/**
|
|
966
|
-
* Force-add worker-created NEW files that `.gitignore` shadows so genuine new
|
|
967
|
-
* source work lands in the commit. `git add -A` (and any diff) honour `.gitignore`
|
|
968
|
-
* and can ONLY be enumerated via `git ls-files --others --ignored
|
|
969
|
-
* --exclude-standard`. Only files UNDER the block's declared write scope are
|
|
970
|
-
* considered: a source-extension one is `git add -f`'d so it lands; a non-source
|
|
971
|
-
* one (generated artifact under a source dir) FAILS LOUDLY rather than committing
|
|
972
|
-
* build output. An untracked-ignored file OUTSIDE the write scope is incidental
|
|
973
|
-
* churn (node_modules created by running npm in the worktree, dist/, .audit-tools/,
|
|
974
|
-
* another module's output) — `git add -A` already skips it, so it is skipped here
|
|
975
|
-
* too (it must NOT trip a fail-loud; doing so falsely rejected every node whose
|
|
976
|
-
* worker ran npm in its worktree). `declaredWritePaths` undefined → the lifecycle
|
|
977
|
-
* unit-test path with no scope: skip force-add entirely (legacy behaviour).
|
|
978
|
-
*/
|
|
979
|
-
function forceAddNewSourceFiles(worktreeRoot, declaredWritePaths) {
|
|
980
|
-
if (declaredWritePaths === undefined)
|
|
981
|
-
return { ok: true };
|
|
982
|
-
const ls = spawnSyncHidden("git", ["ls-files", "--others", "--ignored", "--exclude-standard"], { cwd: worktreeRoot, encoding: "utf8", shell: false });
|
|
983
|
-
if (ls.error || ls.status !== 0) {
|
|
984
|
-
return {
|
|
985
|
-
ok: false,
|
|
986
|
-
error: `git ls-files (untracked-ignored enumeration) failed: ${(ls.stderr ?? ls.error?.message ?? "").toString().trim()}`,
|
|
987
|
-
};
|
|
988
|
-
}
|
|
989
|
-
const newFiles = (ls.stdout ?? "")
|
|
990
|
-
.split(/\r?\n/)
|
|
991
|
-
.map((l) => l.trim().replace(/\\/g, "/"))
|
|
992
|
-
.filter((l) => l.length > 0);
|
|
993
|
-
const toForceAdd = [];
|
|
994
|
-
for (const rel of newFiles) {
|
|
995
|
-
// Only files UNDER the node's declared write scope are candidates. An
|
|
996
|
-
// untracked-ignored file OUTSIDE the write scope is incidental churn the
|
|
997
|
-
// worker did not author as part of this change — node_modules (e.g.
|
|
998
|
-
// `node_modules/.bin/esbuild` created by running npm/vitest in the worktree),
|
|
999
|
-
// `dist/`, `.audit-tools/`, or another module's output. `git add -A` already
|
|
1000
|
-
// skips all of it; it must NOT trip a fail-loud (doing so falsely rejected
|
|
1001
|
-
// every node whose worker ran npm in its worktree). Skip it.
|
|
1002
|
-
if (!isUnderWritePaths(rel, declaredWritePaths, worktreeRoot))
|
|
1003
|
-
continue;
|
|
1004
|
-
if (isSourceNewFile(rel)) {
|
|
1005
|
-
toForceAdd.push(rel);
|
|
1006
|
-
continue;
|
|
1007
|
-
}
|
|
1008
|
-
// Under the declared write scope but NOT a source extension: a generated
|
|
1009
|
-
// artifact (e.g. a `.tsbuildinfo` / generated `.d.ts`) a worker dropped under
|
|
1010
|
-
// a source dir. This is the genuine CE-003 case — fail loudly rather than
|
|
1011
|
-
// committing build output.
|
|
1012
|
-
return {
|
|
1013
|
-
ok: false,
|
|
1014
|
-
error: `Worker created a new non-source (generated) file under its write scope: ${rel}. ` +
|
|
1015
|
-
`Only source-extension new files are committed; a generated artifact must not land. ` +
|
|
1016
|
-
`Refusing to commit a half-change.`,
|
|
1017
|
-
};
|
|
1018
|
-
}
|
|
1019
|
-
for (const rel of toForceAdd) {
|
|
1020
|
-
const add = spawnSyncHidden("git", ["add", "-f", "--", rel], {
|
|
1021
|
-
cwd: worktreeRoot,
|
|
1022
|
-
encoding: "utf8",
|
|
1023
|
-
shell: false,
|
|
1024
|
-
});
|
|
1025
|
-
if (add.status !== 0) {
|
|
1026
|
-
return { ok: false, error: `git add -f ${rel} failed: ${(add.stderr ?? "").trim()}` };
|
|
1027
|
-
}
|
|
1028
|
-
}
|
|
1029
|
-
return { ok: true };
|
|
1030
|
-
}
|
|
1031
|
-
export function commitWorktree(worktreeRoot, message, declaredWritePaths) {
|
|
1032
|
-
// Force-add worker-created new SOURCE files that `.gitignore` shadows (and fail
|
|
1033
|
-
// loudly on a generated-artifact / out-of-scope new file) BEFORE `git add -A`,
|
|
1034
|
-
// which on its own silently drops every untracked-ignored path (CE-003/CE-004).
|
|
1035
|
-
const forced = forceAddNewSourceFiles(worktreeRoot, declaredWritePaths);
|
|
1036
|
-
if (!forced.ok) {
|
|
1037
|
-
return { committed: false, error: forced.error };
|
|
1038
|
-
}
|
|
1039
|
-
const add = spawnSyncHidden("git", ["add", "-A"], {
|
|
1040
|
-
cwd: worktreeRoot,
|
|
1041
|
-
encoding: "utf8",
|
|
1042
|
-
shell: false,
|
|
1043
|
-
});
|
|
1044
|
-
if (add.status !== 0) {
|
|
1045
|
-
return { committed: false, error: `git add failed: ${(add.stderr ?? "").trim()}` };
|
|
1046
|
-
}
|
|
1047
|
-
// `git diff --cached --quiet` exits 0 when nothing is staged → no worker edits.
|
|
1048
|
-
const staged = spawnSyncHidden("git", ["diff", "--cached", "--quiet"], {
|
|
1049
|
-
cwd: worktreeRoot,
|
|
1050
|
-
shell: false,
|
|
1051
|
-
});
|
|
1052
|
-
if (staged.status === 0) {
|
|
1053
|
-
return { committed: false };
|
|
1054
|
-
}
|
|
1055
|
-
const commit = spawnSyncHidden("git", ["commit", "-m", message], {
|
|
1056
|
-
cwd: worktreeRoot,
|
|
1057
|
-
encoding: "utf8",
|
|
1058
|
-
shell: false,
|
|
1059
|
-
});
|
|
1060
|
-
if (commit.status !== 0) {
|
|
1061
|
-
return { committed: false, error: `git commit failed: ${(commit.stderr ?? "").trim()}` };
|
|
1062
|
-
}
|
|
1063
|
-
return { committed: true };
|
|
1064
|
-
}
|
|
1065
|
-
/**
|
|
1066
|
-
* Make the main checkout's installed `node_modules` available to a worktree. A
|
|
1067
|
-
* fresh `git worktree add` checks out only tracked files, and `node_modules` is
|
|
1068
|
-
* gitignored, so per-node verify commands (`npm run check`, focused tests) would
|
|
1069
|
-
* otherwise fail with missing dependencies. Best-effort junction/symlink to the
|
|
1070
|
-
* main root's `node_modules`; on failure it logs and the verify step surfaces the
|
|
1071
|
-
* missing-deps error rather than crashing the dispatch. NOTE: workspace package
|
|
1072
|
-
* symlinks inside `node_modules/@audit-tools/*` point back into the MAIN checkout,
|
|
1073
|
-
* so cross-package runtime resolution sees the main tree — the authoritative
|
|
1074
|
-
* cross-package re-check is the central post-merge build/gate, not this fast
|
|
1075
|
-
* per-node verify (which gates obvious breakage early).
|
|
1076
|
-
*/
|
|
1077
|
-
export function ensureWorktreeNodeModules(mainRoot, worktreeRoot) {
|
|
1078
|
-
const target = join(mainRoot, "node_modules");
|
|
1079
|
-
const link = join(worktreeRoot, "node_modules");
|
|
1080
|
-
if (!existsSync(target) || existsSync(link))
|
|
1081
|
-
return;
|
|
1082
|
-
try {
|
|
1083
|
-
symlinkSync(target, link, "junction");
|
|
1084
|
-
}
|
|
1085
|
-
catch (err) {
|
|
1086
|
-
process.stderr.write(`[remediate-code] worktree node_modules link failed (${worktreeRoot}): ${String(err)}\n`);
|
|
1087
|
-
}
|
|
1088
|
-
}
|
|
1089
|
-
/**
|
|
1090
|
-
* The shared post-worker "accept node" lifecycle, extracted so BOTH rolling
|
|
1091
|
-
* drivers reuse identical correctness: the in-process provider engine
|
|
1092
|
-
* (`driveRollingImplementDispatch`) calls it inline once the worker returns; the
|
|
1093
|
-
* host-subagent driver calls it from the `accept-node` callback once a host
|
|
1094
|
-
* subagent finishes.
|
|
1095
|
-
*
|
|
1096
|
-
* Given a completed worker run in an isolated worktree, this: (1) TOOL-commits
|
|
1097
|
-
* the worker's edits onto the branch (deterministic, never the worker/host) so
|
|
1098
|
-
* the branch diff is the write-scope ground truth; (2) runs the per-node verify
|
|
1099
|
-
* IN the worktree BEFORE accepting; (3) merges via cherry-pick only on a passing
|
|
1100
|
-
* verify; and (4) drops the worktree on any failure so the main tree is never
|
|
1101
|
-
* dirtied by an unverified change. It returns the LIFECYCLE outcome (which the
|
|
1102
|
-
* caller records for the deterministic merge); the caller still returns the
|
|
1103
|
-
* worker's TRANSPORT outcome to the rolling engine (so a `rate_limited` worker
|
|
1104
|
-
* re-queues, while a verify-failure is adjudicated by the merge → triage).
|
|
1105
|
-
*
|
|
1106
|
-
* SAFETY: the main tree is touched only through `mergeWorktree` (cherry-pick of a
|
|
1107
|
-
* verified branch, aborts cleanly on conflict). No state mutation here — the
|
|
1108
|
-
* caller persists via `mergeImplementResults`.
|
|
1109
|
-
*/
|
|
1110
|
-
export async function acceptNodeWorktree(params) {
|
|
1111
|
-
const { root, runId, blockId, worktreeRoot: wt, branch, workerOutcome, targetedCommands, additionalVerifyCommands } = params;
|
|
1112
|
-
let verifyPassed = false;
|
|
1113
|
-
let merged = false;
|
|
1114
|
-
if (workerOutcome === "rate_limited") {
|
|
1115
|
-
// Piece D — quota-death worktree preservation: a worker that died on a host
|
|
1116
|
-
// session-limit is a RETRYABLE pause, not a failure. Leave its worktree INTACT
|
|
1117
|
-
// (do NOT removeWorktree) so nothing is destroyed during the pause; the node
|
|
1118
|
-
// redoes clean on resume (the re-entry `resetNodeWorktreeAndBranch` handles the
|
|
1119
|
-
// clean redo). Nothing to land now — return the outcome so the rolling engine
|
|
1120
|
-
// records the pause + strands the node pending.
|
|
1121
|
-
return { outcome: workerOutcome, verifyPassed, merged };
|
|
1122
|
-
}
|
|
1123
|
-
if (workerOutcome !== "success") {
|
|
1124
|
-
// Real worker failure (error / timeout): nothing to land; drop the worktree so
|
|
1125
|
-
// the main tree is never dirtied by an unverified change, preserve the outcome.
|
|
1126
|
-
removeWorktree(root, wt);
|
|
1127
|
-
return { outcome: workerOutcome, verifyPassed, merged };
|
|
1128
|
-
}
|
|
1129
|
-
const commit = commitWorktree(wt, `remediate ${blockId} (${runId})`, params.writePaths);
|
|
1130
|
-
if (commit.error) {
|
|
1131
|
-
// Could not commit the worker's edits (e.g. a generated-artifact-under-scope
|
|
1132
|
-
// fail-loud) → cannot safely LAND it, but the worker's real source edits are
|
|
1133
|
-
// still uncommitted in the worktree. Preserve them under a durable quarantine
|
|
1134
|
-
// ref BEFORE removing the worktree (P0 data-loss: a guard must not destroy the
|
|
1135
|
-
// good work alongside the offending artifact), mirroring the verify/scope/
|
|
1136
|
-
// merge-fail quarantine paths below.
|
|
1137
|
-
quarantineUncommittedWorktreeEdits(root, wt, branch, runId, blockId);
|
|
1138
|
-
removeWorktree(root, wt);
|
|
1139
|
-
return { outcome: "error", verifyPassed, merged, diagnostic: commit.error };
|
|
1140
|
-
}
|
|
1141
|
-
if (!commit.committed) {
|
|
1142
|
-
// Worker reported success but made no tracked edits — nothing to verify or merge.
|
|
1143
|
-
// The deterministic merge adjudicates the result file (resolved_no_change needs evidence).
|
|
1144
|
-
removeWorktree(root, wt);
|
|
1145
|
-
return { outcome: "success", verifyPassed, merged };
|
|
1146
|
-
}
|
|
1147
|
-
// Base-mutating critical section (INV-2/INV-3/CE-001/CE-002/CE-005) under a DISTINCT
|
|
1148
|
-
// base-branch lock — NOT the per-run rolling-session.lock `advanceHostRolling` holds
|
|
1149
|
-
// (withFileLock is non-reentrant, so a same-path nested acquire would self-deadlock).
|
|
1150
|
-
// Acquired exactly ONCE here so BOTH rolling drivers serialize the rebase →
|
|
1151
|
-
// cherry-pick → cross-package check → reset sequence through one lock. The base HEAD
|
|
1152
|
-
// OID is captured before the cherry-pick so a RED merged-base check rolls the base
|
|
1153
|
-
// back bit-identically. The lock is released on EVERY exit path (success, verify
|
|
1154
|
-
// fail, scope fail, check fail, subprocess fail) by withFileLock's finally.
|
|
1155
|
-
return withFileLock(baseBranchLockPath(root, runId), async () => {
|
|
1156
|
-
// Rebase the node's branch onto the current remediation HEAD BEFORE verify, so a
|
|
1157
|
-
// sibling that merged after this worktree was created is folded in. Verify, the
|
|
1158
|
-
// write-scope gate, and the cherry-pick then all operate on the FINAL to-be-merged
|
|
1159
|
-
// content (green-at-merge; the later cherry-pick can no longer conflict). A true
|
|
1160
|
-
// hunk conflict here is a genuine seam — preserve the work and route to triage
|
|
1161
|
-
// rather than land a broken merge.
|
|
1162
|
-
const rebase = rebaseBranchOntoHead(root, wt, branch);
|
|
1163
|
-
if (!rebase.ok) {
|
|
1164
|
-
quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
1165
|
-
removeWorktree(root, wt);
|
|
1166
|
-
return { outcome: "error", verifyPassed, merged, diagnostic: rebase.error };
|
|
1167
|
-
}
|
|
1168
|
-
// Pre-flight: a dirty tracked file in the MAIN checkout that collides with a
|
|
1169
|
-
// path this node edits makes the later cherry-pick abort with an opaque
|
|
1170
|
-
// "local changes would be overwritten by merge" — a condition the node cannot
|
|
1171
|
-
// fix and that re-fails identically on every auto-retry. Detect it up front
|
|
1172
|
-
// (before the expensive verify) and surface the actionable cause; preserve the
|
|
1173
|
-
// committed work under quarantine like every sibling error path so nothing is
|
|
1174
|
-
// lost while the host commits/stashes the unrelated WIP.
|
|
1175
|
-
const collisions = dirtyMainTreeCollisions(root, branch);
|
|
1176
|
-
if (collisions.length > 0) {
|
|
1177
|
-
quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
1178
|
-
removeWorktree(root, wt);
|
|
1179
|
-
const paths = collisions.map((c) => `\`${c}\``).join(", ");
|
|
1180
|
-
const it = collisions.length > 1 ? "them" : "it";
|
|
1181
|
-
return {
|
|
1182
|
-
outcome: "error",
|
|
1183
|
-
verifyPassed,
|
|
1184
|
-
merged,
|
|
1185
|
-
diagnostic: `main tree has uncommitted changes to ${paths} — commit or stash ${it} ` +
|
|
1186
|
-
`before merging this node (the cherry-pick would otherwise abort with ` +
|
|
1187
|
-
`"local changes would be overwritten by merge"). This is unrelated to the ` +
|
|
1188
|
-
`node's own fix; the node's work is preserved under its quarantine ref.`,
|
|
1189
|
-
};
|
|
1190
|
-
}
|
|
1191
|
-
// Verify commands: when the host omits them (real rolling drivers), DERIVE them
|
|
1192
|
-
// from the just-committed branch's touched test files — correct paths/runner by
|
|
1193
|
-
// construction, only this node's own tests, never the whole suite. An explicit
|
|
1194
|
-
// list (or `[]` to skip) overrides; both used by lifecycle unit tests. task_7d35176d:
|
|
1195
|
-
// run the derive AND the node's own build-free `targeted_commands` (deduped) — the
|
|
1196
|
-
// auditor's fix-specific regression checks the derive misses when a fix touches no
|
|
1197
|
-
// test. `additionalVerifyCommands` is ignored on the explicit-override path.
|
|
1198
|
-
const baseCommands = targetedCommands === undefined
|
|
1199
|
-
? deriveVerifyCommandsFromBranch(root, branch)
|
|
1200
|
-
: targetedCommands;
|
|
1201
|
-
let verifyCommands;
|
|
1202
|
-
if (targetedCommands === undefined) {
|
|
1203
|
-
// Self-contained per-node verify (2026-07-03): the derived `baseCommands` come
|
|
1204
|
-
// from this node's ACTUAL branch edits (self-contained by construction), but the
|
|
1205
|
-
// node's host/auditor-authored `additionalVerifyCommands` can reference a sibling
|
|
1206
|
-
// node's not-yet-created deliverable → a guaranteed-fail deadlock. Drop any such
|
|
1207
|
-
// cross-node command (deferred to the integration/close gate). Own paths = the
|
|
1208
|
-
// node's declared write set ∪ the files it actually edited on its branch.
|
|
1209
|
-
const edited = gitEditedFilesForBranch(root, branch);
|
|
1210
|
-
const ownPaths = [
|
|
1211
|
-
...(params.writePaths ?? []),
|
|
1212
|
-
...(edited.available ? edited.files : []),
|
|
1213
|
-
];
|
|
1214
|
-
const additional = selfContainedVerifyCommands(buildFreeVerifyCommands(additionalVerifyCommands), ownPaths, wt);
|
|
1215
|
-
verifyCommands = [...new Set([...baseCommands, ...additional])];
|
|
1216
|
-
}
|
|
1217
|
-
else {
|
|
1218
|
-
verifyCommands = baseCommands;
|
|
1219
|
-
}
|
|
1220
|
-
const verify = verifyCommands.length > 0
|
|
1221
|
-
? verifyNodeInWorktree(wt, verifyCommands)
|
|
1222
|
-
: { passed: true, output: "" };
|
|
1223
|
-
verifyPassed = verify.passed;
|
|
1224
|
-
if (!verify.passed) {
|
|
1225
|
-
// Verify failed: do not merge; drop the worktree so the main tree stays clean.
|
|
1226
|
-
// The node DID commit real edits, so preserve them under a durable quarantine
|
|
1227
|
-
// ref before the worktree/branch go away — a tool-verify false-negative must
|
|
1228
|
-
// not destroy a good fix (the dogfood lost one this way). Carry the failing
|
|
1229
|
-
// command + output so triage isn't blind on outcome:error.
|
|
1230
|
-
quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
1231
|
-
removeWorktree(root, wt);
|
|
1232
|
-
return { outcome: "error", verifyPassed, merged, diagnostic: verify.output };
|
|
1233
|
-
}
|
|
1234
|
-
// Write-scope gate (OBL-DS-06), BEFORE the cherry-pick: an out-of-scope or
|
|
1235
|
-
// seam-conflicting edit must never land in the main tree, so it is adjudicated
|
|
1236
|
-
// against the branch's git diff (the ground truth) here rather than reported
|
|
1237
|
-
// after `mergeWorktree` already merged it. The gate routes the node's ACTUAL
|
|
1238
|
-
// out-of-declared edits (git diff, never a self-report): an edit to a file no
|
|
1239
|
-
// sibling block owns widens the effective scope, while one owned by another
|
|
1240
|
-
// block blocks as a seam conflict.
|
|
1241
|
-
const decision = enforceAcceptWriteScope({
|
|
1242
|
-
root,
|
|
1243
|
-
branch,
|
|
1244
|
-
blockId,
|
|
1245
|
-
allBlockScopes: params.scope.allBlockScopes,
|
|
1246
|
-
});
|
|
1247
|
-
if (decision.blocked) {
|
|
1248
|
-
// Scope-blocked but the node committed real work — preserve it for recovery.
|
|
1249
|
-
quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
1250
|
-
removeWorktree(root, wt);
|
|
1251
|
-
return { outcome: "error", verifyPassed, merged: false, diagnostic: decision.reason };
|
|
1252
|
-
}
|
|
1253
|
-
// Capture the base HEAD OID BEFORE the cherry-pick so a RED merged-base check can
|
|
1254
|
-
// roll the base back to a bit-identical state.
|
|
1255
|
-
const baseHeadBefore = spawnSyncHidden("git", ["rev-parse", "HEAD"], {
|
|
1256
|
-
cwd: root,
|
|
1257
|
-
encoding: "utf8",
|
|
1258
|
-
shell: false,
|
|
1259
|
-
});
|
|
1260
|
-
if (baseHeadBefore.error || baseHeadBefore.status !== 0) {
|
|
1261
|
-
quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
1262
|
-
removeWorktree(root, wt);
|
|
1263
|
-
return {
|
|
1264
|
-
outcome: "error",
|
|
1265
|
-
verifyPassed,
|
|
1266
|
-
merged,
|
|
1267
|
-
diagnostic: `could not capture base HEAD before merge: ${(baseHeadBefore.stderr ?? baseHeadBefore.error?.message ?? "").toString().trim()}`,
|
|
1268
|
-
};
|
|
1269
|
-
}
|
|
1270
|
-
const baseOid = baseHeadBefore.stdout.trim();
|
|
1271
|
-
// mergeWorktree cherry-picks the verified branch and removes the worktree (on
|
|
1272
|
-
// success AND on conflict-abort), so no explicit cleanup is needed afterwards.
|
|
1273
|
-
const mergeRes = mergeWorktree(root, wt, branch);
|
|
1274
|
-
merged = mergeRes.success;
|
|
1275
|
-
if (!mergeRes.success) {
|
|
1276
|
-
// Cherry-pick conflict: the committed work would otherwise be orphaned — preserve it.
|
|
1277
|
-
quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
1278
|
-
return { outcome: "error", verifyPassed, merged, diagnostic: mergeRes.error };
|
|
1279
|
-
}
|
|
1280
|
-
// Merged-base-green (INV-2): the cherry-pick landed in the MAIN checkout, where
|
|
1281
|
-
// node_modules is faithful (the worktree's @audit-tools junction resolves to main
|
|
1282
|
-
// and is unfaithful, so the per-node worktree verify cannot catch a cross-package
|
|
1283
|
-
// break). Run the REAL cross-package check in the main tree. On RED, roll the base
|
|
1284
|
-
// back to its captured OID bit-identically, scoped-clean the cherry-pick's emitted
|
|
1285
|
-
// untracked files, quarantine, and fail — never leave a broken base for the sibling.
|
|
1286
|
-
const checkArgv = params.mergedBaseCheckCommand === undefined
|
|
1287
|
-
? mergedBaseCheckArgv(root)
|
|
1288
|
-
: params.mergedBaseCheckCommand;
|
|
1289
|
-
if (checkArgv !== null) {
|
|
1290
|
-
// Paths the just-landed pick touched, so the scoped clean nukes only those
|
|
1291
|
-
// (never unrelated untracked state). Resolved BEFORE the check runs.
|
|
1292
|
-
const pickedFiles = gitEditedFilesForBranch(root, branch);
|
|
1293
|
-
// argv via runCommand → runTracked scrubs CLAUDECODE / CLAUDE_CODE_* and applies
|
|
1294
|
-
// the shared Windows `.cmd` wrapping — never `shell: true`.
|
|
1295
|
-
const [checkCmd, ...checkArgs] = checkArgv;
|
|
1296
|
-
const check = runCommand(checkCmd, checkArgs, { cwd: root, encoding: "utf8" });
|
|
1297
|
-
const checkFailed = !!check.error || check.status !== 0;
|
|
1298
|
-
if (checkFailed) {
|
|
1299
|
-
const detail = check.error
|
|
1300
|
-
? check.error.message
|
|
1301
|
-
: [check.stdout ?? "", check.stderr ?? ""].filter(Boolean).join("\n");
|
|
1302
|
-
// Roll the base back to its pre-pick OID, bit-identical.
|
|
1303
|
-
spawnSyncHidden("git", ["reset", "--hard", baseOid], { cwd: root, shell: false });
|
|
1304
|
-
// Scoped clean: remove only the cherry-pick / check-emitted untracked files
|
|
1305
|
-
// under the paths the pick touched — never a blanket `git clean` that could
|
|
1306
|
-
// nuke unrelated untracked state.
|
|
1307
|
-
if (pickedFiles.available && pickedFiles.files.size > 0) {
|
|
1308
|
-
spawnSyncHidden("git", ["clean", "-fdq", "--", ...[...pickedFiles.files]], { cwd: root, shell: false });
|
|
1309
|
-
}
|
|
1310
|
-
quarantineFailedNodeCommit(root, branch, runId, blockId);
|
|
1311
|
-
return {
|
|
1312
|
-
outcome: "error",
|
|
1313
|
-
verifyPassed,
|
|
1314
|
-
merged: false,
|
|
1315
|
-
diagnostic: `$ ${checkArgv.join(" ")}\n${detail}`,
|
|
1316
|
-
};
|
|
1317
|
-
}
|
|
1318
|
-
}
|
|
1319
|
-
// Landed successfully and the merged base is green: clear any quarantine ref left
|
|
1320
|
-
// by a prior failed attempt for this node so the recovery report lists only
|
|
1321
|
-
// genuinely-unrecovered work.
|
|
1322
|
-
clearQuarantinedCommit(root, runId, blockId);
|
|
1323
|
-
return { outcome: "success", verifyPassed, merged };
|
|
1324
|
-
});
|
|
1325
|
-
}
|
|
1326
|
-
/**
|
|
1327
|
-
* Sidecar path for a node's tool-owned accept (verify/merge) outcome. Written by
|
|
1328
|
-
* BOTH rolling drivers as each node is accepted, read by `mergeImplementResults`.
|
|
1329
|
-
* Block ids here follow the same filename-safe convention as the per-node result
|
|
1330
|
-
* files in the same dir.
|
|
1331
|
-
*/
|
|
1332
|
-
export function nodeAcceptOutcomePath(artifactsDir, runId, blockId) {
|
|
1333
|
-
return join(runDir(artifactsDir, runId, "implement"), `accept-outcome-${blockId}.json`);
|
|
1334
|
-
}
|
|
1335
|
-
/**
|
|
1336
|
-
* Persist a node's `acceptNodeWorktree` lifecycle outcome so finalization can tell
|
|
1337
|
-
* a node whose edits actually LANDED (merged) from one that self-reported "resolved"
|
|
1338
|
-
* but failed tool-owned verify / merge (OBL-DS-06: never trust the worker's self
|
|
1339
|
-
* report). Both rolling drivers (host-subagent `advanceHostRolling` and in-process
|
|
1340
|
-
* `driveRollingImplementDispatch`) call this; the interim main-tree path writes none,
|
|
1341
|
-
* so the merge-state gate is inert there.
|
|
1342
|
-
*/
|
|
1343
|
-
export async function recordNodeAcceptOutcome(artifactsDir, runId, blockId, result) {
|
|
1344
|
-
await writeJsonFile(nodeAcceptOutcomePath(artifactsDir, runId, blockId), {
|
|
1345
|
-
schema_version: "remediate-code-implement/node-accept-outcome/v1alpha1",
|
|
1346
|
-
block_id: blockId,
|
|
1347
|
-
outcome: result.outcome,
|
|
1348
|
-
verify_passed: result.verifyPassed,
|
|
1349
|
-
merged: result.merged,
|
|
1350
|
-
// Only present on a failure outcome; gives triage the failing command + output.
|
|
1351
|
-
...(result.diagnostic !== undefined ? { diagnostic: result.diagnostic } : {}),
|
|
1352
|
-
});
|
|
1353
|
-
}
|
|
1354
|
-
/** Load a node's recorded accept outcome, or null when none was written. */
|
|
1355
|
-
export async function loadNodeAcceptOutcome(artifactsDir, runId, blockId) {
|
|
1356
|
-
const raw = await readOptionalJsonFile(nodeAcceptOutcomePath(artifactsDir, runId, blockId));
|
|
1357
|
-
if (!raw)
|
|
1358
|
-
return null;
|
|
1359
|
-
return {
|
|
1360
|
-
outcome: raw.outcome,
|
|
1361
|
-
verifyPassed: raw.verify_passed,
|
|
1362
|
-
merged: raw.merged,
|
|
1363
|
-
...(raw.diagnostic !== undefined ? { diagnostic: raw.diagnostic } : {}),
|
|
1364
|
-
};
|
|
1365
|
-
}
|
|
1366
|
-
/**
|
|
1367
|
-
* Run ONE node's full in-process lifecycle in an isolated worktree — shared by BOTH
|
|
1368
|
-
* in-process callers (the reactive `driveRollingImplementDispatch` engine and the
|
|
1369
|
-
* A-8 hybrid executor) so they create / commit / verify / merge identically:
|
|
1370
|
-
*
|
|
1371
|
-
* reset + create the node's worktree → link node_modules → seed declared targets →
|
|
1372
|
-
* launch the worker (`dispatchNode`) → `acceptNodeWorktree` (tool-commit, rebase,
|
|
1373
|
-
* verify, write-scope gate, cherry-pick) → persist the accept outcome.
|
|
1374
|
-
*
|
|
1375
|
-
* Claim ownership is the CALLER's concern (the reactive engine claims through the
|
|
1376
|
-
* shared registry; the hybrid executor is handed a coordinator-minted claim), so
|
|
1377
|
-
* this fn neither claims nor releases — it returns the worker transport result AND
|
|
1378
|
-
* the accept lifecycle outcome and lets the caller record `nodeOutcomes` / release.
|
|
1379
|
-
* Any thrown error degrades to a dropped worktree + a persisted `error` accept
|
|
1380
|
-
* outcome, never an unhandled rejection into the engine.
|
|
1381
|
-
*/
|
|
1382
|
-
export async function executeNodeInWorktree(args) {
|
|
1383
|
-
const { block, slot, root, artifactsDir, runId, resultPath, seedPaths, allBlockScopes, additionalVerifyCommands, dispatchNode } = args;
|
|
1384
|
-
const branch = worktreeBranchForBlock(block.block_id, runId);
|
|
1385
|
-
const wt = worktreePath(root, block.block_id, runId);
|
|
1386
|
-
try {
|
|
1387
|
-
// Idempotent reset of any worktree dir AND leftover branch from a prior attempt
|
|
1388
|
-
// (a `rate_limited` re-queue re-enters for the same block with its branch still
|
|
1389
|
-
// present), then create this node's isolated worktree (createWorktree also links
|
|
1390
|
-
// the main checkout's node_modules so verify can resolve deps), and seed
|
|
1391
|
-
// untracked declared targets a committed-files-only worktree can't see.
|
|
1392
|
-
resetNodeWorktreeAndBranch(root, wt, branch);
|
|
1393
|
-
createWorktree(root, wt, branch);
|
|
1394
|
-
seedUntrackedDeclaredPaths(root, wt, seedPaths);
|
|
1395
|
-
const result = await dispatchNode({ block, slot, worktreeRoot: wt, resultPath });
|
|
1396
|
-
// Shared post-worker lifecycle. Verify commands are DERIVED from the node's
|
|
1397
|
-
// actually-touched tests inside acceptNodeWorktree (post-commit) — omit them so a
|
|
1398
|
-
// host-authored path can't mis-verify. The write-scope gate adjudicates the node's
|
|
1399
|
-
// ACTUAL git edits against every block's declared scope.
|
|
1400
|
-
const accept = await acceptNodeWorktree({
|
|
1401
|
-
root,
|
|
1402
|
-
runId,
|
|
1403
|
-
blockId: block.block_id,
|
|
1404
|
-
worktreeRoot: wt,
|
|
1405
|
-
branch,
|
|
1406
|
-
workerOutcome: result.outcome,
|
|
1407
|
-
additionalVerifyCommands,
|
|
1408
|
-
scope: { allBlockScopes },
|
|
1409
|
-
// The block's OWN declared write paths (INV-1 new-file inclusion).
|
|
1410
|
-
writePaths: allBlockScopes.find((b) => b.block_id === block.block_id)?.write_paths ?? [],
|
|
1411
|
-
});
|
|
1412
|
-
await recordNodeAcceptOutcome(artifactsDir, runId, block.block_id, accept);
|
|
1413
|
-
return { result, accept };
|
|
1414
|
-
}
|
|
1415
|
-
catch (err) {
|
|
1416
|
-
removeWorktree(root, wt);
|
|
1417
|
-
const accept = { outcome: "error", verifyPassed: false, merged: false };
|
|
1418
|
-
await recordNodeAcceptOutcome(artifactsDir, runId, block.block_id, accept);
|
|
1419
|
-
return {
|
|
1420
|
-
result: {
|
|
1421
|
-
packet: { id: block.block_id, payload: { block_id: block.block_id }, estimatedTokens: 0, complexity: 0.5 },
|
|
1422
|
-
outcome: "error",
|
|
1423
|
-
error: err,
|
|
1424
|
-
},
|
|
1425
|
-
accept,
|
|
1426
|
-
};
|
|
1427
|
-
}
|
|
1428
|
-
}
|
|
1429
|
-
/** Read the promoted DAG-node overlay fields off a Finding (all optional). */
|
|
1430
|
-
function nodeFieldsOf(finding) {
|
|
1431
|
-
return finding;
|
|
1432
|
-
}
|
|
1433
|
-
/**
|
|
1434
|
-
* The reconciliation expectations a node must honor (INV-DS-12): the explicit
|
|
1435
|
-
* `reconciliation_expectations` when present, else the node's `preconditions`
|
|
1436
|
-
* (upstream contracts' declared outputs). Returned as a deduped string list so
|
|
1437
|
-
* the renderer can thread them and the disposition can record them.
|
|
1438
|
-
*/
|
|
1439
|
-
function reconciliationExpectationsOf(finding) {
|
|
1440
|
-
const node = nodeFieldsOf(finding);
|
|
1441
|
-
const explicit = Array.isArray(node.reconciliation_expectations)
|
|
1442
|
-
? node.reconciliation_expectations
|
|
1443
|
-
: [];
|
|
1444
|
-
const preconditions = Array.isArray(node.preconditions) ? node.preconditions : [];
|
|
1445
|
-
return [...new Set([...explicit, ...preconditions])].filter((s) => typeof s === "string" && s.trim().length > 0);
|
|
1446
|
-
}
|
|
1447
|
-
// ---------------------------------------------------------------------------
|
|
1448
|
-
// Build-free per-node verification commands (residual CE-001)
|
|
1449
|
-
// ---------------------------------------------------------------------------
|
|
1450
|
-
/**
|
|
1451
|
-
* The host manages the build centrally; a per-node verify command that runs
|
|
1452
|
-
* `npm run build` (or a `npm test` whose package script prepends a build) races
|
|
1453
|
-
* the central build's dist/ and is therefore forbidden. A command is build-free
|
|
1454
|
-
* only when it neither builds nor invokes a build-prepending test script.
|
|
1455
|
-
*
|
|
1456
|
-
* Forbidden (return false):
|
|
1457
|
-
* - `npm run build` / `npm run build -w ...` / `tsc` emit (`tsc -b`, `tsc --build`)
|
|
1458
|
-
* - bare `npm test` / `npm t` / `npm run test` (the package script prepends build)
|
|
1459
|
-
*
|
|
1460
|
-
* Allowed (return true):
|
|
1461
|
-
* - `npm run check` (no emit)
|
|
1462
|
-
* - `npx vitest run <path>` / `vitest run <path>`
|
|
1463
|
-
* - `node --test <path>`
|
|
1464
|
-
*/
|
|
1465
|
-
export function isBuildFreeVerifyCommand(cmd) {
|
|
1466
|
-
const c = cmd.trim().toLowerCase().replace(/\s+/g, " ");
|
|
1467
|
-
if (c.length === 0)
|
|
1468
|
-
return false;
|
|
1469
|
-
// Any explicit build step is forbidden.
|
|
1470
|
-
if (/\bnpm\s+run\s+build\b/.test(c))
|
|
1471
|
-
return false;
|
|
1472
|
-
if (/\btsc\b.*(-b\b|--build\b)/.test(c))
|
|
1473
|
-
return false;
|
|
1474
|
-
if (/(^|\s)tsc(\s|$)/.test(c) && !/--noemit\b/.test(c)) {
|
|
1475
|
-
// A bare `tsc` (or `tsc -p ...`) emits unless --noEmit is set.
|
|
1476
|
-
return false;
|
|
1477
|
-
}
|
|
1478
|
-
// A build-prepending `npm test` / `npm t` / `npm run test` is forbidden; the
|
|
1479
|
-
// build-free runner (vitest run / node --test) must be invoked directly.
|
|
1480
|
-
if (/\bnpm\s+(test|t)\b/.test(c))
|
|
1481
|
-
return false;
|
|
1482
|
-
if (/\bnpm\s+run\s+test\b/.test(c))
|
|
1483
|
-
return false;
|
|
1484
|
-
return true;
|
|
1485
|
-
}
|
|
1486
|
-
/**
|
|
1487
|
-
* Inject the tsx ESM loader into a bare `node --test <file>` command so the `.mjs`
|
|
1488
|
-
* node:test suites (which import `audit-tools/shared` via tsconfig `paths`, honored
|
|
1489
|
-
* only by tsx) resolve in a per-node worktree with no built `dist/`. A host- or
|
|
1490
|
-
* DAG-authored `node --test tests/audit/x.test.mjs` would otherwise fail module
|
|
1491
|
-
* resolution; the tool normalizes it so correctness can't depend on the host
|
|
1492
|
-
* remembering to add the loader. Idempotent: a command already carrying
|
|
1493
|
-
* `--import tsx/esm` or a `--loader` is left untouched. Mirrors the runner the
|
|
1494
|
-
* derived verify uses ({@link verifyCommandsForEdits}), so the displayed per-node
|
|
1495
|
-
* command and the in-process verify match.
|
|
1496
|
-
*/
|
|
1497
|
-
export function normalizeNodeTestCommand(cmd) {
|
|
1498
|
-
const trimmed = cmd.trim();
|
|
1499
|
-
if (!/^node\b/.test(trimmed))
|
|
1500
|
-
return cmd;
|
|
1501
|
-
if (!/\s--test\b/.test(trimmed))
|
|
1502
|
-
return cmd;
|
|
1503
|
-
if (/--import\s+tsx\/esm\b/.test(trimmed) || /--loader\b/.test(trimmed))
|
|
1504
|
-
return cmd;
|
|
1505
|
-
return trimmed.replace(/^node\b/, "node --import tsx/esm");
|
|
1506
|
-
}
|
|
1507
|
-
/**
|
|
1508
|
-
* True when `cmd` is a test-runner invocation whose target is a whole directory
|
|
1509
|
-
* or the entire suite rather than specific test files — e.g. `npx vitest run
|
|
1510
|
-
* tests/remediate`, `vitest run` (no path), `node --test tests/audit/`. Such a
|
|
1511
|
-
* command, run as an *additional* per-node verify alongside the scoped derive,
|
|
1512
|
-
* re-enters the FULL suite inside a per-node worktree. That is the structural
|
|
1513
|
-
* deadlock proven 2026-06-30: a source node's whole-suite verify fails on a
|
|
1514
|
-
* stale test owned by a DIFFERENT node, and concurrent worktrees race shared
|
|
1515
|
-
* test temp dirs. The derived verify already runs this node's OWN touched
|
|
1516
|
-
* tests, so a host/DAG-authored whole-suite command adds only risk — the tool
|
|
1517
|
-
* drops it rather than relying on the author to scope it (enforce-in-tooling,
|
|
1518
|
-
* never host discretion). A command naming at least one concrete `.test.<ext>`
|
|
1519
|
-
* file is scoped and kept.
|
|
1520
|
-
*/
|
|
1521
|
-
export function isWholeSuiteTestCommand(cmd) {
|
|
1522
|
-
const c = cmd.trim();
|
|
1523
|
-
const isVitest = /\bvitest\b\s+run\b/.test(c) || /\bvitest\b(?!\s+run)/.test(c);
|
|
1524
|
-
const isNodeTest = /^node\b/.test(c) && /\s--test\b/.test(c);
|
|
1525
|
-
if (!isVitest && !isNodeTest)
|
|
1526
|
-
return false;
|
|
1527
|
-
// Tokenise; a concrete test file makes it scoped. Anything that is a runner
|
|
1528
|
-
// with no concrete test-file target (bare runner, or a directory/glob target)
|
|
1529
|
-
// is whole-suite.
|
|
1530
|
-
const namesConcreteTestFile = /(^|\s)[^\s]*\.test\.(ts|tsx|mjs|cjs|js)(\s|$)/.test(c);
|
|
1531
|
-
return !namesConcreteTestFile;
|
|
1532
|
-
}
|
|
1533
|
-
/**
|
|
1534
|
-
* Filter a node's `targeted_commands` to the build-free subset for the per-node
|
|
1535
|
-
* verify section. Build-prepending or build commands are dropped (the host runs
|
|
1536
|
-
* the build centrally) rather than emitted into the prompt. Whole-suite /
|
|
1537
|
-
* whole-directory test runs are dropped too — they re-enter the full suite in a
|
|
1538
|
-
* per-node worktree and re-create the cross-node verify deadlock (the scoped
|
|
1539
|
-
* derive already covers this node's own tests). Surviving `node --test`
|
|
1540
|
-
* commands are normalized to carry the tsx loader.
|
|
1541
|
-
*/
|
|
1542
|
-
function buildFreeVerifyCommands(commands) {
|
|
1543
|
-
if (!Array.isArray(commands))
|
|
1544
|
-
return [];
|
|
1545
|
-
return commands
|
|
1546
|
-
.filter((c) => typeof c === "string" &&
|
|
1547
|
-
isBuildFreeVerifyCommand(c) &&
|
|
1548
|
-
!isWholeSuiteTestCommand(c))
|
|
1549
|
-
.map(normalizeNodeTestCommand);
|
|
1550
|
-
}
|
|
1551
|
-
/**
|
|
1552
|
-
* Repo-relative path-like tokens in a shell command — tokens containing a `/` and a
|
|
1553
|
-
* file extension (e.g. `scripts/remediate/verify-hosts.mjs`, `tests/x.test.ts`).
|
|
1554
|
-
* Used to decide whether a targeted verify command is self-contained.
|
|
1555
|
-
*/
|
|
1556
|
-
export function pathTokensInCommand(cmd) {
|
|
1557
|
-
const tokens = cmd.match(/(?:[\w.@-]+\/)+[\w.@-]+\.\w+/g) ?? [];
|
|
1558
|
-
return [...new Set(tokens.map((t) => t.replace(/\\/g, "/")))];
|
|
1559
|
-
}
|
|
1560
|
-
/**
|
|
1561
|
-
* Keep only the targeted verify commands that are SELF-CONTAINED for this node:
|
|
1562
|
-
* every path-like token they reference is either one of the node's own paths (its
|
|
1563
|
-
* declared write set ∪ the files it actually edited on its branch) or already
|
|
1564
|
-
* present in the tree. A command referencing a path this node doesn't own and that
|
|
1565
|
-
* isn't in the tree depends on a SIBLING node's not-yet-created deliverable —
|
|
1566
|
-
* running it in per-node verify is a guaranteed-fail deadlock (proven 2026-07-03: a
|
|
1567
|
-
* node's `targeted_command` was `node scripts/remediate/verify-hosts.mjs`, another
|
|
1568
|
-
* node's pending output). Per-node verify must be self-contained; such a cross-node
|
|
1569
|
-
* command is dropped here and deferred to the integration/close gate. A command with
|
|
1570
|
-
* no path tokens (e.g. `npm run check`) is always kept.
|
|
1571
|
-
*/
|
|
1572
|
-
export function selfContainedVerifyCommands(commands, ownPaths, treeRoot) {
|
|
1573
|
-
const owned = new Set([...ownPaths].map((p) => p.replace(/\\/g, "/")));
|
|
1574
|
-
return commands.filter((cmd) => {
|
|
1575
|
-
for (const token of pathTokensInCommand(cmd)) {
|
|
1576
|
-
if (owned.has(token))
|
|
1577
|
-
continue;
|
|
1578
|
-
if (existsSync(join(treeRoot, token)))
|
|
1579
|
-
continue;
|
|
1580
|
-
return false;
|
|
1581
|
-
}
|
|
1582
|
-
return true;
|
|
1583
|
-
});
|
|
1584
|
-
}
|
|
1585
|
-
/** A repo-relative test path → the runner that executes that file directly. */
|
|
1586
|
-
function verifyRunnerForTestFile(repoRelPath) {
|
|
1587
|
-
// ONE vitest runner across all suites (audit / shared / remediate). The node:test
|
|
1588
|
-
// split was retired 2026-07-02, so every tracked `.test.mjs` / `.test.ts` file is a
|
|
1589
|
-
// vitest file — running a `.mjs` vitest suite under `node --test` throws "Vitest
|
|
1590
|
-
// failed to access its internal state". vitest resolves `audit-tools/shared` via its
|
|
1591
|
-
// config alias, so a per-node worktree needs no prior build (`npx vitest run <file>`).
|
|
1592
|
-
if (/^tests\/.+\.test\.(mjs|ts|tsx)$/.test(repoRelPath))
|
|
1593
|
-
return "vitest";
|
|
1594
|
-
return undefined;
|
|
1595
|
-
}
|
|
1596
|
-
/**
|
|
1597
|
-
* Derive a node's per-node verify commands from the test files it ACTUALLY touched
|
|
1598
|
-
* on its worktree branch (the git ground truth), instead of trusting host-authored
|
|
1599
|
-
* `targeted_commands` whose paths/runner can drift from where the worker put the
|
|
1600
|
-
* test. Always typechecks (`npm run check`, no emit), then runs ONLY this node's
|
|
1601
|
-
* own touched test files with the repo's runners — never the whole suite (which
|
|
1602
|
-
* would re-enter worktree-spawning tests inside a nested worktree). Build-free: the
|
|
1603
|
-
* host owns the central build; a node's own test imports the source it changed via
|
|
1604
|
-
* the tsx loader. Returns `[]` when there is no git ground truth so the caller can
|
|
1605
|
-
* skip the gate rather than fabricate a command.
|
|
1606
|
-
*/
|
|
1607
|
-
/** Pure assembly (git-free) of the verify commands for a set of edited paths —
|
|
1608
|
-
* the testable core of {@link deriveVerifyCommandsFromBranch}. */
|
|
1609
|
-
export function verifyCommandsForEdits(editedFiles) {
|
|
1610
|
-
const vitestTests = [];
|
|
1611
|
-
for (const f of editedFiles) {
|
|
1612
|
-
const rel = f.replace(/\\/g, "/");
|
|
1613
|
-
if (verifyRunnerForTestFile(rel) === "vitest")
|
|
1614
|
-
vitestTests.push(rel);
|
|
1615
|
-
}
|
|
1616
|
-
const cmds = ["npm run check"];
|
|
1617
|
-
if (vitestTests.length > 0) {
|
|
1618
|
-
cmds.push(`npx vitest run ${vitestTests.sort().join(" ")}`);
|
|
1619
|
-
}
|
|
1620
|
-
return cmds;
|
|
1621
|
-
}
|
|
1622
|
-
export function deriveVerifyCommandsFromBranch(root, branch) {
|
|
1623
|
-
const edited = gitEditedFilesForBranch(root, branch);
|
|
1624
|
-
if (!edited.available)
|
|
1625
|
-
return [];
|
|
1626
|
-
return verifyCommandsForEdits(edited.files);
|
|
1627
|
-
}
|
|
1628
|
-
/**
|
|
1629
|
-
* A node's own `targeted_commands` for the per-node verify (task_7d35176d) — the union
|
|
1630
|
-
* of the block's `targeted_commands` and its findings' `targeted_commands` (the
|
|
1631
|
-
* auditor-specified, fix-specific verification). `acceptNodeWorktree` runs these IN
|
|
1632
|
-
* ADDITION to the derived touched-test commands (build-free subset, deduped), so a
|
|
1633
|
-
* fix-specific regression check is honoured even when the fix touches no test file.
|
|
1634
|
-
*/
|
|
1635
|
-
export function targetedCommandsForBlock(state, blockId) {
|
|
1636
|
-
const block = state.plan?.blocks?.find((b) => b.block_id === blockId);
|
|
1637
|
-
if (!block)
|
|
1638
|
-
return [];
|
|
1639
|
-
const out = [...(block.targeted_commands ?? [])];
|
|
1640
|
-
for (const fid of block.items) {
|
|
1641
|
-
const finding = state.plan?.findings?.find((f) => f.id === fid);
|
|
1642
|
-
for (const c of finding?.targeted_commands ?? [])
|
|
1643
|
-
out.push(c);
|
|
1644
|
-
}
|
|
1645
|
-
return [...new Set(out)];
|
|
1646
|
-
}
|
|
1647
|
-
function markStarted(item) {
|
|
1648
|
-
item.started_at ??= new Date().toISOString();
|
|
1649
|
-
delete item.completed_at;
|
|
1650
|
-
}
|
|
1651
|
-
function markTerminal(item) {
|
|
1652
|
-
const now = new Date().toISOString();
|
|
1653
|
-
item.started_at ??= now;
|
|
1654
|
-
item.completed_at = now;
|
|
1655
|
-
}
|
|
1656
|
-
/**
|
|
1657
|
-
* Load a worker's already-written implement result, distinguishing an ABSENT
|
|
1658
|
-
* file (the worker hasn't run yet → re-dispatch from scratch) from a PRESENT but
|
|
1659
|
-
* INVALID one (the worker ran but emitted malformed/unparseable JSON or a result
|
|
1660
|
-
* that fails the contract). A bare `catch → undefined` conflated the two: a
|
|
1661
|
-
* written-but-invalid file looked identical to "never produced", so the merge
|
|
1662
|
-
* loop silently `continue`d past the block (the missing-file branch never fires
|
|
1663
|
-
* because the file DOES exist) and the node could neither converge nor surface
|
|
1664
|
-
* the corruption. We now archive the invalid file (so a clean re-dispatch can
|
|
1665
|
-
* write a fresh one) and report it loudly, returning `undefined` only for the
|
|
1666
|
-
* genuinely-absent case.
|
|
1667
|
-
*/
|
|
1668
|
-
async function tryLoadExistingImplementResult(resultPath) {
|
|
1669
|
-
if (!existsSync(resultPath))
|
|
1670
|
-
return undefined;
|
|
1671
|
-
try {
|
|
1672
|
-
const result = await readJsonFile(resultPath);
|
|
1673
|
-
assertImplementWorkerResult(result, resultPath);
|
|
1674
|
-
return result;
|
|
1675
|
-
}
|
|
1676
|
-
catch (err) {
|
|
1677
|
-
// Present but invalid: do NOT treat it as absent (which would let the block
|
|
1678
|
-
// be silently dropped from the merge). Archive the corrupt file and surface
|
|
1679
|
-
// the reason so a clean re-dispatch produces a valid result.
|
|
1680
|
-
process.stderr.write(`[remediate-code] dispatch: existing implement result ${resultPath} is present but ` +
|
|
1681
|
-
`invalid (${err instanceof Error ? err.message : String(err)}); archiving and re-dispatching\n`);
|
|
1682
|
-
await archiveIncompleteImplementResult(resultPath);
|
|
1683
|
-
return undefined;
|
|
1684
|
-
}
|
|
1685
|
-
}
|
|
1686
|
-
function pendingOrDocumentedFindingIdsForBlock(block, state) {
|
|
1687
|
-
return block.items.filter((findingId) => {
|
|
1688
|
-
const item = state.items?.[findingId];
|
|
1689
|
-
return item?.status === "pending" && !isTerminalStatus(item.status);
|
|
1690
|
-
});
|
|
1691
|
-
}
|
|
1692
|
-
/**
|
|
1693
|
-
* Bound on incomplete-coverage re-dispatch (E2): after this many merges observe a
|
|
1694
|
-
* worker silently omitting an assigned finding from its `item_results`, the finding
|
|
1695
|
-
* is blocked (→ triage) so the run converges instead of re-dispatching the same
|
|
1696
|
-
* worker indefinitely. Mirrors the other small convergence caps (DAG/cyclic-seam = 2).
|
|
1697
|
-
*/
|
|
1698
|
-
const MAX_INCOMPLETE_COVERAGE_ATTEMPTS = 2;
|
|
1699
|
-
/**
|
|
1700
|
-
* Resolve the set of finding ids a worker result actually covers, alias-aware:
|
|
1701
|
-
* a worker may legitimately report a finding by its block id or an obligation
|
|
1702
|
-
* alias (the exact resolution `collapseItemResults` applies). Coverage/completeness
|
|
1703
|
-
* decisions MUST use this — a raw `finding_id` set would treat an alias-using-but-
|
|
1704
|
-
* complete result as incomplete and re-dispatch it forever.
|
|
1705
|
-
*/
|
|
1706
|
-
function resolveCoveredFindingIds(result, block, state) {
|
|
1707
|
-
const knownFindingIds = new Set(Object.keys(state.items ?? {}));
|
|
1708
|
-
const aliasMap = buildBlockAliasMap(block, state);
|
|
1709
|
-
const covered = new Set();
|
|
1710
|
-
for (const entry of result.item_results) {
|
|
1711
|
-
let targetId = entry.finding_id;
|
|
1712
|
-
if (!knownFindingIds.has(targetId)) {
|
|
1713
|
-
const nodeId = fromBlockId(targetId);
|
|
1714
|
-
if (nodeId && knownFindingIds.has(nodeId)) {
|
|
1715
|
-
targetId = nodeId;
|
|
1716
|
-
}
|
|
1717
|
-
else {
|
|
1718
|
-
const remapped = aliasMap.get(targetId);
|
|
1719
|
-
if (!remapped)
|
|
1720
|
-
continue;
|
|
1721
|
-
targetId = remapped;
|
|
1722
|
-
}
|
|
1723
|
-
}
|
|
1724
|
-
covered.add(targetId);
|
|
1725
|
-
}
|
|
1726
|
-
return covered;
|
|
1727
|
-
}
|
|
1728
|
-
function implementResultCoversFindings(result, findingIds, block, state) {
|
|
1729
|
-
const covered = resolveCoveredFindingIds(result, block, state);
|
|
1730
|
-
return findingIds.every((findingId) => covered.has(findingId));
|
|
1731
|
-
}
|
|
1732
|
-
async function archiveIncompleteImplementResult(resultPath) {
|
|
1733
|
-
if (!existsSync(resultPath))
|
|
1734
|
-
return;
|
|
1735
|
-
const archivedPath = `${resultPath}.stale-${Date.now()}`;
|
|
1736
|
-
await withFsRetry(() => rename(resultPath, archivedPath));
|
|
1737
|
-
}
|
|
1738
|
-
function runDir(artifactsDir, runId, phase) {
|
|
1739
|
-
return join(artifactsDir, "runs", runId, phase);
|
|
1740
|
-
}
|
|
1741
|
-
function dispatchPlanPath(artifactsDir, runId, phase) {
|
|
1742
|
-
return join(runDir(artifactsDir, runId, phase), "dispatch-plan.json");
|
|
1743
|
-
}
|
|
1744
|
-
const TEST_FILE_RE = /\.(test|spec)\.[cm]?[jt]sx?$/;
|
|
1745
|
-
const WALK_SKIP_DIRS = new Set([
|
|
1746
|
-
"node_modules", ".git", "dist", "build", "coverage", "out",
|
|
1747
|
-
".next", ".turbo", ".audit-tools",
|
|
1748
|
-
]);
|
|
1749
|
-
/** Bounded recursive scan for test files under `root` (skips vendor/build dirs). */
|
|
1750
|
-
function walkTestFiles(root, max = 400) {
|
|
1751
|
-
const out = [];
|
|
1752
|
-
const stack = [root];
|
|
1753
|
-
let visited = 0;
|
|
1754
|
-
while (stack.length > 0 && out.length < max) {
|
|
1755
|
-
const dir = stack.pop();
|
|
1756
|
-
let entries;
|
|
1757
|
-
try {
|
|
1758
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
1759
|
-
}
|
|
1760
|
-
catch {
|
|
1761
|
-
continue;
|
|
1762
|
-
}
|
|
1763
|
-
for (const entry of entries) {
|
|
1764
|
-
if (++visited > 20000)
|
|
1765
|
-
return out;
|
|
1766
|
-
if (entry.isDirectory()) {
|
|
1767
|
-
if (WALK_SKIP_DIRS.has(entry.name) || entry.name.startsWith(".test-"))
|
|
1768
|
-
continue;
|
|
1769
|
-
stack.push(join(dir, entry.name));
|
|
1770
|
-
}
|
|
1771
|
-
else if (TEST_FILE_RE.test(entry.name)) {
|
|
1772
|
-
out.push(join(dir, entry.name));
|
|
1773
|
-
if (out.length >= max)
|
|
1774
|
-
break;
|
|
1775
|
-
}
|
|
1776
|
-
}
|
|
1777
|
-
}
|
|
1778
|
-
return out;
|
|
1779
|
-
}
|
|
1780
|
-
/**
|
|
1781
|
-
* Walk the repo ONCE and read every test file's content (bounded). Built once per
|
|
1782
|
-
* dispatch and shared across all blocks so the filesystem walk + reads are not
|
|
1783
|
-
* repeated per block.
|
|
1784
|
-
*/
|
|
1785
|
-
export function buildTestFileIndex(root) {
|
|
1786
|
-
const index = [];
|
|
1787
|
-
for (const testPath of walkTestFiles(root)) {
|
|
1788
|
-
let content;
|
|
1789
|
-
try {
|
|
1790
|
-
content = readFileSync(testPath, "utf8");
|
|
1791
|
-
}
|
|
1792
|
-
catch {
|
|
1793
|
-
continue;
|
|
1794
|
-
}
|
|
1795
|
-
index.push({ rel: relative(root, testPath).replace(/\\/g, "/"), content });
|
|
1796
|
-
}
|
|
1797
|
-
return index;
|
|
1798
|
-
}
|
|
1799
|
-
/**
|
|
1800
|
-
* Collect test files from `index` that reference any of `sourceFiles` by
|
|
1801
|
-
* module basename. When `packageRoot` is supplied (repo-relative prefix, e.g.
|
|
1802
|
-
* `packages/foo`), only test files under that package are considered —
|
|
1803
|
-
* otherwise all test files in the index are matched (existing behavior).
|
|
1804
|
-
*/
|
|
1805
|
-
export function collectReferencingTests(index, sourceFiles, packageRoot) {
|
|
1806
|
-
if (sourceFiles.length === 0 || index.length === 0)
|
|
1807
|
-
return [];
|
|
1808
|
-
const basenames = sourceFiles
|
|
1809
|
-
.map((f) => (f.split(/[/\\]/).pop() ?? f).replace(/\.[cm]?[jt]sx?$/, ""))
|
|
1810
|
-
.filter((b) => b.length > 1);
|
|
1811
|
-
if (basenames.length === 0)
|
|
1812
|
-
return [];
|
|
1813
|
-
const needles = basenames.map((b) => new RegExp(`\\b${b.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`));
|
|
1814
|
-
const sourceSet = new Set(sourceFiles.map((f) => f.replace(/\\/g, "/")));
|
|
1815
|
-
// Normalize packageRoot to forward slashes and ensure it ends without trailing slash
|
|
1816
|
-
const pkgPrefix = packageRoot
|
|
1817
|
-
? packageRoot.replace(/\\/g, "/").replace(/\/$/, "") + "/"
|
|
1818
|
-
: null;
|
|
1819
|
-
const result = [];
|
|
1820
|
-
for (const { rel, content } of index) {
|
|
1821
|
-
if (sourceSet.has(rel))
|
|
1822
|
-
continue;
|
|
1823
|
-
// If a package scope is set, skip files outside that package
|
|
1824
|
-
if (pkgPrefix && !rel.startsWith(pkgPrefix))
|
|
1825
|
-
continue;
|
|
1826
|
-
if (needles.some((re) => re.test(content)))
|
|
1827
|
-
result.push(rel);
|
|
1828
|
-
}
|
|
1829
|
-
return result;
|
|
1830
|
-
}
|
|
1831
|
-
function uniquePaths(paths) {
|
|
1832
|
-
return [...new Set(paths)];
|
|
1833
|
-
}
|
|
1834
|
-
/**
|
|
1835
|
-
* Detect the nearest ancestor directory containing a `package.json` for the
|
|
1836
|
-
* first source file in `sourceFiles` (walk up, stop at `root`). Returns the
|
|
1837
|
-
* repo-relative path prefix (e.g. `packages/foo`) or undefined if none found.
|
|
1838
|
-
*/
|
|
1839
|
-
function detectPackageRoot(sourceFiles, root) {
|
|
1840
|
-
if (sourceFiles.length === 0)
|
|
1841
|
-
return undefined;
|
|
1842
|
-
const first = sourceFiles[0];
|
|
1843
|
-
// Resolve to absolute path relative to root if not already absolute
|
|
1844
|
-
const absFirst = first.startsWith("/") || /^[A-Za-z]:[/\\]/.test(first)
|
|
1845
|
-
? first
|
|
1846
|
-
: join(root, first);
|
|
1847
|
-
let dir = dirname(absFirst);
|
|
1848
|
-
while (dir !== root && dir.length > root.length) {
|
|
1849
|
-
if (existsSync(join(dir, "package.json"))) {
|
|
1850
|
-
return relative(root, dir).replace(/\\/g, "/");
|
|
1851
|
-
}
|
|
1852
|
-
const parent = dirname(dir);
|
|
1853
|
-
if (parent === dir)
|
|
1854
|
-
break;
|
|
1855
|
-
dir = parent;
|
|
1856
|
-
}
|
|
1857
|
-
return undefined;
|
|
1858
|
-
}
|
|
1859
|
-
/**
|
|
1860
|
-
* The files an implement worker should receive as context for a finding:
|
|
1861
|
-
* pre-document affected_files PLUS any files the document phase declared in the
|
|
1862
|
-
* item_spec's `touched_files`.
|
|
1863
|
-
*/
|
|
1864
|
-
function itemReadFiles(finding, spec) {
|
|
1865
|
-
const files = finding.affected_files.map((f) => f.path);
|
|
1866
|
-
if (spec?.touched_files)
|
|
1867
|
-
files.push(...spec.touched_files);
|
|
1868
|
-
return uniquePaths(files);
|
|
1869
|
-
}
|
|
1870
|
-
/**
|
|
1871
|
-
* The files an implement worker is expected to write. The documented
|
|
1872
|
-
* `touched_files` set is authoritative when present; affected_files are only a
|
|
1873
|
-
* fallback for older or incomplete document results.
|
|
1874
|
-
*/
|
|
1875
|
-
function itemWriteFiles(finding, spec) {
|
|
1876
|
-
if (Array.isArray(spec?.touched_files)) {
|
|
1877
|
-
return uniquePaths(spec.touched_files);
|
|
1878
|
-
}
|
|
1879
|
-
return uniquePaths(finding.affected_files.map((f) => f.path));
|
|
1880
|
-
}
|
|
1881
|
-
/**
|
|
1882
|
-
* Repo-relative paths every finding in a block needs for context, deduped.
|
|
1883
|
-
*/
|
|
1884
|
-
function blockReadFiles(block, state) {
|
|
1885
|
-
const files = block.items.flatMap((findingId) => {
|
|
1886
|
-
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
1887
|
-
if (!finding)
|
|
1888
|
-
return [];
|
|
1889
|
-
return itemReadFiles(finding, state.items?.[findingId]?.item_spec);
|
|
1890
|
-
});
|
|
1891
|
-
return uniquePaths(files);
|
|
1892
|
-
}
|
|
1893
|
-
/**
|
|
1894
|
-
* Repo-relative paths every finding in a block may write, deduped. This is kept
|
|
1895
|
-
* narrower than read context so a broad affected hub file does not serialize
|
|
1896
|
-
* blocks whose documented write sets are actually disjoint.
|
|
1897
|
-
*/
|
|
1898
|
-
function blockWriteFiles(block, state) {
|
|
1899
|
-
const files = block.items.flatMap((findingId) => {
|
|
1900
|
-
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
1901
|
-
if (!finding)
|
|
1902
|
-
return [];
|
|
1903
|
-
return itemWriteFiles(finding, state.items?.[findingId]?.item_spec);
|
|
1904
|
-
});
|
|
1905
|
-
return uniquePaths(files);
|
|
1906
|
-
}
|
|
1907
|
-
/**
|
|
1908
|
-
* Construct the DispatchPlanItem for an implement task. Single source of truth
|
|
1909
|
-
* so prepareImplementDispatch and mergeImplementResults stay in lockstep on item
|
|
1910
|
-
* shape.
|
|
1911
|
-
*/
|
|
1912
|
-
function buildImplementDispatchItem(block, state, dir) {
|
|
1913
|
-
const taskId = `implement-${block.block_id}`;
|
|
1914
|
-
const readFiles = blockReadFiles(block, state);
|
|
1915
|
-
const writeFiles = blockWriteFiles(block, state);
|
|
1916
|
-
const resultPath = join(dir, `${taskId}.result.json`);
|
|
1917
|
-
return {
|
|
1918
|
-
task_id: taskId,
|
|
1919
|
-
block_id: block.block_id,
|
|
1920
|
-
prompt_path: join(dir, `${taskId}.md`),
|
|
1921
|
-
result_path: resultPath,
|
|
1922
|
-
model_hint: buildImplementModelHint(block, state),
|
|
1923
|
-
access: {
|
|
1924
|
-
read_paths: readFiles,
|
|
1925
|
-
write_paths: [...writeFiles, resultPath],
|
|
1926
|
-
},
|
|
1927
|
-
};
|
|
1928
|
-
}
|
|
1929
|
-
export function buildImplementModelHint(block, state) {
|
|
1930
|
-
// Prefer the node's own promoted `model_tier` (derived from contract-pipeline
|
|
1931
|
-
// complexity signals) over a re-derived block heuristic. In the contract
|
|
1932
|
-
// pipeline a block maps 1:1 to a DAG node, so the block's single finding
|
|
1933
|
-
// carries the authoritative relative rank. Never collapse to a flat
|
|
1934
|
-
// "standard" when the node declared a tier.
|
|
1935
|
-
const nodeTiers = block.items
|
|
1936
|
-
.map((findingId) => {
|
|
1937
|
-
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
1938
|
-
return finding ? nodeFieldsOf(finding).model_tier : undefined;
|
|
1939
|
-
})
|
|
1940
|
-
.filter((t) => t !== undefined);
|
|
1941
|
-
if (nodeTiers.length > 0) {
|
|
1942
|
-
// Take the most-capable declared rank across the block's nodes so a deep
|
|
1943
|
-
// node is never under-provisioned by a sibling's smaller rank. Ordering is
|
|
1944
|
-
// the single shared tier-rank authority (`mostCapableTier`).
|
|
1945
|
-
const tier = mostCapableTier(nodeTiers) ?? "standard";
|
|
1946
|
-
return { tier, reasons: ["node_model_tier"] };
|
|
1947
|
-
}
|
|
1948
|
-
const deepReasons = [];
|
|
1949
|
-
let allSafe = true;
|
|
1950
|
-
let maxSeverityRank = 0;
|
|
1951
|
-
for (const findingId of block.items) {
|
|
1952
|
-
const item = state.items?.[findingId];
|
|
1953
|
-
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
1954
|
-
if (!finding)
|
|
1955
|
-
continue;
|
|
1956
|
-
const rank = severityRank(finding.severity);
|
|
1957
|
-
if (rank > maxSeverityRank)
|
|
1958
|
-
maxSeverityRank = rank;
|
|
1959
|
-
if (item?.item_spec) {
|
|
1960
|
-
const { tier } = classifyFindingRisk(finding, item.item_spec);
|
|
1961
|
-
if (tier === "context_dependent") {
|
|
1962
|
-
deepReasons.push(`context_dependent_${findingId}`);
|
|
1963
|
-
}
|
|
1964
|
-
if (tier !== "safe") {
|
|
1965
|
-
allSafe = false;
|
|
1966
|
-
}
|
|
1967
|
-
}
|
|
1968
|
-
else {
|
|
1969
|
-
allSafe = false;
|
|
1970
|
-
}
|
|
1971
|
-
}
|
|
1972
|
-
if (maxSeverityRank >= 5) {
|
|
1973
|
-
deepReasons.push("critical_severity");
|
|
1974
|
-
}
|
|
1975
|
-
if (block.items.length >= 5) {
|
|
1976
|
-
deepReasons.push("large_block");
|
|
1977
|
-
}
|
|
1978
|
-
if (deepReasons.length > 0) {
|
|
1979
|
-
return { tier: "deep", reasons: deepReasons };
|
|
1980
|
-
}
|
|
1981
|
-
if (allSafe && block.items.length === 1 && maxSeverityRank <= 2) {
|
|
1982
|
-
return { tier: "small", reasons: ["all_safe_single_finding"] };
|
|
1983
|
-
}
|
|
1984
|
-
return { tier: "standard", reasons: ["default_implement_block"] };
|
|
1985
|
-
}
|
|
1986
|
-
/**
|
|
1987
|
-
* G1 + INV-GND-02: a finding that the auditor's grounding pass marked ungrounded
|
|
1988
|
-
* — or that carries NO grounding verdict (undefined → treated as ungrounded) —
|
|
1989
|
-
* has not been positively re-verified against the cited code. Instruct the
|
|
1990
|
-
* worker to VERIFY the claim against the source first and only then fix it (or
|
|
1991
|
-
* resolve_no_change if the claim does not hold), rather than blindly applying a
|
|
1992
|
-
* fix to a possibly-stale/hallucinated finding. A positively-grounded finding
|
|
1993
|
-
* adds no bullet (it was already re-verified at ingest).
|
|
1994
|
-
*/
|
|
1995
|
-
function groundingVerificationBullet(finding) {
|
|
1996
|
-
if (!findingNeedsVerificationBeforeFix(finding))
|
|
1997
|
-
return "";
|
|
1998
|
-
const reason = finding.grounding?.reason
|
|
1999
|
-
? ` (${finding.grounding.reason})`
|
|
2000
|
-
: " (no grounding verdict was recorded for this finding)";
|
|
2001
|
-
return `- VERIFY BEFORE FIX: this finding is not positively grounded${reason}. Confirm the claim against the cited code first; if it holds, fix it, otherwise mark the item \`resolved_no_change\` with evidence. Do not apply a fix to an unverified claim.`;
|
|
2002
|
-
}
|
|
2003
|
-
/**
|
|
2004
|
-
* Opt-in meta-audit reflection invitation (parity with audit-code's worker
|
|
2005
|
-
* prompt). Rendered after the file-access section because it carves out one
|
|
2006
|
-
* extra append-only path. Shape: the shared `AgentReflectionSchema`;
|
|
2007
|
-
* the close phase aggregates the file into the report's "Process Feedback"
|
|
2008
|
-
* section. Best-effort by design — it must never compete with the obligation.
|
|
2009
|
-
*/
|
|
2010
|
-
function reflectionInvitation(feedbackDisplay, taskId, lens) {
|
|
2011
|
-
return `
|
|
2012
|
-
## Optional process feedback
|
|
2013
|
-
|
|
2014
|
-
Never let this delay or replace the required output above: if you hit task
|
|
2015
|
-
ambiguity, tool friction, or unclear instructions, you MAY append one JSON
|
|
2016
|
-
reflection line to \`${feedbackDisplay}\` with shape:
|
|
2017
|
-
{"task_id": "${taskId}"${lens ? `, "lens": "${lens}"` : ""}, "instruction_clarity": "clear|mostly_clear|ambiguous|unclear",
|
|
2018
|
-
"ambiguities": ["..."], "tool_friction": ["..."], "suggestions": ["..."],
|
|
2019
|
-
"severity": "info|low|medium|high"}
|
|
2020
|
-
One object per line; never overwrite existing lines. Appending to this file is
|
|
2021
|
-
allowed in addition to the file access above.
|
|
2022
|
-
`;
|
|
2023
|
-
}
|
|
2024
|
-
// ---------------------------------------------------------------------------
|
|
2025
|
-
// Infra-modifying block detection
|
|
2026
|
-
// ---------------------------------------------------------------------------
|
|
2027
|
-
/**
|
|
2028
|
-
* The live dispatch/orchestration modules whose modification can break the
|
|
2029
|
-
* running engine mid-run. Derived from the REAL post-A12 source layout
|
|
2030
|
-
* (`src/remediate/...`) — this module IS one of them (`steps/dispatch.ts`), so
|
|
2031
|
-
* the list is anchored to the actual files on disk, not a hand-typed monorepo
|
|
2032
|
-
* path that drifts when the tree is reorganised (the pre-A12
|
|
2033
|
-
* `packages/remediate-code/...` list silently matched NOTHING after the
|
|
2034
|
-
* collapse, so every infra block rendered as non-infra). Each entry is the
|
|
2035
|
-
* module's path relative to the `src/remediate` area, forward-slash form.
|
|
2036
|
-
*/
|
|
2037
|
-
const INFRA_MODULE_SUBPATHS = [
|
|
2038
|
-
"steps/nextStep.ts",
|
|
2039
|
-
"steps/dispatch.ts",
|
|
2040
|
-
"state/store.ts",
|
|
2041
|
-
"steps/contractPipeline.ts",
|
|
2042
|
-
"steps/stepWriter.ts",
|
|
2043
|
-
];
|
|
2044
|
-
/**
|
|
2045
|
-
* The infra module sub-paths anchored under `src/remediate/` — the canonical
|
|
2046
|
-
* repo-relative form for the current (post-A12) single-package layout. A write
|
|
2047
|
-
* path matches when its normalised (forward-slash) form ends with one of these
|
|
2048
|
-
* segments, so an absolute worktree path
|
|
2049
|
-
* (`.../worktrees/foo/src/remediate/steps/dispatch.ts`), a repo-relative path
|
|
2050
|
-
* (`src/remediate/steps/dispatch.ts`), or a Windows backslash path all match,
|
|
2051
|
-
* while a same-basename file in another area (`src/audit/steps/dispatch.ts`)
|
|
2052
|
-
* does not.
|
|
2053
|
-
*/
|
|
2054
|
-
const INFRA_FILE_SEGMENTS = INFRA_MODULE_SUBPATHS.map((sub) => `src/remediate/${sub}`);
|
|
2055
|
-
/**
|
|
2056
|
-
* Returns true when any path in `writePaths` is one of the live infra modules.
|
|
2057
|
-
* Paths are normalised to forward-slash form (win32 backslash → `/`) and matched
|
|
2058
|
-
* by trailing repo-relative segment so absolute/worktree/relative spellings all
|
|
2059
|
-
* resolve identically. Used to gate the live-surface verification section in the
|
|
2060
|
-
* implement prompt.
|
|
2061
|
-
*/
|
|
2062
|
-
export function isInfraModifyingBlock(writePaths) {
|
|
2063
|
-
for (const p of writePaths) {
|
|
2064
|
-
const normalized = p.replace(/\\/g, "/");
|
|
2065
|
-
for (const segment of INFRA_FILE_SEGMENTS) {
|
|
2066
|
-
if (normalized === segment || normalized.endsWith("/" + segment)) {
|
|
2067
|
-
return true;
|
|
2068
|
-
}
|
|
2069
|
-
}
|
|
2070
|
-
}
|
|
2071
|
-
return false;
|
|
2072
|
-
}
|
|
2073
|
-
function infraModifyingSection(repoRoot) {
|
|
2074
|
-
const rootDisplay = toPromptPathToken(repoRoot);
|
|
2075
|
-
return `
|
|
2076
|
-
## Infra-modifying block
|
|
2077
|
-
|
|
2078
|
-
This block modifies the dispatch/orchestration engine that the current run
|
|
2079
|
-
executes. **The host builds the package centrally — do NOT run \`npm run build\`
|
|
2080
|
-
or \`npm test\` here** (a worker-side build races the central build's \`dist/\`).
|
|
2081
|
-
Verify build-free only and let the host re-exercise the live surface after its
|
|
2082
|
-
central build:
|
|
2083
|
-
|
|
2084
|
-
1. **Type-check (no emit):** After completing all edits, run:
|
|
2085
|
-
\`\`\`
|
|
2086
|
-
npm run check
|
|
2087
|
-
\`\`\`
|
|
2088
|
-
from \`${rootDisplay}\`. If type-check fails, mark the item blocked and record
|
|
2089
|
-
the failure in \`failure_reason\`.
|
|
2090
|
-
|
|
2091
|
-
2. **Targeted build-free tests:** Run this package's build-free test runner
|
|
2092
|
-
directly against the tests for your change — for remediate-code:
|
|
2093
|
-
\`\`\`
|
|
2094
|
-
npx vitest run <your-test-file>
|
|
2095
|
-
\`\`\`
|
|
2096
|
-
from \`${rootDisplay}\`. Never invoke \`npm test\`/\`npm run build\`: those
|
|
2097
|
-
prepend a build. If a targeted test fails, mark the item blocked and record
|
|
2098
|
-
the failure in \`failure_reason\`.
|
|
2099
|
-
|
|
2100
|
-
3. **Rollback is the host's job.** Because you do not build or republish the
|
|
2101
|
-
engine, you cannot brick the live dispatcher mid-run. The host owns the
|
|
2102
|
-
central build and any dist rollback; record the files you changed in your
|
|
2103
|
-
result evidence so the host can attribute a post-build failure.
|
|
2104
|
-
`;
|
|
2105
|
-
}
|
|
2106
|
-
/**
|
|
2107
|
-
* Per-item bullets threading what upstream/neighbor nodes agreed to provide
|
|
2108
|
-
* (INV-DS-12): the node's reconciliation_expectations / preconditions and its
|
|
2109
|
-
* expected_changes. Rendered inside each item so a dependent node implements
|
|
2110
|
-
* against the realized upstream surface rather than guessing.
|
|
2111
|
-
*/
|
|
2112
|
-
function upstreamExpectationsBullets(finding) {
|
|
2113
|
-
const node = nodeFieldsOf(finding);
|
|
2114
|
-
const expectations = reconciliationExpectationsOf(finding);
|
|
2115
|
-
const lines = [];
|
|
2116
|
-
if (expectations.length > 0) {
|
|
2117
|
-
lines.push(`- Upstream/neighbor contract provides (implement against these, do not redefine them): ${expectations.join("; ")}`);
|
|
2118
|
-
}
|
|
2119
|
-
if (typeof node.expected_changes === "string" && node.expected_changes.trim().length > 0) {
|
|
2120
|
-
lines.push(`- Expected changes: ${node.expected_changes.trim()}`);
|
|
2121
|
-
}
|
|
2122
|
-
if (Array.isArray(node.verification) && node.verification.length > 0) {
|
|
2123
|
-
lines.push(`- Verification checks: ${node.verification.join("; ")}`);
|
|
2124
|
-
}
|
|
2125
|
-
return lines.join("\n");
|
|
2126
|
-
}
|
|
2127
|
-
/**
|
|
2128
|
-
* The build-free per-node verification section. Emits the node's own build-free
|
|
2129
|
-
* targeted commands (build/build-prepending commands filtered out — residual
|
|
2130
|
-
* CE-001) plus the standard build-free baseline (`npm run check` + the package's
|
|
2131
|
-
* build-free test runner). NON-EMITTING: it instructs the worker to run these to
|
|
2132
|
-
* gate its own result, never to emit further dispatch.
|
|
2133
|
-
*/
|
|
2134
|
-
function perNodeVerificationSection(block, state, rootDisplay) {
|
|
2135
|
-
const nodeCommands = uniquePaths(block.items.flatMap((findingId) => {
|
|
2136
|
-
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
2137
|
-
return finding ? buildFreeVerifyCommands(finding.targeted_commands) : [];
|
|
2138
|
-
}));
|
|
2139
|
-
const commandBlock = nodeCommands.length > 0
|
|
2140
|
-
? `Run these node-targeted, build-free commands and record each command + result in the affected item's evidence:
|
|
2141
|
-
\`\`\`
|
|
2142
|
-
${nodeCommands.join("\n")}
|
|
2143
|
-
\`\`\`
|
|
2144
|
-
`
|
|
2145
|
-
: "";
|
|
2146
|
-
return `
|
|
2147
|
-
## Per-node verification (build-free)
|
|
2148
|
-
|
|
2149
|
-
The host builds the package centrally; do NOT run \`npm run build\` or \`npm test\`
|
|
2150
|
-
(either races the central build's \`dist/\`). Verify build-free only, from
|
|
2151
|
-
\`${rootDisplay}\`:
|
|
2152
|
-
|
|
2153
|
-
- Type-check with \`npm run check\` (no emit).
|
|
2154
|
-
- Run the package's build-free test runner directly against your change
|
|
2155
|
-
(remediate-code: \`npx vitest run <your-test-file>\`; node-test packages:
|
|
2156
|
-
\`node --import tsx/esm --test <your-test-file>\`).
|
|
2157
|
-
|
|
2158
|
-
${commandBlock}A node is verified-complete only when its declared outputs exist and these
|
|
2159
|
-
build-free checks pass; otherwise mark the item blocked with the failure in
|
|
2160
|
-
\`failure_reason\`.
|
|
2161
|
-
`;
|
|
2162
|
-
}
|
|
2163
|
-
function implementPrompt(block, state, resultPath, conventions, repoRoot, feedbackDisplay, worktreeRoot) {
|
|
2164
|
-
const items = block.items.flatMap((findingId) => {
|
|
2165
|
-
const item = state.items?.[findingId];
|
|
2166
|
-
const finding = state.plan?.findings.find((entry) => entry.id === findingId);
|
|
2167
|
-
if (!finding)
|
|
2168
|
-
return [];
|
|
2169
|
-
// Only render items that still need implementing — never a resolved item
|
|
2170
|
-
// from a prior wave or one the user skipped (deemed_inappropriate/ignored).
|
|
2171
|
-
if (!item || isTerminalStatus(item.status))
|
|
2172
|
-
return [];
|
|
2173
|
-
if (item.status !== "pending")
|
|
2174
|
-
return [];
|
|
2175
|
-
// item_spec may be pre-populated from the plan DAG node or absent;
|
|
2176
|
-
// either way the implementer receives finding context directly.
|
|
2177
|
-
// clarification_context carries the user's answer when this item was re-opened
|
|
2178
|
-
// from a clarification round (up-front gate or mid-run) — thread it through so
|
|
2179
|
-
// the retry acts on the decided scope, not the original ambiguity.
|
|
2180
|
-
return [{ finding, spec: item.item_spec, clarification: item.clarification_context }];
|
|
2181
|
-
});
|
|
2182
|
-
// When a worktreeRoot is supplied, the worker operates in the worktree, not
|
|
2183
|
-
// the main repo root. Source file paths are prefixed with the worktree root.
|
|
2184
|
-
// The result path always lives in the artifacts dir (outside the worktree).
|
|
2185
|
-
const effectiveRoot = worktreeRoot ?? repoRoot;
|
|
2186
|
-
// Normalize to forward slashes for host-facing prompt text; bash-like shells
|
|
2187
|
-
// on Windows treat backslashes as escape characters.
|
|
2188
|
-
const rootDisplay = toPromptPathToken(effectiveRoot);
|
|
2189
|
-
const resultDisplay = toPromptPathToken(resultPath);
|
|
2190
|
-
// Prefix each source file path with the worktree root when applicable.
|
|
2191
|
-
function resolveFilePath(rel) {
|
|
2192
|
-
if (!worktreeRoot)
|
|
2193
|
-
return rel;
|
|
2194
|
-
if (rel.startsWith("/") || /^[A-Za-z]:[/\\]/.test(rel))
|
|
2195
|
-
return rel;
|
|
2196
|
-
return toPromptPathToken(join(worktreeRoot, rel));
|
|
2197
|
-
}
|
|
2198
|
-
const worktreeNote = worktreeRoot
|
|
2199
|
-
? `\nYou are working in a worktree at ${toPromptPathToken(worktreeRoot)}; all file edits go here. Do not edit files outside this worktree.\n`
|
|
2200
|
-
: "";
|
|
2201
|
-
return `
|
|
2202
|
-
# Implement Remediation Block
|
|
2203
|
-
|
|
2204
|
-
You are implementing one bounded remediation block. Edit the files needed for the
|
|
2205
|
-
findings in this prompt, and you MAY create new files (e.g. a test file or an
|
|
2206
|
-
extracted module) within the SAME package as those files when a finding's change
|
|
2207
|
-
calls for it. Do not edit unrelated files in other packages, and do not change
|
|
2208
|
-
remediation state files directly.
|
|
2209
|
-
Repository root: ${rootDisplay}
|
|
2210
|
-
Set the shell/tool workdir to the repository root when running commands; do not rely on cwd state from prior shell calls.
|
|
2211
|
-
|
|
2212
|
-
## Block
|
|
2213
|
-
|
|
2214
|
-
- Block ID: ${block.block_id}
|
|
2215
|
-
- Findings: ${items.map(({ finding }) => finding.id).join(", ")}
|
|
2216
|
-
|
|
2217
|
-
## Items
|
|
2218
|
-
|
|
2219
|
-
${items
|
|
2220
|
-
.map(({ finding, spec, clarification }) => `
|
|
2221
|
-
### ${finding.id} — ${finding.title}
|
|
2222
|
-
|
|
2223
|
-
${findingLead(finding.summary)}
|
|
2224
|
-
|
|
2225
|
-
${renderFindingBadgeBody(finding, { showGrounding: false, showAdvisoryMeta: false, showFiles: false, showDetails: false, showEvidence: false }).join("\n")}
|
|
2226
|
-
- Files: ${itemReadFiles(finding, spec).map(resolveFilePath).join(", ")}
|
|
2227
|
-
- Details: ${finding.summary}
|
|
2228
|
-
${clarification ? `- Clarified scope (decided with the user — act on THIS): ${clarification}\n` : ""}${groundingVerificationBullet(finding)}
|
|
2229
|
-
${spec ? `- Concrete change: ${spec.concrete_change}
|
|
2230
|
-
- Tests to write: ${spec.tests_to_write
|
|
2231
|
-
.map((test) => `${test.name}: ${test.assertions.join("; ")}`)
|
|
2232
|
-
.join(" | ")}` : ""}
|
|
2233
|
-
${upstreamExpectationsBullets(finding)}
|
|
2234
|
-
`)
|
|
2235
|
-
.join("\n")}
|
|
2236
|
-
${conventions ? `\n${conventions}\n` : ""}${perNodeVerificationSection(block, state, rootDisplay)}${isInfraModifyingBlock(blockWriteFiles(block, state)) ? infraModifyingSection(repoRoot) : ""}
|
|
2237
|
-
## Verification
|
|
2238
|
-
${worktreeNote}
|
|
2239
|
-
Run changed or newly created tests by name when possible, and record the focused
|
|
2240
|
-
command and result in the affected item's evidence. If a broad or full-suite
|
|
2241
|
-
command fails in a dirty worktree and appears unrelated or pre-existing, record
|
|
2242
|
-
that broad failure separately instead of using it as the only verdict for this
|
|
2243
|
-
block. If a focused test for this block fails, the affected item remains blocked.
|
|
2244
|
-
If targeted commands are listed under an item, run them when applicable and
|
|
2245
|
-
include each command and result in that item's evidence.
|
|
2246
|
-
|
|
2247
|
-
Windows PowerShell: do not pipe an inline foreach statement directly into ConvertTo-Json.
|
|
2248
|
-
Assign the foreach output to a variable first, then pipe that variable to ConvertTo-Json.
|
|
2249
|
-
|
|
2250
|
-
## Output
|
|
2251
|
-
|
|
2252
|
-
After editing and verifying the block, write JSON to exactly:
|
|
2253
|
-
|
|
2254
|
-
\`${resultDisplay}\`
|
|
2255
|
-
|
|
2256
|
-
Emit **exactly one \`item_results\` entry per node id below — no more, no fewer**.
|
|
2257
|
-
Each entry's \`finding_id\` MUST be one of the exact ids: ${items
|
|
2258
|
-
.map(({ finding }) => `\`${finding.id}\``)
|
|
2259
|
-
.join(", ")}. Do not substitute a title, an obligation id, or a block id for
|
|
2260
|
-
the node id, and do not emit duplicate entries for the same node.
|
|
2261
|
-
|
|
2262
|
-
\`\`\`json
|
|
2263
|
-
{
|
|
2264
|
-
"contract_version": "${REMEDIATION_WORKER_RESULT_CONTRACT_VERSION}",
|
|
2265
|
-
"phase": "implement",
|
|
2266
|
-
"item_results": [
|
|
2267
|
-
${items
|
|
2268
|
-
.map(({ finding }) => ` {
|
|
2269
|
-
"finding_id": "${finding.id}",
|
|
2270
|
-
"status": "resolved",
|
|
2271
|
-
"evidence": ["test or verification evidence"]
|
|
2272
|
-
}`)
|
|
2273
|
-
.join(",\n")}
|
|
2274
|
-
]
|
|
2275
|
-
}
|
|
2276
|
-
\`\`\`
|
|
2277
|
-
|
|
2278
|
-
For an item you cannot safely finish because of an EXECUTION failure (a test
|
|
2279
|
-
won't pass, a build breaks, the change is infeasible), set \`status\` to
|
|
2280
|
-
\`blocked\` and include \`failure_reason\`. If instead you are stuck on a SCOPING
|
|
2281
|
-
or JUDGMENT question — how far the fix should reach, which of several valid
|
|
2282
|
-
behaviors is intended, or whether the issue is real — do NOT guess and do NOT
|
|
2283
|
-
block: set \`status\` to \`needs_clarification\` and put the question in
|
|
2284
|
-
\`clarification_question\` (optionally \`clarification_category\`). It is routed to
|
|
2285
|
-
the user as a real question, then re-dispatched with the answer. Stop after
|
|
2286
|
-
writing the result JSON.
|
|
2287
|
-
|
|
2288
|
-
## File access
|
|
2289
|
-
|
|
2290
|
-
Read: ${uniquePaths(items.flatMap(({ finding, spec }) => itemReadFiles(finding, spec))).join(", ")}
|
|
2291
|
-
Write: ${uniquePaths(items.flatMap(({ finding, spec }) => itemWriteFiles(finding, spec))).join(", ")}
|
|
2292
|
-
You may also create new files within the same package as those files (e.g. tests
|
|
2293
|
-
or extracted modules) when a finding requires it.
|
|
2294
|
-
If your change renames, moves, or removes a symbol, also update the existing test
|
|
2295
|
-
files that reference it — fixing tests for a changed surface is part of this
|
|
2296
|
-
block, not a later cleanup. Test files that reference these files are included in
|
|
2297
|
-
your write access.
|
|
2298
|
-
Write result: ${resultDisplay}
|
|
2299
|
-
Do not modify unrelated files outside these paths or files in other packages.
|
|
2300
|
-
${reflectionInvitation(feedbackDisplay, block.block_id)}`;
|
|
2301
|
-
}
|
|
2302
|
-
async function loadStateOrThrow(artifactsDir) {
|
|
2303
|
-
const state = await new StateStore(artifactsDir).loadState();
|
|
2304
|
-
if (!state) {
|
|
2305
|
-
throw new Error(`No remediation state found at ${join(artifactsDir, "state.json")}.`);
|
|
2306
|
-
}
|
|
2307
|
-
return state;
|
|
2308
|
-
}
|
|
2309
|
-
export async function prepareImplementDispatch(options, runId, onlyBlockId, waveOptions) {
|
|
2310
|
-
const state = await loadStateOrThrow(options.artifactsDir);
|
|
2311
|
-
if (!state.plan || !state.items) {
|
|
2312
|
-
throw new Error("Cannot prepare implement dispatch without plan and items.");
|
|
2313
|
-
}
|
|
2314
|
-
const dir = runDir(options.artifactsDir, runId, "implement");
|
|
2315
|
-
await mkdir(dir, { recursive: true });
|
|
2316
|
-
// Use the module-level cache so repeated calls within the same process do not
|
|
2317
|
-
// re-scan the filesystem for repo conventions.
|
|
2318
|
-
const conventions = getCachedConventions(options.root);
|
|
2319
|
-
const seenBlockIds = new Set();
|
|
2320
|
-
const candidateBlocks = state.plan.blocks.filter((block) => {
|
|
2321
|
-
if (onlyBlockId && block.block_id !== onlyBlockId)
|
|
2322
|
-
return false;
|
|
2323
|
-
if (seenBlockIds.has(block.block_id))
|
|
2324
|
-
return false;
|
|
2325
|
-
// Rolling eligibility (INV-RS-01): a dependent node is dispatched only once
|
|
2326
|
-
// every prerequisite reached a VERIFIED-COMPLETE disposition
|
|
2327
|
-
// (resolved / resolved_no_change). A skipped or blocked prerequisite never
|
|
2328
|
-
// satisfies the edge, so the dependent is held back rather than racing the
|
|
2329
|
-
// main tree against an upstream surface that never landed.
|
|
2330
|
-
if (!dependencyVerifiedComplete(block, state))
|
|
2331
|
-
return false;
|
|
2332
|
-
const hasWork = block.items.some((findingId) => {
|
|
2333
|
-
const item = state.items?.[findingId];
|
|
2334
|
-
return item?.status === "pending";
|
|
2335
|
-
});
|
|
2336
|
-
if (hasWork) {
|
|
2337
|
-
seenBlockIds.add(block.block_id);
|
|
2338
|
-
return true;
|
|
2339
|
-
}
|
|
2340
|
-
return false;
|
|
2341
|
-
});
|
|
2342
|
-
// Before any node is dispatched (and therefore before any accepted commit is
|
|
2343
|
-
// cherry-picked into the main tree), switch the main checkout onto the dedicated
|
|
2344
|
-
// remediation branch so all landed work accumulates there and the base branch is
|
|
2345
|
-
// never modified. Idempotent across waves; only when there is work to land.
|
|
2346
|
-
if (candidateBlocks.length > 0 && options.root) {
|
|
2347
|
-
ensureRemediationBranchCheckedOut(options.root, runId, options.artifactsDir);
|
|
2348
|
-
}
|
|
2349
|
-
// Walk the repo for test files ONCE per dispatch (not once per block) and cache
|
|
2350
|
-
// their contents; collectReferencingTests then matches in memory.
|
|
2351
|
-
const testIndex = buildTestFileIndex(options.root);
|
|
2352
|
-
const items = [];
|
|
2353
|
-
const itemReadFileLists = [];
|
|
2354
|
-
let reconciledCount = 0;
|
|
2355
|
-
for (const block of candidateBlocks) {
|
|
2356
|
-
const item = buildImplementDispatchItem(block, state, dir);
|
|
2357
|
-
const readFiles = blockReadFiles(block, state);
|
|
2358
|
-
// Detect the package root from this block's source files: walk up from the
|
|
2359
|
-
// first source file to the nearest ancestor with a package.json (stop at root).
|
|
2360
|
-
const packageRoot = detectPackageRoot(readFiles, options.root);
|
|
2361
|
-
// Pull test files that reference this block's source into its access, so the
|
|
2362
|
-
// worker that changes or removes a symbol also fixes the tests that assert it
|
|
2363
|
-
// (otherwise their breakage is orphaned for a separate central mop-up).
|
|
2364
|
-
// Scoped to the block's package to avoid pulling in unrelated package tests.
|
|
2365
|
-
const referencingTests = collectReferencingTests(testIndex, readFiles, packageRoot);
|
|
2366
|
-
if (referencingTests.length > 0 && item.access) {
|
|
2367
|
-
item.access.read_paths = [
|
|
2368
|
-
...new Set([...item.access.read_paths, ...referencingTests]),
|
|
2369
|
-
];
|
|
2370
|
-
item.access.write_paths = [
|
|
2371
|
-
...new Set([...item.access.write_paths, ...referencingTests]),
|
|
2372
|
-
];
|
|
2373
|
-
}
|
|
2374
|
-
// Reconcile an already-produced result regardless of wave packing.
|
|
2375
|
-
const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
|
|
2376
|
-
const existingResult = await tryLoadExistingImplementResult(item.result_path);
|
|
2377
|
-
if (existingResult) {
|
|
2378
|
-
if (implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
|
|
2379
|
-
reconciledCount++;
|
|
2380
|
-
continue;
|
|
2381
|
-
}
|
|
2382
|
-
process.stderr.write(`[remediate-code] dispatch: existing implement result for block ${block.block_id} ` +
|
|
2383
|
-
`does not cover ${pendingFindingIds.length} still-pending item(s); re-dispatching\n`);
|
|
2384
|
-
await archiveIncompleteImplementResult(item.result_path);
|
|
2385
|
-
}
|
|
2386
|
-
// No wave-time file-conflict deferral heuristic: parallel blocks with
|
|
2387
|
-
// overlapping files are both dispatched. Parallel safety comes from the planner
|
|
2388
|
-
// (mergeBlocksSharingFiles) and rolling verified-complete dependency ordering
|
|
2389
|
-
// (dependencyVerifiedComplete). Workers operate in isolated worktrees;
|
|
2390
|
-
// verification prevents bad merges from dirtying the main tree.
|
|
2391
|
-
await writeTextFile(item.prompt_path, implementPrompt(block, state, item.result_path, conventions, options.root, toPromptPathToken(join(options.artifactsDir, AGENT_FEEDBACK_FILENAME)), waveOptions?.worktreeRootedPrompts
|
|
2392
|
-
? worktreePath(options.root, block.block_id, runId)
|
|
2393
|
-
: undefined));
|
|
2394
|
-
items.push(item);
|
|
2395
|
-
itemReadFileLists.push([...readFiles, ...referencingTests]);
|
|
2396
|
-
}
|
|
2397
|
-
if (reconciledCount > 0) {
|
|
2398
|
-
console.log(`Reconciliation: reused ${reconciledCount} existing implement results.`);
|
|
2399
|
-
}
|
|
2400
|
-
const plan = {
|
|
2401
|
-
contract_version: REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION,
|
|
2402
|
-
phase: "implement",
|
|
2403
|
-
run_id: runId,
|
|
2404
|
-
// Normalize to forward slashes so hosts running bash-like shells on Windows
|
|
2405
|
-
// receive paths that survive shell expansion (backslash is an escape char).
|
|
2406
|
-
repo_root: toPromptPathToken(options.root),
|
|
2407
|
-
artifacts_dir: toPromptPathToken(options.artifactsDir),
|
|
2408
|
-
items,
|
|
2409
|
-
};
|
|
2410
|
-
await writeJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"), plan);
|
|
2411
|
-
const estimatedSlotTokens = itemReadFileLists.map((files) => estimateImplementSlotTokens(files, options.root));
|
|
2412
|
-
const schedule = await scheduleWave({
|
|
2413
|
-
hostMaxConcurrent: waveOptions?.hostMaxConcurrent,
|
|
2414
|
-
sessionConfig: waveOptions?.sessionConfig ?? null,
|
|
2415
|
-
hostContextTokens: waveOptions?.hostContextTokens,
|
|
2416
|
-
hostOutputTokens: waveOptions?.hostOutputTokens,
|
|
2417
|
-
hostModels: waveOptions?.hostModels,
|
|
2418
|
-
hostModelId: waveOptions?.hostModelId,
|
|
2419
|
-
itemCount: items.length,
|
|
2420
|
-
estimatedSlotTokens,
|
|
2421
|
-
});
|
|
2422
|
-
// Admission packets in plan order: id = the node's block id (what
|
|
2423
|
-
// `admission.granted_packet_ids` references and the host matches to nodes),
|
|
2424
|
-
// inputTokens = its estimated slot cost, complexity = the remediate default 0.5.
|
|
2425
|
-
// Keyed by block_id (same filter the frontier builder uses), so a granted id always
|
|
2426
|
-
// resolves to a frontier node.
|
|
2427
|
-
const admissionPackets = items
|
|
2428
|
-
.map((item, i) => ({ item, inputTokens: estimatedSlotTokens[i] ?? 0 }))
|
|
2429
|
-
.filter((p) => typeof p.item.block_id === "string")
|
|
2430
|
-
.map((p) => ({ id: p.item.block_id, inputTokens: p.inputTokens, complexity: 0.5 }));
|
|
2431
|
-
process.stderr.write(`[remediate-code] dispatch: implement ${items.length} item(s) ` +
|
|
2432
|
-
`source=${schedule.source} cap=${schedule.binding_cap ?? "none"}\n`);
|
|
2433
|
-
// Cost-first routing rung 1: honor the operator-confirmed cost ordering from the
|
|
2434
|
-
// shared Gate-0 confirmation (spec/cost-first-routing.md). Best-effort — absent /
|
|
2435
|
-
// unreadable / roster-changed confirmation ⇒ costRank falls to real price then tier.
|
|
2436
|
-
const confirmedCostPositions = await readConfirmedCostPositions(options.root, waveOptions?.sessionConfig ?? {});
|
|
2437
|
-
const quota = await buildDispatchQuota(runId, "implement", schedule, admissionPackets, waveOptions?.grantLeases ?? true, null, confirmedCostPositions);
|
|
2438
|
-
await writeJsonFile(join(dir, "dispatch-quota.json"), quota);
|
|
2439
|
-
return plan;
|
|
2440
|
-
}
|
|
2441
|
-
function assertImplementWorkerResult(value, path) {
|
|
2442
|
-
const issues = validateImplementWorkerResult(value, path).filter((i) => i.severity === "error");
|
|
2443
|
-
if (issues.length > 0) {
|
|
2444
|
-
throw new Error(formatValidationIssues(issues));
|
|
2445
|
-
}
|
|
2446
|
-
}
|
|
2447
|
-
/** True when `root` is inside a git work tree (the git tool is present and it's a repo). */
|
|
2448
|
-
function isGitWorkTree(root) {
|
|
2449
|
-
const probe = spawnSyncHidden("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, encoding: "utf8", shell: false });
|
|
2450
|
-
return !probe.error && probe.status === 0 && /true/.test(probe.stdout ?? "");
|
|
2451
|
-
}
|
|
2452
|
-
/** True when `branch` resolves to a commit in the repo at `root`. */
|
|
2453
|
-
function gitBranchExists(root, branch) {
|
|
2454
|
-
const probe = spawnSyncHidden("git", ["rev-parse", "--verify", "--quiet", `${branch}^{commit}`], { cwd: root, encoding: "utf8", shell: false });
|
|
2455
|
-
return !probe.error && probe.status === 0;
|
|
2456
|
-
}
|
|
2457
|
-
/** Normalize a declared path (absolute, repo-relative, or back-slashed) to a repo-relative forward-slash string. */
|
|
2458
|
-
function toRepoRelative(p, root) {
|
|
2459
|
-
const normalizedRoot = root.replace(/\\/g, "/").replace(/\/$/, "");
|
|
2460
|
-
let s = p.replace(/\\/g, "/");
|
|
2461
|
-
if (s.startsWith(normalizedRoot + "/")) {
|
|
2462
|
-
s = s.slice(normalizedRoot.length + 1);
|
|
2463
|
-
}
|
|
2464
|
-
return s;
|
|
2465
|
-
}
|
|
2466
|
-
/**
|
|
2467
|
-
* Files the worker edited (from git) that fall OUTSIDE the block's declared
|
|
2468
|
-
* write scope. Result-file artifacts and the agent-feedback file are excluded
|
|
2469
|
-
* (they are sanctioned side outputs, never source edits). Returns the offending
|
|
2470
|
-
* repo-relative paths (empty when the edits are fully within scope).
|
|
2471
|
-
*/
|
|
2472
|
-
export function writeScopeViolations(declaredWritePaths, editedFiles, root) {
|
|
2473
|
-
const declared = new Set(declaredWritePaths.map((p) => toRepoRelative(p, root)));
|
|
2474
|
-
const violations = [];
|
|
2475
|
-
for (const edited of editedFiles) {
|
|
2476
|
-
const rel = toRepoRelative(edited, root);
|
|
2477
|
-
if (declared.has(rel))
|
|
2478
|
-
continue;
|
|
2479
|
-
// Sanctioned non-source outputs: result JSON files and the reflection file.
|
|
2480
|
-
if (rel.endsWith(".result.json"))
|
|
2481
|
-
continue;
|
|
2482
|
-
if (rel.endsWith(AGENT_FEEDBACK_FILENAME))
|
|
2483
|
-
continue;
|
|
2484
|
-
violations.push(rel);
|
|
2485
|
-
}
|
|
2486
|
-
return violations;
|
|
2487
|
-
}
|
|
2488
|
-
/** Branch name a block's isolated worktree is created on (mirrors `worktreePath`). */
|
|
2489
|
-
export function worktreeBranchForBlock(blockId, runId) {
|
|
2490
|
-
return `remediate-${blockId}-${runId}`;
|
|
2491
|
-
}
|
|
2492
|
-
/**
|
|
2493
|
-
* The files a worker's worktree branch changed relative to HEAD — the ground
|
|
2494
|
-
* truth for write-scope enforcement. Diffs `HEAD...<branch>` (the branch's own
|
|
2495
|
-
* commits). Fail-closed / not-a-repo semantics mirror `gitEditedFiles`.
|
|
2496
|
-
*/
|
|
2497
|
-
export function gitEditedFilesForBranch(root, branch) {
|
|
2498
|
-
if (!isGitWorkTree(root)) {
|
|
2499
|
-
return { available: false, reason: "not_a_repo", error: "root is not a git work tree" };
|
|
2500
|
-
}
|
|
2501
|
-
const diff = spawnSyncHidden("git", ["diff", "--name-only", `HEAD...${branch}`], { cwd: root, encoding: "utf8", shell: false });
|
|
2502
|
-
if (diff.error || typeof diff.status !== "number" || diff.status !== 0) {
|
|
2503
|
-
const detail = (diff.stderr ?? diff.error?.message ?? "git diff failed").toString().trim();
|
|
2504
|
-
return { available: false, reason: "probe_failed", error: detail };
|
|
2505
|
-
}
|
|
2506
|
-
const files = new Set();
|
|
2507
|
-
for (const line of (diff.stdout ?? "").split(/\r?\n/)) {
|
|
2508
|
-
const p = line.trim();
|
|
2509
|
-
if (p.length > 0)
|
|
2510
|
-
files.add(p.replace(/\\/g, "/"));
|
|
2511
|
-
}
|
|
2512
|
-
return { available: true, files };
|
|
2513
|
-
}
|
|
2514
|
-
/**
|
|
2515
|
-
* Parse `git diff HEAD...<branch>` into per-hunk NEW-side line ranges — the
|
|
2516
|
-
* ground truth for whether two same-file edits actually touch disjoint regions.
|
|
2517
|
-
* Mirrors {@link gitEditedFilesForBranch}'s fail-closed / not-a-repo semantics:
|
|
2518
|
-
* never throws; on a non-repo / failed probe / malformed diff returns a
|
|
2519
|
-
* discriminated result marking hunks unavailable so the caller can fail closed.
|
|
2520
|
-
*
|
|
2521
|
-
* Paths are normalised to repo-relative forward-slash (the same scheme
|
|
2522
|
-
* {@link gitEditedFilesForBranch} emits) so hunk files compare like-for-like
|
|
2523
|
-
* with the file set.
|
|
2524
|
-
*/
|
|
2525
|
-
export function gitHunksForBranch(root, branch) {
|
|
2526
|
-
if (!isGitWorkTree(root)) {
|
|
2527
|
-
return { available: false, reason: "not_a_repo", error: "root is not a git work tree" };
|
|
2528
|
-
}
|
|
2529
|
-
const diff = spawnSyncHidden("git",
|
|
2530
|
-
// No rename detection / context noise beyond what we parse; a plain unified
|
|
2531
|
-
// diff carries the `+++ b/<path>` and `@@ … +start,count @@` headers we need.
|
|
2532
|
-
["diff", `HEAD...${branch}`], { cwd: root, encoding: "utf8", shell: false });
|
|
2533
|
-
if (diff.error || typeof diff.status !== "number" || diff.status !== 0) {
|
|
2534
|
-
const detail = (diff.stderr ?? diff.error?.message ?? "git diff failed").toString().trim();
|
|
2535
|
-
return { available: false, reason: "probe_failed", error: detail };
|
|
2536
|
-
}
|
|
2537
|
-
return parseUnifiedDiffHunks(diff.stdout ?? "");
|
|
2538
|
-
}
|
|
2539
|
-
/**
|
|
2540
|
-
* Parse a unified-diff body into NEW-side hunk ranges. Extracted for testability
|
|
2541
|
-
* (no git spawn). Recognises `+++ b/<path>` file headers and
|
|
2542
|
-
* `@@ -a,b +c,d @@` hunk headers; a header we cannot parse fails the whole probe
|
|
2543
|
-
* closed (returns `probe_failed`) rather than silently producing partial hunks
|
|
2544
|
-
* that would let a real overlap slip through.
|
|
2545
|
-
*/
|
|
2546
|
-
export function parseUnifiedDiffHunks(diffText) {
|
|
2547
|
-
const hunks = [];
|
|
2548
|
-
let currentFile = null;
|
|
2549
|
-
for (const rawLine of diffText.split(/\r?\n/)) {
|
|
2550
|
-
if (rawLine.startsWith("+++ ")) {
|
|
2551
|
-
// `+++ b/path` — or `+++ /dev/null` for a pure deletion (no new side).
|
|
2552
|
-
const target = rawLine.slice(4).trim();
|
|
2553
|
-
if (target === "/dev/null") {
|
|
2554
|
-
currentFile = null;
|
|
2555
|
-
continue;
|
|
2556
|
-
}
|
|
2557
|
-
// Strip the conventional `b/` prefix; leave already-bare paths intact.
|
|
2558
|
-
const bare = target.startsWith("b/") ? target.slice(2) : target;
|
|
2559
|
-
currentFile = bare.replace(/\\/g, "/");
|
|
2560
|
-
continue;
|
|
2561
|
-
}
|
|
2562
|
-
if (rawLine.startsWith("@@")) {
|
|
2563
|
-
const match = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/.exec(rawLine);
|
|
2564
|
-
if (!match) {
|
|
2565
|
-
return {
|
|
2566
|
-
available: false,
|
|
2567
|
-
reason: "probe_failed",
|
|
2568
|
-
error: `unparseable hunk header: ${rawLine}`,
|
|
2569
|
-
};
|
|
2570
|
-
}
|
|
2571
|
-
if (currentFile === null)
|
|
2572
|
-
continue; // deletion-only target: no new-side hunk.
|
|
2573
|
-
const startLine = Number(match[1]);
|
|
2574
|
-
const lineCount = match[2] === undefined ? 1 : Number(match[2]);
|
|
2575
|
-
hunks.push({ file: currentFile, startLine, lineCount });
|
|
2576
|
-
}
|
|
2577
|
-
}
|
|
2578
|
-
return { available: true, hunks };
|
|
2579
|
-
}
|
|
2580
|
-
/**
|
|
2581
|
-
* Pure write-scope gate decision (OBL-DS-06). Given the block's declared write
|
|
2582
|
-
* paths and the resolved git edit set:
|
|
2583
|
-
* - `not_a_repo` → no ground truth (no worktree workflow) → not blocked.
|
|
2584
|
-
* - `probe_failed` → git is a repo but the diff failed → FAIL CLOSED (blocked).
|
|
2585
|
-
* - available → block iff any edited file is outside declared scope.
|
|
2586
|
-
* The worker's self-reported `amended_files` is never an input here.
|
|
2587
|
-
*/
|
|
2588
|
-
export function enforceWriteScope(declaredWritePaths, edited, root) {
|
|
2589
|
-
if (!edited.available) {
|
|
2590
|
-
if (edited.reason === "not_a_repo") {
|
|
2591
|
-
return { blocked: false };
|
|
2592
|
-
}
|
|
2593
|
-
// probe_failed: git is present but could not be queried → fail closed.
|
|
2594
|
-
return {
|
|
2595
|
-
blocked: true,
|
|
2596
|
-
reason: `Write-scope could not be verified: git probe failed (${edited.error}). ` +
|
|
2597
|
-
`Failing closed rather than trusting self-reported edits.`,
|
|
2598
|
-
};
|
|
2599
|
-
}
|
|
2600
|
-
const violations = writeScopeViolations(declaredWritePaths, edited.files, root);
|
|
2601
|
-
if (violations.length === 0)
|
|
2602
|
-
return { blocked: false };
|
|
2603
|
-
return {
|
|
2604
|
-
blocked: true,
|
|
2605
|
-
reason: `Worker edited files outside its declared write scope: ${violations.join(", ")}. ` +
|
|
2606
|
-
`Declared scope must be amended through the seam protocol; the self-reported ` +
|
|
2607
|
-
`amended_files set is not trusted for this gate.`,
|
|
2608
|
-
};
|
|
2609
|
-
}
|
|
2610
|
-
/** Each block's declared write scope from a dispatch plan — the seed for the
|
|
2611
|
-
* accept-time write-scope gate's ownership registry (so an amended path owned by
|
|
2612
|
-
* a sibling block is recognised as a seam conflict, not silently granted). */
|
|
2613
|
-
export function blockScopesFromPlan(plan) {
|
|
2614
|
-
return plan.items.flatMap((item) => item.block_id && item.access
|
|
2615
|
-
? [{ block_id: item.block_id, write_paths: item.access.write_paths }]
|
|
2616
|
-
: []);
|
|
2617
|
-
}
|
|
2618
|
-
/**
|
|
2619
|
-
* A block's declared target paths (write ∪ read) from the persisted dispatch plan
|
|
2620
|
-
* — the single source of the scope the worker actually received (same authority
|
|
2621
|
-
* the accept-time write-scope gate reads). Used to seed untracked declared targets
|
|
2622
|
-
* into a fresh worktree (see {@link seedUntrackedDeclaredPaths}).
|
|
2623
|
-
*/
|
|
2624
|
-
export function declaredPathsFromPlan(plan, blockId) {
|
|
2625
|
-
const item = plan.items.find((i) => i.block_id === blockId);
|
|
2626
|
-
if (!item?.access)
|
|
2627
|
-
return [];
|
|
2628
|
-
return [...(item.access.write_paths ?? []), ...(item.access.read_paths ?? [])];
|
|
2629
|
-
}
|
|
2630
|
-
/**
|
|
2631
|
-
* Pure write-scope adjudication (OBL-DS-06) — git-free so it is unit-testable with
|
|
2632
|
-
* a synthetic edit set. Seeds an ephemeral `OwnershipRegistry` from `allBlockScopes`
|
|
2633
|
-
* (normalised to repo-relative so ownership compares like-for-like) and routes the
|
|
2634
|
-
* node's ACTUAL out-of-declared edits — the git ground truth, never a self-report:
|
|
2635
|
-
* - an edit to a file no sibling block owns is granted and widens this node's
|
|
2636
|
-
* effective scope (a too-narrow — or empty — declared scope no longer blocks a
|
|
2637
|
-
* correct fix; this is the sanctioned "extend into unowned files" path);
|
|
2638
|
-
* - an edit to a file in another block's declared scope is a seam conflict that
|
|
2639
|
-
* blocks until the seam protocol re-scopes or serialises the nodes.
|
|
2640
|
-
* Cross-sibling contention on a file two live nodes both touch (neither declared)
|
|
2641
|
-
* is left to the merge-time lost-update detector (`detectOverlappingEdits`), which
|
|
2642
|
-
* sees the full set of merged blocks a single accept cannot.
|
|
2643
|
-
*/
|
|
2644
|
-
export function adjudicateWriteScope(allBlockScopes, blockId, edited, root) {
|
|
2645
|
-
const registry = new OwnershipRegistry();
|
|
2646
|
-
registry.initialize(allBlockScopes.map((b) => ({
|
|
2647
|
-
node_id: b.block_id,
|
|
2648
|
-
write_paths: b.write_paths.map((p) => toRepoRelative(p, root)),
|
|
2649
|
-
})));
|
|
2650
|
-
if (edited.available) {
|
|
2651
|
-
// The node's real source edits outside its declared scope (sanctioned side
|
|
2652
|
-
// outputs already excluded by writeScopeViolations).
|
|
2653
|
-
const candidates = writeScopeViolations(registry.getScope(blockId), edited.files, root);
|
|
2654
|
-
if (candidates.length > 0) {
|
|
2655
|
-
const { seam_routed } = routeAmendmentRequest(registry, blockId, candidates);
|
|
2656
|
-
if (seam_routed.length > 0) {
|
|
2657
|
-
const detail = seam_routed
|
|
2658
|
-
.map((r) => {
|
|
2659
|
-
const reason = r.reason;
|
|
2660
|
-
if (reason.outcome === "owned")
|
|
2661
|
-
return `${r.path} owned by ${reason.owner_node_id}`;
|
|
2662
|
-
if (reason.outcome === "contended") {
|
|
2663
|
-
return `${r.path} contended by ${reason.sibling_node_id}`;
|
|
2664
|
-
}
|
|
2665
|
-
return r.path;
|
|
2666
|
-
})
|
|
2667
|
-
.join("; ");
|
|
2668
|
-
return {
|
|
2669
|
-
blocked: true,
|
|
2670
|
-
reason: `Node edited files owned by another block (seam conflict): ${detail}. ` +
|
|
2671
|
-
`Resolve via the seam protocol (re-scope contracts or serialise the nodes) before this node can land.`,
|
|
2672
|
-
};
|
|
2673
|
-
}
|
|
2674
|
-
// every candidate was unowned → granted into this node's effective scope.
|
|
2675
|
-
}
|
|
2676
|
-
}
|
|
2677
|
-
return enforceWriteScope(registry.getScope(blockId), edited, root);
|
|
2678
|
-
}
|
|
2679
|
-
/**
|
|
2680
|
-
* Accept-time write-scope gate, run from `acceptNodeWorktree` AFTER the verify and
|
|
2681
|
-
* BEFORE the cherry-pick so a violation PREVENTS the merge rather than being
|
|
2682
|
-
* reported once the edit already landed in main. Thin git wrapper around
|
|
2683
|
-
* {@link adjudicateWriteScope}: resolves the branch's actual edits and adjudicates.
|
|
2684
|
-
*/
|
|
2685
|
-
export function enforceAcceptWriteScope(params) {
|
|
2686
|
-
const { root, branch, blockId, allBlockScopes } = params;
|
|
2687
|
-
return adjudicateWriteScope(allBlockScopes, blockId, gitEditedFilesForBranch(root, branch), root);
|
|
2688
|
-
}
|
|
2689
|
-
// ---------------------------------------------------------------------------
|
|
2690
|
-
// Merge-seam: obligation-id → node remap + multi-entry collapse (tolerance)
|
|
2691
|
-
// ---------------------------------------------------------------------------
|
|
2692
|
-
/**
|
|
2693
|
-
* Build the map from a known obligation/node alias to the finding id that owns
|
|
2694
|
-
* it, for one block. A worker that mislabels its `finding_id` as an obligation
|
|
2695
|
-
* id it was assigned (or a CP-BLOCK-prefixed/unprefixed node alias) is remapped
|
|
2696
|
-
* to the owning node's finding rather than dropped as an orphan — the tolerant
|
|
2697
|
-
* seam (the host is a variable of any strength). The map only ever points at
|
|
2698
|
-
* findings that belong to THIS block, so a mislabel can never resolve to an
|
|
2699
|
-
* unrelated node.
|
|
2700
|
-
*/
|
|
2701
|
-
export function buildBlockAliasMap(block, state) {
|
|
2702
|
-
const aliasToFinding = new Map();
|
|
2703
|
-
for (const findingId of block.items) {
|
|
2704
|
-
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
2705
|
-
if (!finding)
|
|
2706
|
-
continue;
|
|
2707
|
-
// The node id itself, and its block-prefixed / unprefixed aliases.
|
|
2708
|
-
const register = (alias) => {
|
|
2709
|
-
if (!alias || alias === findingId)
|
|
2710
|
-
return;
|
|
2711
|
-
if (!aliasToFinding.has(alias))
|
|
2712
|
-
aliasToFinding.set(alias, findingId);
|
|
2713
|
-
};
|
|
2714
|
-
// CP-BLOCK- aliases are now resolved deterministically by the id registry in
|
|
2715
|
-
// `collapseItemResults` (S4); registering them here is defence-in-depth only.
|
|
2716
|
-
register(toBlockId(findingId));
|
|
2717
|
-
register(block.block_id);
|
|
2718
|
-
// The obligation ids the node satisfies/verifies — a worker may report one.
|
|
2719
|
-
for (const obl of [
|
|
2720
|
-
...(finding.contract_obligation_ids ?? []),
|
|
2721
|
-
...(finding.verification_obligation_ids ?? []),
|
|
2722
|
-
]) {
|
|
2723
|
-
register(obl);
|
|
2724
|
-
}
|
|
2725
|
-
}
|
|
2726
|
-
return aliasToFinding;
|
|
2727
|
-
}
|
|
2728
|
-
/**
|
|
2729
|
-
* Collapse a worker result's `item_results` to one entry per resolved finding
|
|
2730
|
-
* id, applying the block alias map first (obligation/node-alias → finding). When
|
|
2731
|
-
* several entries collapse onto the same finding, a single `blocked` entry wins
|
|
2732
|
-
* over `resolved` (a node is not complete if any reported facet failed), and the
|
|
2733
|
-
* union of evidence / first failure_reason is preserved. Entries whose id is
|
|
2734
|
-
* neither a known finding nor a known alias are returned in `unresolved` so the
|
|
2735
|
-
* caller can record them as orphans.
|
|
2736
|
-
*/
|
|
2737
|
-
export function collapseItemResults(itemResults, aliasMap, knownFindingIds) {
|
|
2738
|
-
const byFinding = new Map();
|
|
2739
|
-
const unresolved = [];
|
|
2740
|
-
for (const entry of itemResults) {
|
|
2741
|
-
let targetId = entry.finding_id;
|
|
2742
|
-
if (!knownFindingIds.has(targetId)) {
|
|
2743
|
-
// Registry-authoritative (S4): a CP-BLOCK- block id maps deterministically
|
|
2744
|
-
// to its bare node id via the id registry, so the common "worker reported
|
|
2745
|
-
// the block id" mislabel resolves here without the tolerant alias remap —
|
|
2746
|
-
// the remap is defence-in-depth for non-block aliases (e.g. a mislabelled
|
|
2747
|
-
// obligation id) only.
|
|
2748
|
-
const nodeId = fromBlockId(targetId);
|
|
2749
|
-
if (nodeId && knownFindingIds.has(nodeId)) {
|
|
2750
|
-
targetId = nodeId;
|
|
2751
|
-
}
|
|
2752
|
-
else {
|
|
2753
|
-
const remapped = aliasMap.get(targetId);
|
|
2754
|
-
if (remapped) {
|
|
2755
|
-
targetId = remapped;
|
|
2756
|
-
}
|
|
2757
|
-
else {
|
|
2758
|
-
unresolved.push(entry);
|
|
2759
|
-
continue;
|
|
2760
|
-
}
|
|
2761
|
-
}
|
|
2762
|
-
}
|
|
2763
|
-
const normalized = { ...entry, finding_id: targetId };
|
|
2764
|
-
const existing = byFinding.get(targetId);
|
|
2765
|
-
if (!existing) {
|
|
2766
|
-
byFinding.set(targetId, normalized);
|
|
2767
|
-
continue;
|
|
2768
|
-
}
|
|
2769
|
-
// Collapse precedence: blocked > needs_clarification > resolved >
|
|
2770
|
-
// resolved_no_change. A hard failure dominates an unanswered scoping question,
|
|
2771
|
-
// which dominates an actual change, which dominates a no-change claim (a
|
|
2772
|
-
// no-change claim only survives if every entry agreed nothing changed). Merge
|
|
2773
|
-
// evidence; keep first failure_reason / clarification question.
|
|
2774
|
-
const mergedEvidence = [
|
|
2775
|
-
...new Set([...(existing.evidence ?? []), ...(normalized.evidence ?? [])]),
|
|
2776
|
-
];
|
|
2777
|
-
const status = existing.status === "blocked" || normalized.status === "blocked"
|
|
2778
|
-
? "blocked"
|
|
2779
|
-
: existing.status === "needs_clarification" || normalized.status === "needs_clarification"
|
|
2780
|
-
? "needs_clarification"
|
|
2781
|
-
: existing.status === "resolved" || normalized.status === "resolved"
|
|
2782
|
-
? "resolved"
|
|
2783
|
-
: "resolved_no_change";
|
|
2784
|
-
byFinding.set(targetId, {
|
|
2785
|
-
finding_id: targetId,
|
|
2786
|
-
status,
|
|
2787
|
-
evidence: mergedEvidence.length > 0 ? mergedEvidence : undefined,
|
|
2788
|
-
failure_reason: existing.failure_reason ?? normalized.failure_reason,
|
|
2789
|
-
clarification_question: existing.clarification_question ?? normalized.clarification_question,
|
|
2790
|
-
clarification_category: existing.clarification_category ?? normalized.clarification_category,
|
|
2791
|
-
});
|
|
2792
|
-
}
|
|
2793
|
-
return { collapsed: [...byFinding.values()], unresolved };
|
|
2794
|
-
}
|
|
2795
|
-
/**
|
|
2796
|
-
* Build the per-node disposition for a block (INV-DS-15). A SKIP disposition
|
|
2797
|
-
* (user-skipped: `ignored` / `deemed_inappropriate`) is NEVER reported as
|
|
2798
|
-
* `verified_complete`. Each block maps 1:1 to a node, so the disposition keys on
|
|
2799
|
-
* the block's first finding (the node id).
|
|
2800
|
-
*/
|
|
2801
|
-
export function buildNodeDisposition(block, state) {
|
|
2802
|
-
const nodeId = block.items[0] ?? block.block_id;
|
|
2803
|
-
const finding = state.plan?.findings.find((f) => f.id === nodeId);
|
|
2804
|
-
// Resolve the block's overall status from its items.
|
|
2805
|
-
const statuses = block.items.map((id) => state.items?.[id]?.status ?? "pending");
|
|
2806
|
-
const isSkip = statuses.some((s) => isSkipStatus(s));
|
|
2807
|
-
const allResolved = statuses.length > 0 && statuses.every((s) => isVerifiedCompleteStatus(s));
|
|
2808
|
-
const anyBlocked = statuses.some((s) => s === "blocked");
|
|
2809
|
-
let disposition;
|
|
2810
|
-
if (isSkip) {
|
|
2811
|
-
// INV-DS-15: a skipped node is never verified_complete.
|
|
2812
|
-
disposition = "skipped";
|
|
2813
|
-
}
|
|
2814
|
-
else if (anyBlocked) {
|
|
2815
|
-
disposition = "blocked";
|
|
2816
|
-
}
|
|
2817
|
-
else if (allResolved) {
|
|
2818
|
-
disposition = "verified_complete";
|
|
2819
|
-
}
|
|
2820
|
-
else {
|
|
2821
|
-
disposition = "missing_result";
|
|
2822
|
-
}
|
|
2823
|
-
const reason = block.items
|
|
2824
|
-
.map((id) => state.items?.[id]?.failure_reason)
|
|
2825
|
-
.find((r) => typeof r === "string" && r.length > 0);
|
|
2826
|
-
return {
|
|
2827
|
-
node_id: nodeId,
|
|
2828
|
-
block_id: block.block_id,
|
|
2829
|
-
disposition,
|
|
2830
|
-
finding_status: statuses.join(","),
|
|
2831
|
-
reconciliation_expectations: finding ? reconciliationExpectationsOf(finding) : [],
|
|
2832
|
-
reason,
|
|
2833
|
-
};
|
|
2834
|
-
}
|
|
2835
|
-
/**
|
|
2836
|
-
* Attribute a post-merge sibling-block failure (INV-DS-14). Given the repo-
|
|
2837
|
-
* relative paths implicated by a red sibling and the merged blocks' declared
|
|
2838
|
-
* write scopes, return the exactly-one block whose scope contains an implicated
|
|
2839
|
-
* file (attributable → route THAT sibling to triage). When zero or more than one
|
|
2840
|
-
* merged block could own the failure, the red is unattributable and is deferred
|
|
2841
|
-
* to the rolling-scheduler's coarse backstop (return null).
|
|
2842
|
-
*/
|
|
2843
|
-
export function attributeSiblingRed(implicatedFiles, mergedBlockScopes, root) {
|
|
2844
|
-
const implicated = new Set(implicatedFiles.map((p) => toRepoRelative(p, root)));
|
|
2845
|
-
const owners = new Set();
|
|
2846
|
-
for (const { block_id, write_paths } of mergedBlockScopes) {
|
|
2847
|
-
for (const wp of write_paths) {
|
|
2848
|
-
if (implicated.has(toRepoRelative(wp, root))) {
|
|
2849
|
-
owners.add(block_id);
|
|
2850
|
-
break;
|
|
2851
|
-
}
|
|
2852
|
-
}
|
|
2853
|
-
}
|
|
2854
|
-
// Attributable only when a single merged block owns the implicated surface.
|
|
2855
|
-
return owners.size === 1 ? [...owners][0] : null;
|
|
2856
|
-
}
|
|
2857
|
-
/**
|
|
2858
|
-
* Detect lost-update hazards across concurrently-merged blocks (ARC-f378135d-2 /
|
|
2859
|
-
* ARC-c1693139). When the rolling engine dispatches multiple nodes in flight and
|
|
2860
|
-
* each worker edits in its own worktree, two workers can both modify the SAME
|
|
2861
|
-
* file; cherry-picking both branches silently drops one worker's change to that
|
|
2862
|
-
* file (lost update). This pure function returns every repo-relative path that
|
|
2863
|
-
* appears in more than one merged block's ACTUAL edit set, with the owning block
|
|
2864
|
-
* ids. The caller routes the involved blocks to triage so the conflict is
|
|
2865
|
-
* reconciled rather than silently losing an edit. Result-file artifacts and the
|
|
2866
|
-
* agent-feedback file are sanctioned side outputs and are never counted as
|
|
2867
|
-
* overlaps.
|
|
2868
|
-
*/
|
|
2869
|
-
export function detectOverlappingEdits(editedByBlock) {
|
|
2870
|
-
const isSanctionedSideOutput = (rel) => rel.endsWith(".result.json") || rel.endsWith(AGENT_FEEDBACK_FILENAME);
|
|
2871
|
-
// path → block_id → the block's hunks for that path (undefined when hunk info
|
|
2872
|
-
// is unavailable for the block, which forces the conservative fail-closed path).
|
|
2873
|
-
const pathToBlockHunks = new Map();
|
|
2874
|
-
for (const { block_id, files, hunks } of editedByBlock) {
|
|
2875
|
-
for (const file of files) {
|
|
2876
|
-
const rel = file.replace(/\\/g, "/");
|
|
2877
|
-
// Sanctioned non-source outputs never constitute a lost-update conflict.
|
|
2878
|
-
if (isSanctionedSideOutput(rel))
|
|
2879
|
-
continue;
|
|
2880
|
-
let byBlock = pathToBlockHunks.get(rel);
|
|
2881
|
-
if (!byBlock) {
|
|
2882
|
-
byBlock = new Map();
|
|
2883
|
-
pathToBlockHunks.set(rel, byBlock);
|
|
2884
|
-
}
|
|
2885
|
-
// Hunk info is only usable when git resolved it AND this block edited this
|
|
2886
|
-
// file per its hunk set. `available:false` (or an absent map) → undefined,
|
|
2887
|
-
// which detectOverlap treats as "cannot prove disjoint" → collision stands.
|
|
2888
|
-
const fileHunks = hunks?.available === true
|
|
2889
|
-
? hunks.hunks.filter((h) => h.file === rel)
|
|
2890
|
-
: undefined;
|
|
2891
|
-
byBlock.set(block_id, fileHunks);
|
|
2892
|
-
}
|
|
2893
|
-
}
|
|
2894
|
-
const overlaps = [];
|
|
2895
|
-
for (const [path, byBlock] of pathToBlockHunks) {
|
|
2896
|
-
if (byBlock.size <= 1)
|
|
2897
|
-
continue;
|
|
2898
|
-
const owners = [...byBlock.keys()].sort();
|
|
2899
|
-
// A file is a lost-update hazard iff SOME pair of owning blocks has edits we
|
|
2900
|
-
// cannot prove disjoint. If EVERY pair's hunks are known and disjoint, the
|
|
2901
|
-
// cherry-picks compose cleanly → not a collision. Any pair with unavailable
|
|
2902
|
-
// hunks fails closed (treated as overlapping).
|
|
2903
|
-
let collides = false;
|
|
2904
|
-
outer: for (let i = 0; i < owners.length; i++) {
|
|
2905
|
-
for (let j = i + 1; j < owners.length; j++) {
|
|
2906
|
-
const a = byBlock.get(owners[i]);
|
|
2907
|
-
const b = byBlock.get(owners[j]);
|
|
2908
|
-
if (a === undefined || b === undefined) {
|
|
2909
|
-
collides = true; // fail closed: no proof of disjointness.
|
|
2910
|
-
break outer;
|
|
2911
|
-
}
|
|
2912
|
-
if (hunkRangesOverlap(a, b)) {
|
|
2913
|
-
collides = true;
|
|
2914
|
-
break outer;
|
|
2915
|
-
}
|
|
2916
|
-
}
|
|
2917
|
-
}
|
|
2918
|
-
if (collides) {
|
|
2919
|
-
overlaps.push({ path, block_ids: owners });
|
|
2920
|
-
}
|
|
2921
|
-
}
|
|
2922
|
-
// Deterministic ordering so the diagnostic + tests are stable.
|
|
2923
|
-
return overlaps.sort((a, b) => a.path.localeCompare(b.path));
|
|
2924
|
-
}
|
|
2925
|
-
/**
|
|
2926
|
-
* True when any hunk in `a` shares a new-side line with any hunk in `b`. Each
|
|
2927
|
-
* hunk spans `[startLine, startLine + lineCount - 1]`; a zero-line hunk (pure
|
|
2928
|
-
* deletion at `startLine`) is treated as touching the single anchor line
|
|
2929
|
-
* `startLine` so a deletion adjacent to another block's insertion is still a
|
|
2930
|
-
* conflict. Purely non-overlapping ranges (e.g. lines 1–5 vs 40–50) return
|
|
2931
|
-
* false → the two edits are disjoint and compose cleanly.
|
|
2932
|
-
*/
|
|
2933
|
-
function hunkRangesOverlap(a, b) {
|
|
2934
|
-
const span = (h) => {
|
|
2935
|
-
const count = h.lineCount > 0 ? h.lineCount : 1;
|
|
2936
|
-
return [h.startLine, h.startLine + count - 1];
|
|
2937
|
-
};
|
|
2938
|
-
for (const ha of a) {
|
|
2939
|
-
const [aStart, aEnd] = span(ha);
|
|
2940
|
-
for (const hb of b) {
|
|
2941
|
-
const [bStart, bEnd] = span(hb);
|
|
2942
|
-
if (aStart <= bEnd && bStart <= aEnd)
|
|
2943
|
-
return true;
|
|
2944
|
-
}
|
|
2945
|
-
}
|
|
2946
|
-
return false;
|
|
2947
|
-
}
|
|
2948
|
-
/**
|
|
2949
|
-
* The contract-pipeline obligation ids a finding covers — the union of its
|
|
2950
|
-
* `contract_obligation_ids` (satisfied) and `verification_obligation_ids`
|
|
2951
|
-
* (verified). Empty for audit-findings intake (no contract overlay), so the DC-5
|
|
2952
|
-
* verify gate is inert there.
|
|
2953
|
-
*/
|
|
2954
|
-
function obligationIdsForFinding(state, findingId) {
|
|
2955
|
-
const finding = state.plan?.findings.find((f) => f.id === findingId);
|
|
2956
|
-
if (!finding)
|
|
2957
|
-
return [];
|
|
2958
|
-
return [
|
|
2959
|
-
...(finding.contract_obligation_ids ?? []),
|
|
2960
|
-
...(finding.verification_obligation_ids ?? []),
|
|
2961
|
-
];
|
|
2962
|
-
}
|
|
2963
|
-
/**
|
|
2964
|
-
* Reconcile (free) the reservation-ledger leases the dispatch grant took for this
|
|
2965
|
-
* run's granted set — the "reconcile at result-ingest" half of admission control
|
|
2966
|
-
* (spec/audit/dispatch-admission-control.md), the remediate analog of audit's
|
|
2967
|
-
* `reconcileAdmissionLeases`. The host has now reported the granted set's results, so
|
|
2968
|
-
* those reservations are no longer in flight and their budget returns to the shared
|
|
2969
|
-
* account for the NEXT grant. Best-effort + token-checked (a missing/already-freed
|
|
2970
|
-
* lease is a no-op), so a lost reconcile self-heals via the lease TTL and never blocks
|
|
2971
|
-
* the merge. Only the host-subagent grant persists leases (`grantLeases: true`); the
|
|
2972
|
-
* in-process path leases per-packet in the engine and reconciles there.
|
|
2973
|
-
*/
|
|
2974
|
-
async function reconcileAdmissionLeases(artifactsDir, runId) {
|
|
2975
|
-
const quotaPath = join(runDir(artifactsDir, runId, "implement"), "dispatch-quota.json");
|
|
2976
|
-
const quota = await readOptionalJsonFile(quotaPath);
|
|
2977
|
-
const leases = quota?.admission?.leases;
|
|
2978
|
-
if (!leases || leases.length === 0)
|
|
2979
|
-
return;
|
|
2980
|
-
const ledger = createReservationLedger();
|
|
2981
|
-
for (const lease of leases) {
|
|
2982
|
-
try {
|
|
2983
|
-
await ledger.reconcile(lease.resource_key, lease.lease_id);
|
|
2984
|
-
}
|
|
2985
|
-
catch {
|
|
2986
|
-
// Best-effort: the lease TTL reclaims budget if a reconcile is lost.
|
|
2987
|
-
}
|
|
2988
|
-
}
|
|
2989
|
-
}
|
|
2990
|
-
export async function mergeImplementResults(options, runId) {
|
|
2991
|
-
// Free the grant's reservation-ledger leases now that the host has reported the
|
|
2992
|
-
// granted set's results — returns the reserved budget for the next grant.
|
|
2993
|
-
await reconcileAdmissionLeases(options.artifactsDir, runId);
|
|
2994
|
-
const plan = await readJsonFile(dispatchPlanPath(options.artifactsDir, runId, "implement"));
|
|
2995
|
-
if (plan.contract_version !== REMEDIATION_DISPATCH_PLAN_CONTRACT_VERSION ||
|
|
2996
|
-
plan.phase !== "implement") {
|
|
2997
|
-
throw new Error("Implement dispatch plan has an unsupported contract.");
|
|
2998
|
-
}
|
|
2999
|
-
const store = new StateStore(options.artifactsDir);
|
|
3000
|
-
// OBL-INV-RSD-02 / OBL-SEAM-RSD-04: the entire read-modify-write of state.json
|
|
3001
|
-
// is performed under a single held lock via StateStore.mutate, and committed
|
|
3002
|
-
// exactly once after the full item loop. No partial state.json write happens
|
|
3003
|
-
// mid-loop — a malformed/unknown finding_id no longer leaves a half-applied
|
|
3004
|
-
// state (and never throws past the loop; see OBL-INV-RSD-01 below). Evidence
|
|
3005
|
-
// artifacts (result_<id>_verify_code_against_documentation.json, the orphan
|
|
3006
|
-
// diagnostic) are separate sidecar files, not state.json, so writing them
|
|
3007
|
-
// inside the loop does not violate the single-state-commit invariant.
|
|
3008
|
-
return store.mutate(async (loaded) => {
|
|
3009
|
-
if (!loaded) {
|
|
3010
|
-
throw new Error(`No remediation state found at ${join(options.artifactsDir, "state.json")}.`);
|
|
3011
|
-
}
|
|
3012
|
-
const state = loaded;
|
|
3013
|
-
if (!state.items) {
|
|
3014
|
-
throw new Error("Cannot merge implement results without items.");
|
|
3015
|
-
}
|
|
3016
|
-
return mergeImplementResultsIntoState(options, runId, plan, state);
|
|
3017
|
-
});
|
|
3018
|
-
}
|
|
3019
|
-
/**
|
|
3020
|
-
* Apply every dispatched implement worker result to `state` (mutated in place)
|
|
3021
|
-
* and return it. Runs inside the StateStore.mutate lock so the caller commits
|
|
3022
|
-
* the result exactly once (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). Pure with respect
|
|
3023
|
-
* to state.json: it mutates the in-memory `state` and writes only sidecar
|
|
3024
|
-
* evidence/diagnostic artifacts.
|
|
3025
|
-
*/
|
|
3026
|
-
async function mergeImplementResultsIntoState(options, runId, plan, state) {
|
|
3027
|
-
if (!state.items) {
|
|
3028
|
-
throw new Error("Cannot merge implement results without items.");
|
|
3029
|
-
}
|
|
3030
|
-
const dir = runDir(options.artifactsDir, runId, "implement");
|
|
3031
|
-
// Piece D — quota-paused strand set: block_ids stranded by a `quota_paused`
|
|
3032
|
-
// partial-completion terminal (their worker rate-limited on a host session
|
|
3033
|
-
// limit). Their result files are legitimately absent, but they are a RETRYABLE
|
|
3034
|
-
// pause, NOT a failure — a later step redispatches them clean (worktrees were
|
|
3035
|
-
// preserved). The merge must therefore LEAVE their items pending instead of
|
|
3036
|
-
// marking them blocked on the missing result. Only the quota_paused reason is
|
|
3037
|
-
// treated this way; `empty_pool` nodes are genuine failures and block as before.
|
|
3038
|
-
const quotaPausedStrandedBlocks = state.partial_completion_terminal?.reason === "quota_paused"
|
|
3039
|
-
? new Set(state.partial_completion_terminal.stranded_ids)
|
|
3040
|
-
: new Set();
|
|
3041
|
-
// DC-5 verify gate: load the obligation_ledger + test_validator_plan once so a
|
|
3042
|
-
// resolved finding that covers a behavior-CHANGE obligation can be re-blocked
|
|
3043
|
-
// when its test specs are only one polarity (a positive without a scoped
|
|
3044
|
-
// negative, or a negative-only set). Absent for non-contract-pipeline runs
|
|
3045
|
-
// (audit-findings intake), where the gate is inert. Read defensively: the
|
|
3046
|
-
// payloads are the validated artifact bodies, or `undefined` when missing.
|
|
3047
|
-
const obligationLedgerPayload = (await readContractArtifact(options.artifactsDir, "obligation_ledger"))?.payload;
|
|
3048
|
-
const testValidatorPlanPayload = (await readContractArtifact(options.artifactsDir, "test_validator_plan"))?.payload;
|
|
3049
|
-
const plannedBlockIds = new Set(plan.items.map((item) => item.block_id).filter((id) => typeof id === "string"));
|
|
3050
|
-
const itemsToMerge = [...plan.items];
|
|
3051
|
-
for (const block of state.plan?.blocks ?? []) {
|
|
3052
|
-
if (plannedBlockIds.has(block.block_id)) {
|
|
3053
|
-
continue;
|
|
3054
|
-
}
|
|
3055
|
-
const hasDocumentedWork = block.items.some((findingId) => {
|
|
3056
|
-
const stateItem = state.items?.[findingId];
|
|
3057
|
-
return stateItem?.status === "pending";
|
|
3058
|
-
});
|
|
3059
|
-
if (!hasDocumentedWork) {
|
|
3060
|
-
continue;
|
|
3061
|
-
}
|
|
3062
|
-
const item = buildImplementDispatchItem(block, state, dir);
|
|
3063
|
-
const existingResult = await tryLoadExistingImplementResult(item.result_path);
|
|
3064
|
-
const pendingFindingIds = pendingOrDocumentedFindingIdsForBlock(block, state);
|
|
3065
|
-
if (!existingResult ||
|
|
3066
|
-
!implementResultCoversFindings(existingResult, pendingFindingIds, block, state)) {
|
|
3067
|
-
continue;
|
|
3068
|
-
}
|
|
3069
|
-
itemsToMerge.push(item);
|
|
3070
|
-
}
|
|
3071
|
-
// Build a lightweight ownership registry seeded from each block's declared
|
|
3072
|
-
// write_paths so amended_files checks are correct even when no rolling-dispatch
|
|
3073
|
-
// registry was persisted (interim path, until rollingDispatch replaces this).
|
|
3074
|
-
const mergeRegistry = new OwnershipRegistry();
|
|
3075
|
-
const dagNodes = itemsToMerge.flatMap((item) => {
|
|
3076
|
-
if (!item.block_id || !item.access)
|
|
3077
|
-
return [];
|
|
3078
|
-
return [{ node_id: item.block_id, write_paths: item.access.write_paths }];
|
|
3079
|
-
});
|
|
3080
|
-
mergeRegistry.initialize(dagNodes);
|
|
3081
|
-
// OBL-INV-RSD-01: a worker result whose finding_id is not in state.items is
|
|
3082
|
-
// never silently dropped and never throws past the loop. Each such id is
|
|
3083
|
-
// recorded here; if it belongs to a known block (via the result's owning
|
|
3084
|
-
// task block_id) that block's non-terminal items are blocked, otherwise it is
|
|
3085
|
-
// a true orphan recorded in the diagnostic artifact below. Either way the run
|
|
3086
|
-
// cannot advance past an unaccounted result.
|
|
3087
|
-
const orphanResults = [];
|
|
3088
|
-
// Per-block ACTUAL edited file sets (resolved from each block's worktree
|
|
3089
|
-
// branch), collected for post-loop lost-update / overlapping-edit detection
|
|
3090
|
-
// (ARC-f378135d-2 / ARC-c1693139). Only blocks dispatched through an isolated
|
|
3091
|
-
// worktree (their branch exists) contribute; the interim main-tree path has no
|
|
3092
|
-
// per-worker diff to attribute, so it cannot be checked for cross-block overlap.
|
|
3093
|
-
const editedByBlock = [];
|
|
3094
|
-
for (const item of itemsToMerge) {
|
|
3095
|
-
if (!existsSync(item.result_path)) {
|
|
3096
|
-
// Piece D: a node stranded by a quota_paused terminal has no result file
|
|
3097
|
-
// because its worker paused on a host session limit — leave its items
|
|
3098
|
-
// PENDING so a later step (after the reset) redispatches them clean; never
|
|
3099
|
-
// mark them blocked here.
|
|
3100
|
-
if (item.block_id && quotaPausedStrandedBlocks.has(item.block_id)) {
|
|
3101
|
-
continue;
|
|
3102
|
-
}
|
|
3103
|
-
console.warn(`Missing implement worker result: ${item.result_path} — marking items blocked.`);
|
|
3104
|
-
const block = item.block_id
|
|
3105
|
-
? state.plan?.blocks.find((b) => b.block_id === item.block_id)
|
|
3106
|
-
: undefined;
|
|
3107
|
-
for (const findingId of block?.items ?? []) {
|
|
3108
|
-
const stateItem = state.items[findingId];
|
|
3109
|
-
// Don't flip a terminal item (resolved, or user-skipped
|
|
3110
|
-
// deemed_inappropriate/ignored) to blocked — only items that were
|
|
3111
|
-
// actually awaiting this worker's result.
|
|
3112
|
-
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
3113
|
-
continue;
|
|
3114
|
-
stateItem.status = "blocked";
|
|
3115
|
-
markTerminal(stateItem);
|
|
3116
|
-
stateItem.failure_reason =
|
|
3117
|
-
`Implementation worker did not produce a result file: ${item.result_path}`;
|
|
3118
|
-
}
|
|
3119
|
-
continue;
|
|
3120
|
-
}
|
|
3121
|
-
const result = await readJsonFile(item.result_path);
|
|
3122
|
-
assertImplementWorkerResult(result, item.result_path);
|
|
3123
|
-
// Gate amended_files through the ownership registry (N-R22).
|
|
3124
|
-
// Unowned amended paths are granted and added to this block's effective scope
|
|
3125
|
-
// for verification; owned/contended paths block the item with a seam conflict.
|
|
3126
|
-
const blockId = item.block_id ?? "";
|
|
3127
|
-
if (result.amended_files && result.amended_files.length > 0) {
|
|
3128
|
-
const { granted, seam_routed } = routeAmendmentRequest(mergeRegistry, blockId, result.amended_files);
|
|
3129
|
-
if (granted.length > 0 && item.access) {
|
|
3130
|
-
// Expand the block's effective write scope for downstream verification.
|
|
3131
|
-
item.access.write_paths = uniquePaths([...item.access.write_paths, ...granted]);
|
|
3132
|
-
}
|
|
3133
|
-
if (seam_routed.length > 0) {
|
|
3134
|
-
// Mark all non-terminal items in this block as blocked with seam conflict detail.
|
|
3135
|
-
const block = state.plan?.blocks.find((b) => b.block_id === blockId);
|
|
3136
|
-
for (const findingId of block?.items ?? []) {
|
|
3137
|
-
const stateItem = state.items[findingId];
|
|
3138
|
-
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
3139
|
-
continue;
|
|
3140
|
-
stateItem.status = "blocked";
|
|
3141
|
-
markTerminal(stateItem);
|
|
3142
|
-
stateItem.failure_reason =
|
|
3143
|
-
`Seam conflict on amended_files: ${seam_routed
|
|
3144
|
-
.map((r) => {
|
|
3145
|
-
const reason = r.reason;
|
|
3146
|
-
if (reason.outcome === "owned") {
|
|
3147
|
-
return `${r.path} owned by ${reason.owner_node_id}`;
|
|
3148
|
-
}
|
|
3149
|
-
else if (reason.outcome === "contended") {
|
|
3150
|
-
return `${r.path} contended by ${reason.sibling_node_id}`;
|
|
3151
|
-
}
|
|
3152
|
-
return r.path;
|
|
3153
|
-
})
|
|
3154
|
-
.join("; ")}`;
|
|
3155
|
-
}
|
|
3156
|
-
// Release any grants we just made before moving on (best-effort cleanup).
|
|
3157
|
-
mergeRegistry.releaseAmendments(blockId);
|
|
3158
|
-
continue;
|
|
3159
|
-
}
|
|
3160
|
-
}
|
|
3161
|
-
// Tolerant seam: remap an obligation/node-alias finding_id to the owning
|
|
3162
|
-
// node's finding, and collapse multi-entry results onto one entry per
|
|
3163
|
-
// finding (blocked dominates), before applying any status. A mislabel can
|
|
3164
|
-
// only ever resolve to a finding that belongs to THIS block.
|
|
3165
|
-
const owningBlock = blockId
|
|
3166
|
-
? state.plan?.blocks.find((b) => b.block_id === blockId)
|
|
3167
|
-
: undefined;
|
|
3168
|
-
const aliasMap = owningBlock
|
|
3169
|
-
? buildBlockAliasMap(owningBlock, state)
|
|
3170
|
-
: new Map();
|
|
3171
|
-
const knownFindingIds = new Set(Object.keys(state.items));
|
|
3172
|
-
const { collapsed, unresolved } = collapseItemResults(result.item_results, aliasMap, knownFindingIds);
|
|
3173
|
-
// Track which findings in this block this worker flipped to a resolved
|
|
3174
|
-
// status, so the write-scope gate below can re-block them if the worker's
|
|
3175
|
-
// ACTUAL git edits fall outside the declared scope.
|
|
3176
|
-
const resolvedFindingIds = [];
|
|
3177
|
-
// The recorded per-node accept outcome is the ground truth (never the worker's
|
|
3178
|
-
// result file). Absent on the interim main-tree path (which writes none) → the
|
|
3179
|
-
// gates below stay inert there. A HARD accept failure (outcome=error|timeout with
|
|
3180
|
-
// merged=false) means the node's committed edits were QUARANTINED and are NOT in the
|
|
3181
|
-
// main tree; the worker's own status is then untrustworthy (proven 2026-07-03: a
|
|
3182
|
-
// node whose accept failed on a dirty-main-tree collision reported resolved_no_change
|
|
3183
|
-
// and silently stranded, because the resolvedFindingIds gate below only re-blocks
|
|
3184
|
-
// actual-change `resolved` items). When hard-failed, the collapsed loop's resolve
|
|
3185
|
-
// branch blocks the item outright so no dependent builds on missing code.
|
|
3186
|
-
const acceptOutcome = await loadNodeAcceptOutcome(options.artifactsDir, runId, blockId);
|
|
3187
|
-
const acceptHardFailed = !!acceptOutcome &&
|
|
3188
|
-
!acceptOutcome.merged &&
|
|
3189
|
-
(acceptOutcome.outcome === "error" || acceptOutcome.outcome === "timeout");
|
|
3190
|
-
// M-FRICTION (node_quarantine): a node that committed real edits but hard-failed
|
|
3191
|
-
// the tool's verify/scope/merge had its work QUARANTINED under a durable ref and
|
|
3192
|
-
// NOT landed — a backend-observed step-boundary fact the per-category friction walk
|
|
3193
|
-
// must account for (recovery is a `reverify-node` re-drive). A hard-fail carries a
|
|
3194
|
-
// captured `diagnostic` ONLY on the quarantine paths; a plain worker error/timeout
|
|
3195
|
-
// that never committed sets none, so guarding on it excludes the non-quarantine
|
|
3196
|
-
// failures. Routed through the single CE-005 chokepoint keyed on the node id (one
|
|
3197
|
-
// event per node, deduped across its findings). Best-effort / non-fatal.
|
|
3198
|
-
if (acceptHardFailed && acceptOutcome.diagnostic) {
|
|
3199
|
-
await captureStepBoundaryFriction(options.artifactsDir, runId, {
|
|
3200
|
-
eventType: "node_quarantine",
|
|
3201
|
-
discriminator: blockId,
|
|
3202
|
-
note: `Node ${blockId} committed edits but hard-failed the tool's verify/scope/merge ` +
|
|
3203
|
-
`(outcome=${acceptOutcome.outcome}); work quarantined and NOT landed — re-drive ` +
|
|
3204
|
-
`with \`remediate-code reverify-node --id ${blockId} --run-id ${runId}\` once the ` +
|
|
3205
|
-
`cause is fixed.`,
|
|
3206
|
-
category: "bug",
|
|
3207
|
-
}, "remediate-code");
|
|
3208
|
-
}
|
|
3209
|
-
for (const itemResult of unresolved) {
|
|
3210
|
-
// OBL-INV-RSD-01: do NOT throw on an unknown finding_id that did not remap
|
|
3211
|
-
// to a known node alias. Block the owning block's non-terminal items so the
|
|
3212
|
-
// run cannot advance past an unaccounted result; record a diagnostic.
|
|
3213
|
-
if (owningBlock) {
|
|
3214
|
-
for (const findingId of owningBlock.items) {
|
|
3215
|
-
const owningItem = state.items[findingId];
|
|
3216
|
-
if (!owningItem || isTerminalStatus(owningItem.status))
|
|
3217
|
-
continue;
|
|
3218
|
-
owningItem.status = "blocked";
|
|
3219
|
-
markTerminal(owningItem);
|
|
3220
|
-
owningItem.failure_reason =
|
|
3221
|
-
`Implementation worker for block ${blockId} reported an unknown ` +
|
|
3222
|
-
`finding_id "${itemResult.finding_id}" not present in this plan ` +
|
|
3223
|
-
`(and not a known obligation/node alias of this block); blocking the ` +
|
|
3224
|
-
`block's items so the run does not advance past an unaccounted result.`;
|
|
3225
|
-
}
|
|
3226
|
-
orphanResults.push({
|
|
3227
|
-
finding_id: itemResult.finding_id,
|
|
3228
|
-
result_path: item.result_path,
|
|
3229
|
-
owning_block_id: blockId,
|
|
3230
|
-
disposition: "blocked_owning_block",
|
|
3231
|
-
worker_status: itemResult.status,
|
|
3232
|
-
});
|
|
3233
|
-
}
|
|
3234
|
-
else {
|
|
3235
|
-
orphanResults.push({
|
|
3236
|
-
finding_id: itemResult.finding_id,
|
|
3237
|
-
result_path: item.result_path,
|
|
3238
|
-
owning_block_id: null,
|
|
3239
|
-
disposition: "orphan",
|
|
3240
|
-
worker_status: itemResult.status,
|
|
3241
|
-
});
|
|
3242
|
-
}
|
|
3243
|
-
}
|
|
3244
|
-
for (const itemResult of collapsed) {
|
|
3245
|
-
const stateItem = state.items[itemResult.finding_id];
|
|
3246
|
-
if (!stateItem)
|
|
3247
|
-
continue;
|
|
3248
|
-
// A worker may report a finding that is already terminal (user-skipped, or
|
|
3249
|
-
// resolved in a prior wave) — never let a result resurrect or overwrite it.
|
|
3250
|
-
if (isTerminalStatus(stateItem.status)) {
|
|
3251
|
-
continue;
|
|
3252
|
-
}
|
|
3253
|
-
if (itemResult.status === "resolved" || itemResult.status === "resolved_no_change") {
|
|
3254
|
-
if (acceptHardFailed) {
|
|
3255
|
-
// The tool-owned accept hard-failed (quarantined, not in the main tree), so
|
|
3256
|
-
// this worker's resolved/resolved_no_change claim can't be trusted — block it
|
|
3257
|
-
// regardless of label so a mislabeled no-change can't strand and no dependent
|
|
3258
|
-
// builds on missing code. Routed to triage with the failing output.
|
|
3259
|
-
stateItem.status = "blocked";
|
|
3260
|
-
markTerminal(stateItem);
|
|
3261
|
-
stateItem.failure_reason =
|
|
3262
|
-
`Node ${blockId} reported finding ${itemResult.finding_id} ` +
|
|
3263
|
-
`${itemResult.status}, but its tool-owned accept failed ` +
|
|
3264
|
-
`(outcome=${acceptOutcome.outcome}, merged=false); the edits were quarantined ` +
|
|
3265
|
-
`and are NOT in the main tree. Routed to triage so dependents never build on ` +
|
|
3266
|
-
`missing code.` +
|
|
3267
|
-
(acceptOutcome.diagnostic
|
|
3268
|
-
? `\nFailing command output:\n${acceptOutcome.diagnostic}`
|
|
3269
|
-
: "");
|
|
3270
|
-
continue;
|
|
3271
|
-
}
|
|
3272
|
-
const spec = stateItem.item_spec;
|
|
3273
|
-
// The worker's explicit `resolved_no_change` is a no-change signal in its
|
|
3274
|
-
// own right; the spec heuristic is the fallback for a plain `resolved`.
|
|
3275
|
-
const isNoChange = itemResult.status === "resolved_no_change" || specIndicatesNoChange(spec);
|
|
3276
|
-
// DC-5 verify gate: an actual-change closure for a finding that covers a
|
|
3277
|
-
// behavior-CHANGE obligation must have a paired positive+scoped-negative
|
|
3278
|
-
// test spec; only-one-polarity (or an unscoped repo-wide negative) is
|
|
3279
|
-
// blocked, never silently resolved. The same single-source pairing/scoping
|
|
3280
|
-
// evaluation the test-plan derivation gate uses. A no-change closure makes
|
|
3281
|
-
// no edits, so it is exempt (the closure path above already proves it).
|
|
3282
|
-
const pairingBlockReason = isNoChange
|
|
3283
|
-
? null
|
|
3284
|
-
: verifyPairingForFinding(obligationIdsForFinding(state, itemResult.finding_id), obligationLedgerPayload, testValidatorPlanPayload);
|
|
3285
|
-
if (isNoChange && !hasExecutableEvidence(itemResult.evidence)) {
|
|
3286
|
-
// No-prose closure: a "verified-already-satisfied" (no-change) claim must
|
|
3287
|
-
// be backed by an executable assertion (a test/build/check command +
|
|
3288
|
-
// result), not prose — otherwise a real requirement silently no-ops.
|
|
3289
|
-
// Route an unproven no-change claim to triage instead of closing it.
|
|
3290
|
-
stateItem.status = "blocked";
|
|
3291
|
-
markTerminal(stateItem);
|
|
3292
|
-
stateItem.failure_reason =
|
|
3293
|
-
"verified-already-satisfied requires an executable regression test proving " +
|
|
3294
|
-
"the behavior (a test/build/check command + result in evidence), not prose.";
|
|
3295
|
-
}
|
|
3296
|
-
else if (pairingBlockReason) {
|
|
3297
|
-
stateItem.status = "blocked";
|
|
3298
|
-
markTerminal(stateItem);
|
|
3299
|
-
stateItem.failure_reason = pairingBlockReason;
|
|
3300
|
-
}
|
|
3301
|
-
else {
|
|
3302
|
-
stateItem.status = isNoChange ? "resolved_no_change" : "resolved";
|
|
3303
|
-
markTerminal(stateItem);
|
|
3304
|
-
// A no-change closure makes no edits, so it is exempt from the
|
|
3305
|
-
// git-diff write-scope gate; an actual fix is subject to it.
|
|
3306
|
-
if (!isNoChange) {
|
|
3307
|
-
resolvedFindingIds.push(itemResult.finding_id);
|
|
3308
|
-
}
|
|
3309
|
-
else {
|
|
3310
|
-
// M-FRICTION (no_change_merge): a resolved_no_change node merged with
|
|
3311
|
-
// no diff is a backend-observed step-boundary fact. Route it through
|
|
3312
|
-
// the single CE-005 chokepoint with the pinned discriminator
|
|
3313
|
-
// (node/block id + finding id) so the de-dup id is collision-free and
|
|
3314
|
-
// re-recording the same fact is a guaranteed no-op (CE-006). Best-effort
|
|
3315
|
-
// and non-fatal — capture never throws into the merge loop.
|
|
3316
|
-
await captureStepBoundaryFriction(options.artifactsDir, runId, {
|
|
3317
|
-
eventType: "no_change_merge",
|
|
3318
|
-
discriminator: `${blockId}:${itemResult.finding_id}`,
|
|
3319
|
-
note: `Node ${blockId} merged finding ${itemResult.finding_id} as ` +
|
|
3320
|
-
`resolved_no_change (no diff landed).`,
|
|
3321
|
-
category: "trap",
|
|
3322
|
-
}, "remediate-code");
|
|
3323
|
-
}
|
|
3324
|
-
// OBL-INV-RSD-06 / OBL-SEAM-RSD-03: use the shared REMEDIATION_STEP
|
|
3325
|
-
// constant, never the bare string literal, so this path and any other
|
|
3326
|
-
// verify-against-documentation writer agree on one source of truth.
|
|
3327
|
-
stateItem.last_successful_step =
|
|
3328
|
-
REMEDIATION_STEP.VERIFY_AGAINST_DOCUMENTATION;
|
|
3329
|
-
if (itemResult.evidence?.length) {
|
|
3330
|
-
await writeJsonFile(join(options.artifactsDir, `result_${itemResult.finding_id}_verify_code_against_documentation.json`), {
|
|
3331
|
-
finding_id: itemResult.finding_id,
|
|
3332
|
-
passed: true,
|
|
3333
|
-
reason: itemResult.evidence,
|
|
3334
|
-
});
|
|
3335
|
-
}
|
|
3336
|
-
}
|
|
3337
|
-
}
|
|
3338
|
-
else if (itemResult.status === "needs_clarification") {
|
|
3339
|
-
// Mid-run escape hatch (note 3, part B): the worker hit scoping/judgment
|
|
3340
|
-
// ambiguity. Route it to a clarification round (a real user question), not
|
|
3341
|
-
// to triage's retry/ignore/halt. NOT terminal — the answer re-opens it.
|
|
3342
|
-
stateItem.status = "needs_clarification";
|
|
3343
|
-
const question = itemResult.clarification_question ??
|
|
3344
|
-
itemResult.failure_reason ??
|
|
3345
|
-
"The worker reported unresolved scoping/judgment ambiguity.";
|
|
3346
|
-
stateItem.failure_reason = question;
|
|
3347
|
-
const category = isClarificationCategory(itemResult.clarification_category)
|
|
3348
|
-
? itemResult.clarification_category
|
|
3349
|
-
: "scope_of_fix";
|
|
3350
|
-
const clarifications = state.clarifications ?? [];
|
|
3351
|
-
if (!clarifications.some((c) => c.finding_id === itemResult.finding_id)) {
|
|
3352
|
-
clarifications.push({
|
|
3353
|
-
finding_id: itemResult.finding_id,
|
|
3354
|
-
category,
|
|
3355
|
-
description: question,
|
|
3356
|
-
});
|
|
3357
|
-
}
|
|
3358
|
-
state.clarifications = clarifications;
|
|
3359
|
-
// The run is paused for the batched clarification round at the single
|
|
3360
|
-
// post-loop status decision below (a needs_clarification item outranks
|
|
3361
|
-
// implementing/triage), so the answer is applied before any more work.
|
|
3362
|
-
}
|
|
3363
|
-
else {
|
|
3364
|
-
stateItem.status = "blocked";
|
|
3365
|
-
markTerminal(stateItem);
|
|
3366
|
-
stateItem.failure_reason =
|
|
3367
|
-
itemResult.failure_reason ?? "Implementation worker blocked.";
|
|
3368
|
-
}
|
|
3369
|
-
}
|
|
3370
|
-
// E2 convergence: a worker may silently OMIT an assigned finding (return no
|
|
3371
|
-
// item_results entry for it) — distinct from reporting it blocked or returning
|
|
3372
|
-
// an unknown id (both handled above). The collapsed loop leaves an omitted
|
|
3373
|
-
// finding untouched (still pending), so without accounting it re-dispatches
|
|
3374
|
-
// forever. Bound it: count each omission and, at the cap, block the finding
|
|
3375
|
-
// (→ triage) so a no-human run converges instead of looping (T2 termination).
|
|
3376
|
-
if (owningBlock) {
|
|
3377
|
-
const coveredFindingIds = new Set(collapsed.map((entry) => entry.finding_id));
|
|
3378
|
-
for (const findingId of owningBlock.items) {
|
|
3379
|
-
if (coveredFindingIds.has(findingId))
|
|
3380
|
-
continue;
|
|
3381
|
-
const stateItem = state.items[findingId];
|
|
3382
|
-
// Only a still-`pending` item is genuinely awaiting this worker's result;
|
|
3383
|
-
// terminal / needs_clarification / in-flight states are not "omitted".
|
|
3384
|
-
if (!stateItem || stateItem.status !== "pending")
|
|
3385
|
-
continue;
|
|
3386
|
-
const attempts = (stateItem.incomplete_coverage_attempts ?? 0) + 1;
|
|
3387
|
-
stateItem.incomplete_coverage_attempts = attempts;
|
|
3388
|
-
if (attempts >= MAX_INCOMPLETE_COVERAGE_ATTEMPTS) {
|
|
3389
|
-
stateItem.status = "blocked";
|
|
3390
|
-
markTerminal(stateItem);
|
|
3391
|
-
stateItem.failure_reason =
|
|
3392
|
-
`Implementation worker for block ${blockId} omitted this finding from its ` +
|
|
3393
|
-
`item_results across ${attempts} dispatch(es) (no entry returned, neither ` +
|
|
3394
|
-
`resolved nor blocked); blocking to converge instead of re-dispatching indefinitely.`;
|
|
3395
|
-
}
|
|
3396
|
-
}
|
|
3397
|
-
}
|
|
3398
|
-
// Per-block ACTUAL worktree-branch edits, collected for the post-loop
|
|
3399
|
-
// lost-update / overlapping-edit detection (a file edited by more than one
|
|
3400
|
-
// merged block). The write-scope gate itself is NOT applied here: it runs at
|
|
3401
|
-
// ACCEPT time (`acceptNodeWorktree` → `enforceAcceptWriteScope`), BEFORE the
|
|
3402
|
-
// cherry-pick, so an out-of-scope edit is prevented from landing rather than
|
|
3403
|
-
// reported once already merged — and a node it blocks reaches the merge as
|
|
3404
|
-
// `merged:false`, routed to triage by the merge-state gate below (with the
|
|
3405
|
-
// write-scope reason carried in its diagnostic). A missing branch means the
|
|
3406
|
-
// interim main-tree path was used (no per-worker diff): nothing to collect.
|
|
3407
|
-
const worktreeBranch = worktreeBranchForBlock(blockId, runId);
|
|
3408
|
-
const branchEdited = gitBranchExists(options.root, worktreeBranch)
|
|
3409
|
-
? gitEditedFilesForBranch(options.root, worktreeBranch)
|
|
3410
|
-
: null;
|
|
3411
|
-
if (branchEdited?.available) {
|
|
3412
|
-
// Resolve the block's ACTUAL edited hunks too, so overlap detection can
|
|
3413
|
-
// spare same-file blocks whose real line-ranges are disjoint. Unavailable
|
|
3414
|
-
// hunks fail closed inside detectOverlappingEdits (still flagged).
|
|
3415
|
-
const branchHunks = gitHunksForBranch(options.root, worktreeBranch);
|
|
3416
|
-
editedByBlock.push({
|
|
3417
|
-
block_id: blockId,
|
|
3418
|
-
files: branchEdited.files,
|
|
3419
|
-
hunks: branchHunks,
|
|
3420
|
-
});
|
|
3421
|
-
}
|
|
3422
|
-
// Merge-state gate (authoritative, OBL-DS-06): a node that self-reported a
|
|
3423
|
-
// finding "resolved" but whose tool-owned verify/merge did NOT land its edits
|
|
3424
|
-
// (acceptNodeWorktree returned merged:false — verify failed, a cherry-pick
|
|
3425
|
-
// conflict, or no actual edit) must never stand as resolved: its fix is not in
|
|
3426
|
-
// the main tree. Keyed on resolvedFindingIds, so a legitimate no-change closure
|
|
3427
|
-
// (which makes no edits by design, and is not in that set) stays exempt. This
|
|
3428
|
-
// covers the outcome=success/merged:false case (worker reported an actual-change
|
|
3429
|
-
// "resolved" but committed nothing); the hard-failure case (outcome=error|timeout)
|
|
3430
|
-
// is caught earlier, in the collapsed loop's resolve branch, so a mislabeled
|
|
3431
|
-
// `resolved_no_change` can't slip past this resolvedFindingIds keying.
|
|
3432
|
-
if (resolvedFindingIds.length > 0) {
|
|
3433
|
-
if (acceptOutcome && !acceptOutcome.merged) {
|
|
3434
|
-
for (const findingId of resolvedFindingIds) {
|
|
3435
|
-
const stateItem = state.items[findingId];
|
|
3436
|
-
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
3437
|
-
continue;
|
|
3438
|
-
stateItem.status = "blocked";
|
|
3439
|
-
markTerminal(stateItem);
|
|
3440
|
-
stateItem.failure_reason =
|
|
3441
|
-
`Node ${blockId} reported finding ${findingId} resolved, but its tool-owned ` +
|
|
3442
|
-
`verify/merge did not land the edits (outcome=${acceptOutcome.outcome}, ` +
|
|
3443
|
-
`verify_passed=${acceptOutcome.verifyPassed}, merged=false); the fix is not in ` +
|
|
3444
|
-
`the main tree. Routed to triage.` +
|
|
3445
|
-
(acceptOutcome.diagnostic
|
|
3446
|
-
? `\nFailing command output:\n${acceptOutcome.diagnostic}`
|
|
3447
|
-
: "");
|
|
3448
|
-
}
|
|
3449
|
-
}
|
|
3450
|
-
}
|
|
3451
|
-
// Release this block's amendment claims after it has been merged or blocked.
|
|
3452
|
-
mergeRegistry.releaseAmendments(blockId);
|
|
3453
|
-
}
|
|
3454
|
-
// OBL-INV-RSD-01: persist a deterministic diagnostic for every unmatched
|
|
3455
|
-
// worker result so an orphan is auditable and never silently dropped. This is
|
|
3456
|
-
// a sidecar artifact (not state.json), so it does not affect the single
|
|
3457
|
-
// state-commit invariant (RSD-02).
|
|
3458
|
-
if (orphanResults.length > 0) {
|
|
3459
|
-
await writeJsonFile(join(dir, "orphaned-implement-results.json"), {
|
|
3460
|
-
schema_version: "remediate-code-implement/orphaned-results/v1alpha1",
|
|
3461
|
-
run_id: runId,
|
|
3462
|
-
created_at: new Date().toISOString(),
|
|
3463
|
-
orphans: orphanResults,
|
|
3464
|
-
});
|
|
3465
|
-
process.stderr.write(`[remediate-code] dispatch: ${orphanResults.length} unmatched implement ` +
|
|
3466
|
-
`result finding_id(s) recorded as orphan dispositions (not dropped): ` +
|
|
3467
|
-
`${orphanResults.map((o) => o.finding_id).join(", ")}\n`);
|
|
3468
|
-
// M-FRICTION (artifact_rejected): each unmatched worker result is an artifact
|
|
3469
|
-
// rejected from the merge (referential-integrity reject). Route each through
|
|
3470
|
-
// the single CE-005 chokepoint with the pinned discriminator (artifact id +
|
|
3471
|
-
// cause token = the orphan disposition) so the de-dup id is collision-free
|
|
3472
|
-
// (CE-006). Best-effort / non-fatal — capture never throws into the merge.
|
|
3473
|
-
for (const orphan of orphanResults) {
|
|
3474
|
-
await captureStepBoundaryFriction(options.artifactsDir, runId, {
|
|
3475
|
-
eventType: "artifact_rejected",
|
|
3476
|
-
discriminator: `${orphan.finding_id}:${orphan.disposition}`,
|
|
3477
|
-
note: `Implement worker result for finding ${orphan.finding_id} was rejected ` +
|
|
3478
|
-
`from the merge (disposition=${orphan.disposition}, ` +
|
|
3479
|
-
`worker_status=${orphan.worker_status}).`,
|
|
3480
|
-
category: "trap",
|
|
3481
|
-
}, "remediate-code");
|
|
3482
|
-
}
|
|
3483
|
-
}
|
|
3484
|
-
// Lost-update / overlapping-edit detection (ARC-f378135d-2 / ARC-c1693139):
|
|
3485
|
-
// when the rolling engine had multiple nodes in flight, two workers can each
|
|
3486
|
-
// edit the SAME file in their own worktree; cherry-picking both would silently
|
|
3487
|
-
// drop one change. Any file edited by more than one merged block is a
|
|
3488
|
-
// lost-update hazard — block every involved block's still-non-terminal items
|
|
3489
|
-
// and route them to triage so the conflict is reconciled, never lost. Recorded
|
|
3490
|
-
// as a sidecar diagnostic. Single-block runs (the proven host-wave path)
|
|
3491
|
-
// produce zero overlaps, so this is inert on the current default path.
|
|
3492
|
-
const overlappingEdits = detectOverlappingEdits(editedByBlock);
|
|
3493
|
-
if (overlappingEdits.length > 0) {
|
|
3494
|
-
const involvedBlockIds = new Set(overlappingEdits.flatMap((o) => o.block_ids));
|
|
3495
|
-
for (const blockId of involvedBlockIds) {
|
|
3496
|
-
const block = state.plan?.blocks.find((b) => b.block_id === blockId);
|
|
3497
|
-
const conflictPaths = overlappingEdits
|
|
3498
|
-
.filter((o) => o.block_ids.includes(blockId))
|
|
3499
|
-
.map((o) => o.path);
|
|
3500
|
-
for (const findingId of block?.items ?? []) {
|
|
3501
|
-
const stateItem = state.items[findingId];
|
|
3502
|
-
if (!stateItem || isTerminalStatus(stateItem.status))
|
|
3503
|
-
continue;
|
|
3504
|
-
stateItem.status = "blocked";
|
|
3505
|
-
markTerminal(stateItem);
|
|
3506
|
-
stateItem.failure_reason =
|
|
3507
|
-
`Lost-update hazard: this block's worker edited file(s) also edited by ` +
|
|
3508
|
-
`another concurrently-dispatched block (${conflictPaths.join(", ")}). ` +
|
|
3509
|
-
`Blocking both so the overlapping change is reconciled in triage rather ` +
|
|
3510
|
-
`than silently dropped by a cherry-pick.`;
|
|
3511
|
-
}
|
|
3512
|
-
}
|
|
3513
|
-
await writeJsonFile(join(dir, "overlapping-edits.json"), {
|
|
3514
|
-
schema_version: "remediate-code-implement/overlapping-edits/v1alpha1",
|
|
3515
|
-
run_id: runId,
|
|
3516
|
-
created_at: new Date().toISOString(),
|
|
3517
|
-
overlaps: overlappingEdits,
|
|
3518
|
-
});
|
|
3519
|
-
process.stderr.write(`[remediate-code] dispatch: ${overlappingEdits.length} overlapping-edit ` +
|
|
3520
|
-
`conflict(s) across concurrently-merged blocks; involved blocks routed to ` +
|
|
3521
|
-
`triage: ${[...involvedBlockIds].join(", ")}\n`);
|
|
3522
|
-
}
|
|
3523
|
-
// Re-baseline affected-file hashes: the implement phase legitimately rewrites
|
|
3524
|
-
// these files, so a later integrity check must not flag the run's own edits as
|
|
3525
|
-
// a stale plan when re-attempting any remaining blocked findings.
|
|
3526
|
-
if (state.plan?.findings?.length) {
|
|
3527
|
-
resnapshotAffectedFileHashes(options.root, state.plan.findings);
|
|
3528
|
-
}
|
|
3529
|
-
// Per-node dispositions (INV-DS-15). One disposition per merged block/node; a
|
|
3530
|
-
// SKIP disposition is never reported as verified_complete. This is a sidecar
|
|
3531
|
-
// artifact (not state.json).
|
|
3532
|
-
const mergedBlocks = itemsToMerge.flatMap((item) => {
|
|
3533
|
-
if (!item.block_id)
|
|
3534
|
-
return [];
|
|
3535
|
-
const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
|
|
3536
|
-
return block ? [{ block, item }] : [];
|
|
3537
|
-
});
|
|
3538
|
-
const dispositions = mergedBlocks.map(({ block }) => buildNodeDisposition(block, state));
|
|
3539
|
-
// Sibling-red routing (INV-DS-14). For each merged block that ended red
|
|
3540
|
-
// (blocked), attribute the failure against the OTHER merged blocks' write
|
|
3541
|
-
// scopes: an attributable red (exactly one sibling owns the implicated
|
|
3542
|
-
// surface) routes that sibling to triage; an unattributable red is deferred to
|
|
3543
|
-
// the rolling-scheduler's coarse backstop. The state already advances to
|
|
3544
|
-
// triage below; this records the attribution decision deterministically.
|
|
3545
|
-
const siblingRedRoutes = [];
|
|
3546
|
-
for (const { block, item } of mergedBlocks) {
|
|
3547
|
-
const disposition = dispositions.find((d) => d.block_id === block.block_id);
|
|
3548
|
-
if (!disposition || disposition.disposition !== "blocked")
|
|
3549
|
-
continue;
|
|
3550
|
-
// The files implicated by this red node = its declared write scope.
|
|
3551
|
-
const implicatedFiles = item.access?.write_paths ?? [];
|
|
3552
|
-
const siblingScopes = mergedBlocks
|
|
3553
|
-
.filter((m) => m.block.block_id !== block.block_id)
|
|
3554
|
-
.map((m) => ({
|
|
3555
|
-
block_id: m.block.block_id,
|
|
3556
|
-
write_paths: m.item.access?.write_paths ?? [],
|
|
3557
|
-
}));
|
|
3558
|
-
const attributed = attributeSiblingRed(implicatedFiles, siblingScopes, options.root);
|
|
3559
|
-
siblingRedRoutes.push({
|
|
3560
|
-
red_block_id: block.block_id,
|
|
3561
|
-
implicated_files: implicatedFiles.map((p) => toRepoRelative(p, options.root)),
|
|
3562
|
-
routed_to_triage_block_id: attributed,
|
|
3563
|
-
backstop: attributed ? null : "rolling_scheduler_coarse",
|
|
3564
|
-
});
|
|
3565
|
-
}
|
|
3566
|
-
if (dispositions.length > 0) {
|
|
3567
|
-
await writeJsonFile(join(dir, "node-dispositions.json"), {
|
|
3568
|
-
schema_version: "remediate-code-implement/node-dispositions/v1alpha1",
|
|
3569
|
-
run_id: runId,
|
|
3570
|
-
created_at: new Date().toISOString(),
|
|
3571
|
-
dispositions,
|
|
3572
|
-
sibling_red_routes: siblingRedRoutes,
|
|
3573
|
-
});
|
|
3574
|
-
}
|
|
3575
|
-
const mergedFindingIds = new Set(itemsToMerge.flatMap((item) => {
|
|
3576
|
-
if (!item.block_id)
|
|
3577
|
-
return [];
|
|
3578
|
-
const block = state.plan?.blocks.find((b) => b.block_id === item.block_id);
|
|
3579
|
-
return block?.items ?? [];
|
|
3580
|
-
}));
|
|
3581
|
-
let implementResolved = 0;
|
|
3582
|
-
let implementRejected = 0;
|
|
3583
|
-
for (const findingId of mergedFindingIds) {
|
|
3584
|
-
const status = state.items[findingId]?.status;
|
|
3585
|
-
if (isVerifiedCompleteStatus(status))
|
|
3586
|
-
implementResolved++;
|
|
3587
|
-
else if (status === "blocked")
|
|
3588
|
-
implementRejected++;
|
|
3589
|
-
}
|
|
3590
|
-
process.stderr.write(`[remediate-code] dispatch: merged ${implementResolved} implement result(s), ` +
|
|
3591
|
-
`${implementRejected} rejected\n`);
|
|
3592
|
-
// A worker that reported needs_clarification (note 3, part B) outranks both
|
|
3593
|
-
// implementing and triage: pause the run for the batched clarification round so
|
|
3594
|
-
// the user's answer is applied before any more work is dispatched or triaged.
|
|
3595
|
-
// Otherwise route back to implementing while pending work remains (later
|
|
3596
|
-
// dependency waves, or blocks deferred this wave because a prerequisite was
|
|
3597
|
-
// still running); else advance to triage.
|
|
3598
|
-
const needsClarification = Object.values(state.items).some((it) => it.status === "needs_clarification");
|
|
3599
|
-
const moreToImplement = Object.values(state.items).some((it) => it.status === "pending");
|
|
3600
|
-
state.status = needsClarification
|
|
3601
|
-
? "waiting_for_clarification"
|
|
3602
|
-
: moreToImplement
|
|
3603
|
-
? "implementing"
|
|
3604
|
-
: "triage";
|
|
3605
|
-
// Single commit: StateStore.mutate writes the returned state once, under the
|
|
3606
|
-
// lock it already holds (OBL-INV-RSD-02 / OBL-SEAM-RSD-04). No saveState here.
|
|
3607
|
-
return state;
|
|
3608
|
-
}
|
|
3609
|
-
export async function readExtractedPlanIfPresent(artifactsDir) {
|
|
3610
|
-
return readOptionalJsonFile(join(artifactsDir, "extracted-plan.json"));
|
|
3611
|
-
}
|
|
3612
|
-
export async function readDispatchPlan(artifactsDir, runId, phase) {
|
|
3613
|
-
return readJsonFile(dispatchPlanPath(artifactsDir, runId, phase));
|
|
3614
|
-
}
|
|
2
|
+
// dispatch.ts — thin barrel
|
|
3
|
+
//
|
|
4
|
+
// The implementation was split into cohesive sibling modules under
|
|
5
|
+
// `./dispatch/` (CP-NODE-7). This file re-exports the EXACT original public
|
|
6
|
+
// surface so every consumer that imports from `steps/dispatch.js` keeps working
|
|
7
|
+
// unchanged. New code may import directly from the submodules; the barrel is the
|
|
8
|
+
// backwards-compatible aggregate.
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
export { gitTopLevel, worktreeBranchForBlock, gitEditedFilesForBranch, gitHunksForBranch, parseUnifiedDiffHunks, writeScopeViolations, detectRepoConventionsCache, } from "./dispatch/common.js";
|
|
11
|
+
export { resolveHostActiveSubagentLimit, detectHostConcurrencyFromEnv, normalizeSlotTokens, resolveHostConcurrencyLimit, scheduleWave, buildConfirmedPools, buildDispatchQuota, } from "./dispatch/waveScheduling.js";
|
|
12
|
+
export { createWorktree, seedUntrackedDeclaredPaths, removeWorktree, resetNodeWorktreeAndBranch, verifyNodeInWorktree, mergeWorktree, dirtyMainTreeCollisions, rebaseBranchOntoHead, worktreePath, remediationBranchName, baseBranchLockPath, quarantineRef, quarantineFailedNodeCommit, quarantineUncommittedWorktreeEdits, clearQuarantinedCommit, listQuarantinedCommits, remediationBaseBranchPath, readRemediationBaseBranch, ensureRemediationBranchCheckedOut, commitWorktree, ensureWorktreeNodeModules, } from "./dispatch/worktreeLifecycle.js";
|
|
13
|
+
export { acceptNodeWorktree, nodeAcceptOutcomePath, recordNodeAcceptOutcome, loadNodeAcceptOutcome, executeNodeInWorktree, } from "./dispatch/acceptNode.js";
|
|
14
|
+
// --- verify commands ---
|
|
15
|
+
export { isBuildFreeVerifyCommand, normalizeNodeTestCommand, isWholeSuiteTestCommand, pathTokensInCommand, selfContainedVerifyCommands, verifyCommandsForEdits, deriveVerifyCommandsFromBranch, targetedCommandsForBlock, } from "./dispatch/verifyCommands.js";
|
|
16
|
+
export { buildImplementModelHint, implementResultPath, isInfraModifyingBlock, buildTestFileIndex, collectReferencingTests, } from "./dispatch/implementPrompt.js";
|
|
17
|
+
export { enforceWriteScope, blockScopesFromPlan, declaredPathsFromPlan, adjudicateWriteScope, enforceAcceptWriteScope, buildBlockAliasMap, collapseItemResults, buildNodeDisposition, attributeSiblingRed, detectOverlappingEdits, } from "./dispatch/writeScope.js";
|
|
18
|
+
// --- marshalling (prepare / merge / readers) ---
|
|
19
|
+
export { prepareImplementDispatch, mergeImplementResults, readExtractedPlanIfPresent, readDispatchPlan, } from "./dispatch/marshal.js";
|
|
3615
20
|
//# sourceMappingURL=dispatch.js.map
|