@bridge_gpt/mcp-server 0.2.18 → 0.2.20
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/CONDUCTOR.md +75 -0
- package/README.md +8 -5
- package/build/agent-capabilities/probe-context.js +13 -3
- package/build/agent-capabilities/probes.js +262 -11
- package/build/agent-capabilities/reporter.js +1 -0
- package/build/agents.generated.js +1 -1
- package/build/backend-warnings.js +44 -0
- package/build/claude-settings.js +129 -0
- package/build/commands.generated.js +5 -3
- package/build/conductor/bridge-api-client.js +7 -7
- package/build/conductor/cli.js +65 -12
- package/build/conductor/deny-enforcement-preflight.js +96 -0
- package/build/conductor/doctor.js +183 -2
- package/build/conductor/epic-reconcile.js +9 -1
- package/build/conductor/epic-runtime.js +403 -43
- package/build/conductor/epic-state.js +7 -0
- package/build/conductor/errors.js +115 -3
- package/build/conductor/event-accessors.js +28 -10
- package/build/conductor/local-merge.js +458 -95
- package/build/conductor/merge-ledger.js +6 -4
- package/build/conductor/pr-ci-producer.js +17 -2
- package/build/conductor/producer-ledger.js +1 -1
- package/build/conductor/store.js +161 -18
- package/build/conductor/supervisor-merge.js +32 -5
- package/build/conductor/taxonomy.js +8 -0
- package/build/conductor/tools.js +28 -6
- package/build/conductor/worker-ledger-cli.js +244 -0
- package/build/conductor-bin.js +1884 -6917
- package/build/doctor.js +8 -0
- package/build/estimate-epic.js +84 -0
- package/build/executor/cli.js +229 -0
- package/build/executor/credentials.js +65 -0
- package/build/executor/deps.js +117 -0
- package/build/executor/env.js +79 -0
- package/build/executor/heartbeat.js +59 -0
- package/build/executor/http-client.js +131 -0
- package/build/executor/index.js +10 -0
- package/build/executor/job-errors.js +55 -0
- package/build/executor/job-log-registry.js +110 -0
- package/build/executor/job-runner.js +822 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +229 -0
- package/build/executor/observation.js +123 -0
- package/build/executor/permissions.js +79 -0
- package/build/executor/preflight.js +144 -0
- package/build/executor/process.js +81 -0
- package/build/executor/prompt-spec.js +235 -0
- package/build/executor/results.js +134 -0
- package/build/executor/resume-pre-spawn.js +179 -0
- package/build/executor/runner.js +98 -0
- package/build/executor/terminal-mutation.js +34 -0
- package/build/executor/test-clock.js +109 -0
- package/build/executor/types.js +18 -0
- package/build/executor/verdict-artifact.js +53 -0
- package/build/executor/viewer-tabs.js +78 -0
- package/build/executor/watch-cli.js +113 -0
- package/build/executor/worker-command.js +106 -0
- package/build/executor/worker-finalization.js +177 -0
- package/build/executor/worker-log.js +92 -0
- package/build/executor/worktree-gc.js +134 -0
- package/build/executor/worktree-inspection.js +86 -0
- package/build/executor/worktree.js +103 -0
- package/build/index.js +14534 -10416
- package/build/mcp-invoke.js +19 -3
- package/build/mcp-provisioning.js +31 -25
- package/build/mcp-registration-doctor.js +27 -7
- package/build/mcp-server-invocation.js +152 -0
- package/build/pipelines.generated.js +16 -20
- package/build/readme.generated.js +1 -1
- package/build/sfcc/client.js +192 -50
- package/build/sfcc/ocapi-write-faults.js +94 -0
- package/build/sfcc/permissions.js +7 -22
- package/build/sfcc/reads-site-preference.js +52 -19
- package/build/sfcc/register.js +9 -0
- package/build/sfcc/write-grants.js +80 -0
- package/build/sfcc/write-guard.js +39 -0
- package/build/sfcc/write-result.js +47 -0
- package/build/sfcc/write-tool-common.js +85 -0
- package/build/sfcc/writes-custom-object-def.js +141 -0
- package/build/sfcc/writes-object-attribute-payloads.js +97 -0
- package/build/sfcc/writes-site-preference-payloads.js +59 -0
- package/build/sfcc/writes-site-preference.js +96 -0
- package/build/sfcc/writes-system-object-payloads.js +213 -0
- package/build/sfcc/writes-system-object.js +348 -0
- package/build/sfcc/writes.js +66 -0
- package/build/start-tickets-conductor.js +25 -93
- package/build/start-tickets-prereqs.js +152 -1
- package/build/start-tickets.js +96 -158
- package/build/version.generated.js +1 -1
- package/build/visual-diff-worker.js +313 -0
- package/build/visual-diff.js +632 -0
- package/build/worktree-core.js +202 -0
- package/package.json +8 -4
- package/pipelines/idea-to-ticket.json +7 -0
- package/pipelines/review-ticket.json +5 -18
- package/public/css/main.min.css +1612 -107
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +10267 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fail-loud worker-finalization check (BAPI-551).
|
|
3
|
+
*
|
|
4
|
+
* An implementation-style worker (`implement`/`resume`/`remediate`/`ci_fix`/
|
|
5
|
+
* `rebase`) can exit cleanly yet strand its work if its push never landed on
|
|
6
|
+
* origin and no PR was opened — e.g. the worker backgrounds a slow push (the
|
|
7
|
+
* advisory pre-push suite) and returns before it completes. Without this check
|
|
8
|
+
* the executor still reports `clean_exit`/`succeeded`, and the gap is only ever
|
|
9
|
+
* caught by the reconciler's 3h watchdog. `validateWorkerFinalization` converts
|
|
10
|
+
* that condition into an explicit executor failure instead, using an authoritative
|
|
11
|
+
* local `git ls-remote` check (no Bridge API credentials, no conductor identity)
|
|
12
|
+
* that reads the real origin tip — never a stale local `origin/<branch>` tracking
|
|
13
|
+
* ref. That lookup is optionally repeated a few times with a short bounded delay
|
|
14
|
+
* before reporting `WorkerFinalizationMissingRemoteBranchAndPr`, so a recovery
|
|
15
|
+
* job whose push has landed but whose remote visibility settles just after the
|
|
16
|
+
* worker exits is not spuriously failed (BAPI-566 Bug A).
|
|
17
|
+
*
|
|
18
|
+
* For `resume`/`remediate`/`ci_fix`/`rebase`, the branch is fetched from origin
|
|
19
|
+
* BEFORE the worker starts (worktree.ts's recovery reuse path, resume-pre-spawn's
|
|
20
|
+
* "recreate ONLY from the pushed branch" path) — so the ref existing on origin is
|
|
21
|
+
* not proof THIS session's commit landed; a stale pre-existing branch would pass
|
|
22
|
+
* an existence-only check. The check instead compares the authoritative origin
|
|
23
|
+
* branch tip SHA against the worker's own HEAD commit (`headSha`, from git
|
|
24
|
+
* telemetry) whenever that comparison is available.
|
|
25
|
+
*/
|
|
26
|
+
import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr } from "./job-errors.js";
|
|
27
|
+
import { isImplementationStyleJobType } from "./job-types.js";
|
|
28
|
+
/** Bounded settling re-check defaults for the authoritative origin-tip lookup. */
|
|
29
|
+
const DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS = 3;
|
|
30
|
+
const DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS = 500;
|
|
31
|
+
/** Real-timer sleep; overridable via the `sleep` seam for deterministic tests. */
|
|
32
|
+
function sleepMs(ms) {
|
|
33
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
34
|
+
}
|
|
35
|
+
function normalizeAttempts(value) {
|
|
36
|
+
return typeof value === "number" && Number.isInteger(value) && value > 0
|
|
37
|
+
? value
|
|
38
|
+
: DEFAULT_ORIGIN_FINALIZATION_ATTEMPTS;
|
|
39
|
+
}
|
|
40
|
+
function normalizeRetryDelay(value) {
|
|
41
|
+
return typeof value === "number" && Number.isFinite(value) && value >= 0
|
|
42
|
+
? Math.floor(value)
|
|
43
|
+
: DEFAULT_ORIGIN_FINALIZATION_RETRY_DELAY_MS;
|
|
44
|
+
}
|
|
45
|
+
function extractPrUrl(result) {
|
|
46
|
+
const raw = result.pr_url;
|
|
47
|
+
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
|
|
48
|
+
}
|
|
49
|
+
/** Normalize to exactly one `refs/heads/` prefix — never `refs/heads/refs/heads/...`. */
|
|
50
|
+
function normalizeBranchRef(branch) {
|
|
51
|
+
return branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
|
|
52
|
+
}
|
|
53
|
+
/**
|
|
54
|
+
* Scan `git ls-remote` stdout and return the SHA for EXACTLY `expectedRef`.
|
|
55
|
+
* `ls-remote` lines are `<sha>\t<ref>`; only an exact ref match counts, so a
|
|
56
|
+
* prefix/suffix/child ref (`…-old`, `…/retry`) is never accepted. The returned
|
|
57
|
+
* SHA is trimmed and lower-cased so callers compare against a normalized worker
|
|
58
|
+
* HEAD. Raw stderr and credentials are never touched here.
|
|
59
|
+
*/
|
|
60
|
+
function parseLsRemoteHeadSha(stdout, expectedRef) {
|
|
61
|
+
for (const line of stdout.split("\n")) {
|
|
62
|
+
const trimmed = line.trim();
|
|
63
|
+
if (trimmed.length === 0)
|
|
64
|
+
continue;
|
|
65
|
+
const parts = trimmed.split(/\s+/);
|
|
66
|
+
if (parts.length < 2)
|
|
67
|
+
continue;
|
|
68
|
+
const [sha, ref] = parts;
|
|
69
|
+
if (ref === expectedRef) {
|
|
70
|
+
const normalized = sha.trim().toLowerCase();
|
|
71
|
+
return normalized.length > 0 ? normalized : null;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
return null;
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* Resolve the origin branch's authoritative current tip SHA (normalized), or
|
|
78
|
+
* `null` if the ref is absent. Uses `git ls-remote` against the exact normalized
|
|
79
|
+
* ref — never a local `origin/<branch>` remote-tracking ref, which can be stale
|
|
80
|
+
* on a recovery worktree. A non-zero exit is treated as "branch not found"
|
|
81
|
+
* without copying raw stderr into diagnostics.
|
|
82
|
+
*/
|
|
83
|
+
export async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
|
|
84
|
+
const normalizedRef = normalizeBranchRef(branch);
|
|
85
|
+
const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizedRef], { cwd: worktreePath });
|
|
86
|
+
if (result.exitCode !== 0)
|
|
87
|
+
return null;
|
|
88
|
+
return parseLsRemoteHeadSha(result.stdout, normalizedRef);
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* Repeatedly resolve the authoritative origin tip until it either satisfies
|
|
92
|
+
* finalization or the bounded retry budget is exhausted. Stops early (no further
|
|
93
|
+
* lookups) when the origin tip matches the worker HEAD, or when the branch exists
|
|
94
|
+
* and the worker HEAD is unavailable (degraded telemetry, existence-only). Retries
|
|
95
|
+
* when the tip is `null` (branch not yet visible) or, with a known HEAD, does not
|
|
96
|
+
* yet match. Returns the LAST authoritative observation — the caller decides
|
|
97
|
+
* loud-failure from that final value, so true negatives are preserved.
|
|
98
|
+
*/
|
|
99
|
+
async function resolveOriginBranchShaForFinalization(runCommand, worktreePath, branch, trimmedHeadSha, attempts, retryDelayMs, sleep) {
|
|
100
|
+
const total = Math.max(1, attempts);
|
|
101
|
+
let remoteSha = null;
|
|
102
|
+
for (let attempt = 0; attempt < total; attempt++) {
|
|
103
|
+
remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, branch);
|
|
104
|
+
if (remoteSha !== null) {
|
|
105
|
+
// Existence-only success when HEAD is unknown; exact-match success otherwise.
|
|
106
|
+
if (!trimmedHeadSha || remoteSha === trimmedHeadSha)
|
|
107
|
+
return remoteSha;
|
|
108
|
+
}
|
|
109
|
+
if (attempt < total - 1)
|
|
110
|
+
await sleep(retryDelayMs);
|
|
111
|
+
}
|
|
112
|
+
return remoteSha;
|
|
113
|
+
}
|
|
114
|
+
function missingBranchAndPrFailure(job, detail) {
|
|
115
|
+
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
116
|
+
return {
|
|
117
|
+
error_kind: WorkerFinalizationMissingRemoteBranchAndPr,
|
|
118
|
+
error_message: `${label} exited cleanly but ${detail}`,
|
|
119
|
+
classification: "crashed",
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Validate that an implementation-style job's clean exit actually produced a PR
|
|
124
|
+
* or a pushed origin branch. Non-implementation-style jobs (verdict jobs) and
|
|
125
|
+
* any result carrying a non-empty `pr_url` bypass the git check entirely.
|
|
126
|
+
*/
|
|
127
|
+
export async function validateWorkerFinalization(input) {
|
|
128
|
+
const { job, branch, worktreePath, result, runCommand, headSha } = input;
|
|
129
|
+
if (!isImplementationStyleJobType(job.job_type)) {
|
|
130
|
+
return { ok: true };
|
|
131
|
+
}
|
|
132
|
+
if (extractPrUrl(result)) {
|
|
133
|
+
return { ok: true };
|
|
134
|
+
}
|
|
135
|
+
const trimmedBranch = typeof branch === "string" ? branch.trim() : "";
|
|
136
|
+
if (!trimmedBranch) {
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
failure: missingBranchAndPrFailure(job, "produced no PR URL and no expected branch to verify on origin"),
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
// Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
|
|
143
|
+
// so the ref merely existing is not proof this session's commit landed. When
|
|
144
|
+
// the worker's own HEAD is known, require the authoritative origin tip to match
|
|
145
|
+
// it exactly — a stale pre-existing branch (this session's push never happened)
|
|
146
|
+
// fails loud instead of passing. When HEAD is unresolved (degraded telemetry),
|
|
147
|
+
// fall back to the existence-only check rather than inventing a new failure
|
|
148
|
+
// mode. The authoritative lookup is retried a few times so a push that settles
|
|
149
|
+
// just after the worker exits still finalizes (BAPI-566 Bug A).
|
|
150
|
+
const trimmedHeadSha = typeof headSha === "string" ? headSha.trim().toLowerCase() : "";
|
|
151
|
+
const attempts = normalizeAttempts(input.originResolveAttempts);
|
|
152
|
+
const retryDelayMs = normalizeRetryDelay(input.originResolveRetryDelayMs);
|
|
153
|
+
const sleep = input.sleep ?? sleepMs;
|
|
154
|
+
let remoteSha;
|
|
155
|
+
try {
|
|
156
|
+
remoteSha = await resolveOriginBranchShaForFinalization(runCommand, worktreePath, trimmedBranch, trimmedHeadSha, attempts, retryDelayMs, sleep);
|
|
157
|
+
}
|
|
158
|
+
catch (err) {
|
|
159
|
+
return {
|
|
160
|
+
ok: false,
|
|
161
|
+
failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' could not be verified on origin: ${secretFreeErrorMessage(err)}`),
|
|
162
|
+
};
|
|
163
|
+
}
|
|
164
|
+
if (remoteSha === null) {
|
|
165
|
+
return {
|
|
166
|
+
ok: false,
|
|
167
|
+
failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' is not on origin`),
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
if (trimmedHeadSha && remoteSha !== trimmedHeadSha) {
|
|
171
|
+
return {
|
|
172
|
+
ok: false,
|
|
173
|
+
failure: missingBranchAndPrFailure(job, `produced no PR URL and origin branch '${trimmedBranch}' is at ${remoteSha.slice(0, 12)}, which does not match this worker's HEAD ${trimmedHeadSha.slice(0, 12)} — the worker's commit was not pushed`),
|
|
174
|
+
};
|
|
175
|
+
}
|
|
176
|
+
return { ok: true };
|
|
177
|
+
}
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-job worker log tee primitives (BAPI-535, TDD §8, G5).
|
|
3
|
+
*
|
|
4
|
+
* Every worker's output is teed to the fixed path `<worktree>/.conductor/worker.log`
|
|
5
|
+
* in human-readable form so `tail -f` reproduces the v1 tab experience on demand.
|
|
6
|
+
* The tee is PURELY a recorder: it never owns, signals, or kills the worker
|
|
7
|
+
* process — closing/finalizing the log affects the file only. All filesystem
|
|
8
|
+
* access is behind injected boundaries so this is unit-testable with no real I/O.
|
|
9
|
+
*/
|
|
10
|
+
import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
|
|
11
|
+
/** The fixed `.conductor` worker-log directory (relative to the worktree). */
|
|
12
|
+
export const CONDUCTOR_WORKER_LOG_DIR = ".conductor";
|
|
13
|
+
/** The fixed worker-log filename. */
|
|
14
|
+
export const CONDUCTOR_WORKER_LOG_FILE = "worker.log";
|
|
15
|
+
/** Resolve `<worktree>/.conductor/worker.log` with platform-correct separators. */
|
|
16
|
+
export function buildWorkerLogPath(worktreePath, platform = process.platform) {
|
|
17
|
+
const pathApi = pathApiForExecutorPlatform(platform);
|
|
18
|
+
return pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR, CONDUCTOR_WORKER_LOG_FILE);
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Render one stdout/stderr chunk as human-readable log text. The worker's stream
|
|
22
|
+
* is already text; this passes it through WITHOUT JSON framing, process metadata,
|
|
23
|
+
* or secret-bearing envelope fields. `stream` is accepted for future labeling but
|
|
24
|
+
* the default rendering is a faithful passthrough so `tail -f` shows raw output.
|
|
25
|
+
*/
|
|
26
|
+
export function renderWorkerLogChunk(chunk, _stream = "stdout") {
|
|
27
|
+
return chunk;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Create the `.conductor` directory, initialize an empty `worker.log`, and return
|
|
31
|
+
* a tee handle. When an `appendFile` boundary is supplied, chunks are appended
|
|
32
|
+
* incrementally; otherwise a buffered fallback re-writes the accumulated content
|
|
33
|
+
* via `writeFile` so all consumed chunks are preserved in order.
|
|
34
|
+
*/
|
|
35
|
+
export async function createWorkerLogTee(worktreePath, deps) {
|
|
36
|
+
const platform = deps.platform ?? process.platform;
|
|
37
|
+
const pathApi = pathApiForExecutorPlatform(platform);
|
|
38
|
+
const dir = pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR);
|
|
39
|
+
const logPath = pathApi.join(dir, CONDUCTOR_WORKER_LOG_FILE);
|
|
40
|
+
await deps.mkdir(dir, { recursive: true });
|
|
41
|
+
await deps.writeFile(logPath, "");
|
|
42
|
+
let buffer = "";
|
|
43
|
+
let closed = false;
|
|
44
|
+
return {
|
|
45
|
+
logPath,
|
|
46
|
+
async append(chunk) {
|
|
47
|
+
if (closed)
|
|
48
|
+
return;
|
|
49
|
+
try {
|
|
50
|
+
if (deps.appendFile) {
|
|
51
|
+
await deps.appendFile(logPath, chunk);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
buffer += chunk;
|
|
55
|
+
await deps.writeFile(logPath, buffer);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
/* the log tee is a visibility nicety — a write failure never fails a job */
|
|
60
|
+
}
|
|
61
|
+
},
|
|
62
|
+
async close() {
|
|
63
|
+
closed = true;
|
|
64
|
+
},
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Wrap an `AsyncIterable<string>` so every consumed chunk is rendered and written
|
|
69
|
+
* to the worker log before being yielded to the existing runner logic. The
|
|
70
|
+
* original chunks pass through UNCHANGED, so downstream observation/classification
|
|
71
|
+
* is unaffected.
|
|
72
|
+
*/
|
|
73
|
+
export async function* teeAsyncIterable(source, tee, stream = "stdout") {
|
|
74
|
+
for await (const chunk of source) {
|
|
75
|
+
await tee.append(renderWorkerLogChunk(chunk, stream));
|
|
76
|
+
yield chunk;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Safely finalize a tee. Tolerates a `null`/`undefined` tee and repeated calls;
|
|
81
|
+
* NEVER kills or signals the worker process.
|
|
82
|
+
*/
|
|
83
|
+
export async function closeWorkerLogTee(tee) {
|
|
84
|
+
if (!tee)
|
|
85
|
+
return;
|
|
86
|
+
try {
|
|
87
|
+
await tee.close();
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
/* finalization is best-effort */
|
|
91
|
+
}
|
|
92
|
+
}
|
|
@@ -0,0 +1,134 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Conservative executor worktree GC sweep (BAPI-535, TDD §7 teardown).
|
|
3
|
+
*
|
|
4
|
+
* Removes ONLY executor/conductor-managed worktrees whose branch is merged into
|
|
5
|
+
* the base branch (`git merge-base --is-ancestor <branch> origin/<base>`) and
|
|
6
|
+
* idle for more than 24h. CONSERVATIVE by construction: a worktree that is the
|
|
7
|
+
* base branch, unmanaged, dirty, recently active, on an unknown/detached branch,
|
|
8
|
+
* unmerged, or whose merged/idle status cannot be PROVEN is always retained. Any
|
|
9
|
+
* inability to prove eligibility retains the worktree; per-worktree failures
|
|
10
|
+
* never abort the whole sweep. All I/O is injected for offline unit testing.
|
|
11
|
+
*/
|
|
12
|
+
import { listGitWorktrees, pathApiForExecutorPlatform, } from "./worktree-inspection.js";
|
|
13
|
+
/** Idle cutoff: a worktree must be idle strictly longer than 24h to be eligible. */
|
|
14
|
+
export const EXECUTOR_WORKTREE_GC_IDLE_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
function conductorMarkerPaths(worktreePath, platform) {
|
|
16
|
+
const pathApi = pathApiForExecutorPlatform(platform);
|
|
17
|
+
const dir = pathApi.join(worktreePath, ".conductor");
|
|
18
|
+
return {
|
|
19
|
+
dir,
|
|
20
|
+
workerLog: pathApi.join(dir, "worker.log"),
|
|
21
|
+
resultJson: pathApi.join(dir, "result.json"),
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
/**
|
|
25
|
+
* Conservatively identify an executor/conductor-managed worktree by the presence
|
|
26
|
+
* of a `.conductor` directory, `.conductor/worker.log`, or `.conductor/result.json`.
|
|
27
|
+
*/
|
|
28
|
+
export async function isExecutorManagedWorktree(worktreePath, deps) {
|
|
29
|
+
const markers = conductorMarkerPaths(worktreePath, deps.platform ?? process.platform);
|
|
30
|
+
for (const marker of [markers.workerLog, markers.resultJson, markers.dir]) {
|
|
31
|
+
if ((await deps.statMtimeMs(marker)) !== null)
|
|
32
|
+
return true;
|
|
33
|
+
}
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Compute last activity as the NEWEST mtime among `.conductor/worker.log`,
|
|
38
|
+
* `.conductor/result.json`, and the worktree directory itself. Returns `null`
|
|
39
|
+
* when none can be stat'd (activity cannot be proven → the caller retains).
|
|
40
|
+
*/
|
|
41
|
+
export async function getWorktreeLastActivityMs(worktreePath, deps) {
|
|
42
|
+
const markers = conductorMarkerPaths(worktreePath, deps.platform ?? process.platform);
|
|
43
|
+
const candidates = [markers.workerLog, markers.resultJson, worktreePath];
|
|
44
|
+
let newest = null;
|
|
45
|
+
for (const candidate of candidates) {
|
|
46
|
+
const mtime = await deps.statMtimeMs(candidate);
|
|
47
|
+
if (mtime !== null && (newest === null || mtime > newest))
|
|
48
|
+
newest = mtime;
|
|
49
|
+
}
|
|
50
|
+
return newest;
|
|
51
|
+
}
|
|
52
|
+
/** Idle when the last activity is STRICTLY more than 24h before `now`. */
|
|
53
|
+
export function isWorktreeIdle(lastActivityMs, nowMs) {
|
|
54
|
+
return nowMs - lastActivityMs > EXECUTOR_WORKTREE_GC_IDLE_MS;
|
|
55
|
+
}
|
|
56
|
+
/** True only when `git merge-base --is-ancestor <branch> origin/<base>` exits 0. */
|
|
57
|
+
export async function isBranchMergedIntoBase(runCommand, cwd, branch, baseBranch) {
|
|
58
|
+
const result = await runCommand("git", ["merge-base", "--is-ancestor", branch, `origin/${baseBranch}`], { cwd });
|
|
59
|
+
return result.exitCode === 0;
|
|
60
|
+
}
|
|
61
|
+
/** True when `git status --porcelain` inside the worktree is non-empty. */
|
|
62
|
+
export async function isWorktreeDirty(runCommand, worktreePath) {
|
|
63
|
+
const result = await runCommand("git", ["status", "--porcelain"], { cwd: worktreePath });
|
|
64
|
+
return result.stdout.trim().length > 0;
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Sweep worktrees, removing ONLY managed, clean, idle, non-base, merged
|
|
68
|
+
* worktrees. Everything else — including any worktree whose eligibility cannot be
|
|
69
|
+
* proven — is retained with a structured reason. Per-worktree failures are
|
|
70
|
+
* recorded and the sweep continues; the sweep never throws globally.
|
|
71
|
+
*/
|
|
72
|
+
export async function sweepExecutorWorktrees(deps) {
|
|
73
|
+
const summary = { removed: [], retained: [], errors: [] };
|
|
74
|
+
const listWorktrees = deps.listWorktrees ?? listGitWorktrees;
|
|
75
|
+
const nowMs = deps.now();
|
|
76
|
+
let entries;
|
|
77
|
+
try {
|
|
78
|
+
entries = await listWorktrees(deps.runCommand, deps.cwd);
|
|
79
|
+
}
|
|
80
|
+
catch (err) {
|
|
81
|
+
summary.errors.push({ path: deps.cwd, error: err instanceof Error ? err.message : String(err) });
|
|
82
|
+
return summary;
|
|
83
|
+
}
|
|
84
|
+
for (const entry of entries) {
|
|
85
|
+
try {
|
|
86
|
+
if (!entry.branch) {
|
|
87
|
+
summary.retained.push({ path: entry.path, reason: "detached/unknown branch" });
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
if (entry.branch === deps.baseBranch) {
|
|
91
|
+
summary.retained.push({ path: entry.path, reason: "base branch" });
|
|
92
|
+
continue;
|
|
93
|
+
}
|
|
94
|
+
if (!(await isExecutorManagedWorktree(entry.path, deps))) {
|
|
95
|
+
summary.retained.push({ path: entry.path, reason: "unmanaged (no .conductor marker)" });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (await isWorktreeDirty(deps.runCommand, entry.path)) {
|
|
99
|
+
summary.retained.push({ path: entry.path, reason: "dirty" });
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const lastActivity = await getWorktreeLastActivityMs(entry.path, deps);
|
|
103
|
+
if (lastActivity === null) {
|
|
104
|
+
summary.retained.push({ path: entry.path, reason: "activity unprovable" });
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
if (!isWorktreeIdle(lastActivity, nowMs)) {
|
|
108
|
+
summary.retained.push({ path: entry.path, reason: "recent activity" });
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!(await isBranchMergedIntoBase(deps.runCommand, deps.cwd, entry.branch, deps.baseBranch))) {
|
|
112
|
+
summary.retained.push({ path: entry.path, reason: "not merged into base" });
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
const removeResult = await deps.runCommand("git", ["worktree", "remove", entry.path], {
|
|
116
|
+
cwd: deps.cwd,
|
|
117
|
+
});
|
|
118
|
+
if (removeResult.exitCode === 0) {
|
|
119
|
+
summary.removed.push(entry.path);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
summary.errors.push({
|
|
123
|
+
path: entry.path,
|
|
124
|
+
error: (removeResult.stderr || removeResult.stdout || "git worktree remove failed").trim(),
|
|
125
|
+
});
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
catch (err) {
|
|
129
|
+
// Any failure to prove eligibility retains the worktree.
|
|
130
|
+
summary.errors.push({ path: entry.path, error: err instanceof Error ? err.message : String(err) });
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return summary;
|
|
134
|
+
}
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Local worktree inspection utilities for executor job behaviors (BAPI-535).
|
|
3
|
+
*
|
|
4
|
+
* Safe, read-only Git worktree lookup, branch verification, and remote-branch
|
|
5
|
+
* probes used by the `resume` pre-spawn protocol and the worktree GC sweep. All
|
|
6
|
+
* subprocess access is behind the injected `RunCommand` boundary so every helper
|
|
7
|
+
* is unit-testable on Linux CI with no real `git`. NONE of these helpers ever
|
|
8
|
+
* mutates repository state (no reset/checkout/clean/branch writes).
|
|
9
|
+
*/
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { BranchMismatch, ExecutorNamedError } from "./job-errors.js";
|
|
12
|
+
/** Return the Node `path` API matching a platform (`win32` vs POSIX). */
|
|
13
|
+
export function pathApiForExecutorPlatform(platform) {
|
|
14
|
+
return platform === "win32" ? path.win32 : path.posix;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Parse `git worktree list --porcelain` output into entries. Each record is a
|
|
18
|
+
* blank-line-separated block whose first line is `worktree <path>`; a `branch
|
|
19
|
+
* refs/heads/<name>` line carries the local branch, which is normalized to the
|
|
20
|
+
* short branch name. Detached/no-branch worktrees are represented without a
|
|
21
|
+
* `branch` (never an invented one).
|
|
22
|
+
*/
|
|
23
|
+
export function parseGitWorktreePorcelain(porcelain) {
|
|
24
|
+
const entries = [];
|
|
25
|
+
let current = null;
|
|
26
|
+
for (const rawLine of porcelain.split("\n")) {
|
|
27
|
+
const line = rawLine.replace(/\r$/, "");
|
|
28
|
+
if (line.startsWith("worktree ")) {
|
|
29
|
+
if (current)
|
|
30
|
+
entries.push(current);
|
|
31
|
+
current = { path: line.slice("worktree ".length).trim() };
|
|
32
|
+
}
|
|
33
|
+
else if (line.startsWith("branch ") && current) {
|
|
34
|
+
const ref = line.slice("branch ".length).trim();
|
|
35
|
+
current.branch = ref.startsWith("refs/heads/")
|
|
36
|
+
? ref.slice("refs/heads/".length)
|
|
37
|
+
: ref;
|
|
38
|
+
}
|
|
39
|
+
// `detached`, `bare`, `HEAD <sha>`, and blank lines carry no branch.
|
|
40
|
+
}
|
|
41
|
+
if (current)
|
|
42
|
+
entries.push(current);
|
|
43
|
+
return entries;
|
|
44
|
+
}
|
|
45
|
+
/** Run `git worktree list --porcelain` from the repo root and parse the result. */
|
|
46
|
+
export async function listGitWorktrees(runCommand, cwd) {
|
|
47
|
+
const result = await runCommand("git", ["worktree", "list", "--porcelain"], { cwd });
|
|
48
|
+
if (result.exitCode !== 0)
|
|
49
|
+
return [];
|
|
50
|
+
return parseGitWorktreePorcelain(result.stdout);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Return the worktree path whose local branch EXACTLY equals `branch` (not a
|
|
54
|
+
* substring / prefix match), or `undefined` when none matches.
|
|
55
|
+
*/
|
|
56
|
+
export function findWorktreeByBranch(entries, branch) {
|
|
57
|
+
const match = entries.find((e) => e.branch === branch);
|
|
58
|
+
return match?.path;
|
|
59
|
+
}
|
|
60
|
+
/** Run `git rev-parse --abbrev-ref HEAD` inside a worktree and return the branch. */
|
|
61
|
+
export async function getCurrentBranch(runCommand, worktreePath) {
|
|
62
|
+
const result = await runCommand("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
|
|
63
|
+
cwd: worktreePath,
|
|
64
|
+
});
|
|
65
|
+
return result.stdout.trim();
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Verify a worktree is on the expected branch; throw {@link ExecutorNamedError}
|
|
69
|
+
* with kind {@link BranchMismatch} otherwise. Working on the wrong branch would
|
|
70
|
+
* corrupt an unrelated branch, so this aborts rather than proceeding (TDD §7).
|
|
71
|
+
*/
|
|
72
|
+
export async function assertWorktreeOnBranch(runCommand, worktreePath, expectedBranch) {
|
|
73
|
+
const actual = await getCurrentBranch(runCommand, worktreePath);
|
|
74
|
+
if (actual !== expectedBranch) {
|
|
75
|
+
throw new ExecutorNamedError(BranchMismatch, `worktree '${worktreePath}' is on '${actual}', expected '${expectedBranch}'`);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Non-mutating probe for whether `origin/<branch>` exists. Uses
|
|
80
|
+
* `git rev-parse --verify --quiet origin/<branch>` (exit 0 ⇒ exists). Never
|
|
81
|
+
* fetches, never writes a ref.
|
|
82
|
+
*/
|
|
83
|
+
export async function remoteBranchExists(runCommand, cwd, branch) {
|
|
84
|
+
const result = await runCommand("git", ["rev-parse", "--verify", "--quiet", `origin/${branch}`], { cwd });
|
|
85
|
+
return result.exitCode === 0;
|
|
86
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Executor worktree ensure (BAPI-534, TDD §7, §12).
|
|
3
|
+
*
|
|
4
|
+
* Real worker jobs run in a Worktrunk-created worktree via the SHARED primitive
|
|
5
|
+
* (`worktree-core.createWorktreeForTicket`) — no duplicated `wt switch` argument
|
|
6
|
+
* construction or Worktrunk JSON path parsing here. Per-job create failures are
|
|
7
|
+
* returned as structured job failures (→ `/fail`), never thrown.
|
|
8
|
+
*/
|
|
9
|
+
import { commandSucceeded } from "../start-tickets-prereqs.js";
|
|
10
|
+
import { createWorktreeForTicket } from "../worktree-core.js";
|
|
11
|
+
/**
|
|
12
|
+
* Resolve the branch for a real spawn job: `expected_branch` wins; else
|
|
13
|
+
* `feature/<ticket_key>`; else a structured contract failure.
|
|
14
|
+
*/
|
|
15
|
+
export function resolveExecutorBranch(job) {
|
|
16
|
+
const expected = typeof job.expected_branch === "string" && job.expected_branch.trim().length > 0
|
|
17
|
+
? job.expected_branch.trim()
|
|
18
|
+
: null;
|
|
19
|
+
if (expected)
|
|
20
|
+
return { ok: true, branch: expected };
|
|
21
|
+
const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
|
|
22
|
+
? job.ticket_key.trim()
|
|
23
|
+
: null;
|
|
24
|
+
if (ticketKey)
|
|
25
|
+
return { ok: true, branch: `feature/${ticketKey}` };
|
|
26
|
+
return { ok: false, error: "no branch could be resolved (no expected_branch and no ticket_key)" };
|
|
27
|
+
}
|
|
28
|
+
/** Build the lean shared-worktree deps from the executor deps. */
|
|
29
|
+
function toWorktreeCoreDeps(deps) {
|
|
30
|
+
return {
|
|
31
|
+
runCommand: deps.runCommand,
|
|
32
|
+
platform: deps.platform,
|
|
33
|
+
env: deps.env,
|
|
34
|
+
cwd: deps.cwd,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Ensure the worktree for a job exists, delegating to the shared Worktrunk
|
|
39
|
+
* primitive. A create failure is returned as a structured failure that the job
|
|
40
|
+
* runner can send to `/fail`.
|
|
41
|
+
*
|
|
42
|
+
* `policy.reuseExistingBranch` selects between the two supported modes (see
|
|
43
|
+
* {@link EnsureExecutorWorktreePolicy}); it defaults to the fresh-off-base,
|
|
44
|
+
* F7-guarded behavior so callers that omit it are unchanged.
|
|
45
|
+
*/
|
|
46
|
+
export async function ensureExecutorWorktree(job, options, deps, policy = {}) {
|
|
47
|
+
const branchResult = resolveExecutorBranch(job);
|
|
48
|
+
if (!branchResult.ok)
|
|
49
|
+
return { ok: false, error: branchResult.error };
|
|
50
|
+
const branch = branchResult.branch;
|
|
51
|
+
// The shared primitive resolves the branch from `key` via a per-key override,
|
|
52
|
+
// so we pin the exact resolved branch through the override map.
|
|
53
|
+
const key = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
|
|
54
|
+
? job.ticket_key.trim()
|
|
55
|
+
: branch;
|
|
56
|
+
const reuseExistingBranch = policy.reuseExistingBranch === true;
|
|
57
|
+
// The F7 guard applies to FRESH implementation-style jobs — refuse to reuse a
|
|
58
|
+
// stale pre-existing branch carrying commits not on base (mirrors the v1
|
|
59
|
+
// conductor's `!isResume` guard). RECOVERY jobs intentionally continue from the
|
|
60
|
+
// ticket's bound branch (BAPI-542): they cut from `origin/<branch>` — the pushed
|
|
61
|
+
// PR branch, so a missing local branch is recreated from it rather than from
|
|
62
|
+
// base — with the guard OFF, because that branch legitimately carries the
|
|
63
|
+
// implement commit and later correction rounds.
|
|
64
|
+
const baseStartPoint = reuseExistingBranch ? `origin/${branch}` : options.baseBranch;
|
|
65
|
+
const guardStaleWorktree = !reuseExistingBranch;
|
|
66
|
+
// BAPI-528: recovery correction is ALWAYS post-push (the PR exists), so the
|
|
67
|
+
// pushed head is the source of truth. Two gaps this closes:
|
|
68
|
+
// 1. A multi-consumer executor that only cloned/fetched main has NEITHER the
|
|
69
|
+
// local branch NOR the `origin/<branch>` tracking ref, so cutting from
|
|
70
|
+
// `origin/<branch>` cannot resolve its start point → WorktreeError → the
|
|
71
|
+
// run strands at code_review. Fetch the pushed head first so it can.
|
|
72
|
+
// 2. When a local branch already exists but has DIVERGED from origin, reusing
|
|
73
|
+
// it as-is would address review comments on stale code and force-push over
|
|
74
|
+
// newer origin commits. Freshen it onto `origin/<branch>` (below).
|
|
75
|
+
// A failed fetch surfaces as a structured job failure — never a silent stale
|
|
76
|
+
// reuse. (§16's "no fetch && reset --hard" rule is scoped to the RESUME path,
|
|
77
|
+
// which must preserve unpushed local work; it does not apply here.)
|
|
78
|
+
if (reuseExistingBranch) {
|
|
79
|
+
const originRef = `origin/${branch}`;
|
|
80
|
+
const fetch = await deps.runCommand("git", ["fetch", "origin", branch], { cwd: deps.cwd });
|
|
81
|
+
if (!commandSucceeded(fetch)) {
|
|
82
|
+
const reason = (fetch.stderr || fetch.stdout || "").trim();
|
|
83
|
+
return {
|
|
84
|
+
ok: false,
|
|
85
|
+
error: `git fetch origin ${branch} failed${reason ? `: ${reason}` : ""}`,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseStartPoint, guardStaleWorktree,
|
|
89
|
+
// Hard-reset a reused existing local branch onto the freshly fetched
|
|
90
|
+
// pushed head; a missing local branch is instead cut from `baseStartPoint`
|
|
91
|
+
// (= `origin/<branch>`) inside the primitive, so both land at origin.
|
|
92
|
+
{ freshenFromOrigin: originRef });
|
|
93
|
+
if (row.status === "created" && typeof row.path === "string") {
|
|
94
|
+
return { ok: true, worktreePath: row.path, branch };
|
|
95
|
+
}
|
|
96
|
+
return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
|
|
97
|
+
}
|
|
98
|
+
const row = await createWorktreeForTicket(toWorktreeCoreDeps(deps), key, { [key]: branch }, options.worktrunkBinary, baseStartPoint, guardStaleWorktree);
|
|
99
|
+
if (row.status === "created" && typeof row.path === "string") {
|
|
100
|
+
return { ok: true, worktreePath: row.path, branch };
|
|
101
|
+
}
|
|
102
|
+
return { ok: false, error: row.error ?? `worktree creation failed for branch '${branch}'` };
|
|
103
|
+
}
|