@bridge_gpt/mcp-server 0.2.18 → 0.2.19
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 +2 -2
- 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 +1 -0
- 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/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/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 +688 -0
- package/build/executor/job-types.js +60 -0
- package/build/executor/merge-job.js +155 -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 +97 -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 +11222 -8544
- 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 +1 -1
- package/build/readme.generated.js +1 -1
- package/build/sfcc/reads-site-preference.js +52 -19
- 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/public/css/main.min.css +39 -0
- package/public/css/main.min.css.map +1 -1
- package/public/js/main.min.js +7924 -1
- package/public/js/main.min.js.map +1 -1
- package/smoke-test/SMOKE-TEST.md +2 -1
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
export class VirtualClock {
|
|
2
|
+
t = 0;
|
|
3
|
+
timers = [];
|
|
4
|
+
id = 0;
|
|
5
|
+
now = () => this.t;
|
|
6
|
+
setTimer = (cb, ms) => {
|
|
7
|
+
const id = ++this.id;
|
|
8
|
+
this.timers.push({ at: this.t + ms, cb, id });
|
|
9
|
+
return id;
|
|
10
|
+
};
|
|
11
|
+
clearTimer = (h) => {
|
|
12
|
+
this.timers = this.timers.filter((x) => x.id !== h);
|
|
13
|
+
};
|
|
14
|
+
sleep = (ms) => new Promise((res) => {
|
|
15
|
+
this.setTimer(() => res(), ms);
|
|
16
|
+
});
|
|
17
|
+
/** Fire timers in time order until `predicate()` holds or timers run out. */
|
|
18
|
+
async tickUntil(predicate, maxSteps = 5000) {
|
|
19
|
+
let steps = 0;
|
|
20
|
+
await flushMicrotasks();
|
|
21
|
+
while (!predicate() && this.timers.length > 0 && steps++ < maxSteps) {
|
|
22
|
+
this.timers.sort((a, b) => a.at - b.at);
|
|
23
|
+
const next = this.timers.shift();
|
|
24
|
+
this.t = next.at;
|
|
25
|
+
next.cb();
|
|
26
|
+
await flushMicrotasks();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
async function flushMicrotasks() {
|
|
31
|
+
for (let i = 0; i < 12; i++)
|
|
32
|
+
await Promise.resolve();
|
|
33
|
+
}
|
|
34
|
+
/** A controllable fake owned process for `implement` spawn tests. */
|
|
35
|
+
export function makeControllableProcess(stdout) {
|
|
36
|
+
let resolveWait;
|
|
37
|
+
const waitP = new Promise((r) => {
|
|
38
|
+
resolveWait = r;
|
|
39
|
+
});
|
|
40
|
+
const signals = [];
|
|
41
|
+
let done = false;
|
|
42
|
+
return {
|
|
43
|
+
proc: {
|
|
44
|
+
pid: 4242,
|
|
45
|
+
stdout: stdout ?? null,
|
|
46
|
+
stderr: null,
|
|
47
|
+
wait: () => waitP,
|
|
48
|
+
kill: (s) => {
|
|
49
|
+
signals.push(s);
|
|
50
|
+
if (!done) {
|
|
51
|
+
done = true;
|
|
52
|
+
resolveWait({ exitCode: null, signal: s });
|
|
53
|
+
}
|
|
54
|
+
},
|
|
55
|
+
},
|
|
56
|
+
resolve: (v) => {
|
|
57
|
+
done = true;
|
|
58
|
+
resolveWait(v);
|
|
59
|
+
},
|
|
60
|
+
signals,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
/** Build a full fake `ExecutorDeps`, driven by a `VirtualClock`. */
|
|
64
|
+
export function makeFakeExecutorDeps(clock, overrides = {}) {
|
|
65
|
+
const base = {
|
|
66
|
+
runCommand: async () => ({ stdout: "", stderr: "", exitCode: 0 }),
|
|
67
|
+
spawnProcess: () => {
|
|
68
|
+
throw new Error("spawnProcess not configured for this test");
|
|
69
|
+
},
|
|
70
|
+
readFile: async () => {
|
|
71
|
+
throw new Error("ENOENT");
|
|
72
|
+
},
|
|
73
|
+
writeFile: async () => { },
|
|
74
|
+
mkdir: async () => undefined,
|
|
75
|
+
stat: async () => ({ mode: 0o644 }),
|
|
76
|
+
statfs: async () => ({ bavail: 1_000_000, bsize: 4096 }),
|
|
77
|
+
sleep: clock.sleep,
|
|
78
|
+
now: clock.now,
|
|
79
|
+
setTimer: clock.setTimer,
|
|
80
|
+
clearTimer: clock.clearTimer,
|
|
81
|
+
env: {},
|
|
82
|
+
cwd: "/repo",
|
|
83
|
+
platform: "linux",
|
|
84
|
+
homedir: () => "/home/tester",
|
|
85
|
+
fetch: async () => ({ status: 204, text: async () => "" }),
|
|
86
|
+
log: () => { },
|
|
87
|
+
errorLog: () => { },
|
|
88
|
+
};
|
|
89
|
+
return { ...base, ...overrides };
|
|
90
|
+
}
|
|
91
|
+
/** Sensible default executor options for tests. */
|
|
92
|
+
export function makeTestOptions(overrides = {}) {
|
|
93
|
+
return {
|
|
94
|
+
executorId: "exec-test",
|
|
95
|
+
repos: ["repo-a"],
|
|
96
|
+
repoName: "repo-a",
|
|
97
|
+
maxConcurrent: 2,
|
|
98
|
+
pollIntervalMs: 15000,
|
|
99
|
+
heartbeatIntervalMs: 60000,
|
|
100
|
+
deadmanMs: 120000,
|
|
101
|
+
termGraceMs: 10000,
|
|
102
|
+
once: true,
|
|
103
|
+
worktrunkBinary: "wt",
|
|
104
|
+
baseBranch: "main",
|
|
105
|
+
advisoryParserEnabled: false,
|
|
106
|
+
defaultJobTimeoutSeconds: 1200,
|
|
107
|
+
...overrides,
|
|
108
|
+
};
|
|
109
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Epic Conductor v2 executor — core type/boundary definitions (BAPI-534, TDD §7).
|
|
3
|
+
*
|
|
4
|
+
* ARCHITECTURE BOUNDARY (TDD §12, Req 1): files under `mcp_server/src/executor/`
|
|
5
|
+
* are a dumb, pull-based local worker. They MUST NOT import from the v1
|
|
6
|
+
* conductor event/ledger graph — `conductor/store.ts`, `conductor/taxonomy.ts`,
|
|
7
|
+
* `conductor/event-accessors.ts`, `conductor/epic-runtime.ts`,
|
|
8
|
+
* `conductor/epic-reconcile.ts`, `conductor/epic-state.ts`, or any conductor
|
|
9
|
+
* producer / supervisor-runtime module. Allowed conductor reuse is limited to
|
|
10
|
+
* read-only / deterministic helpers — the deny-enforcement preflight
|
|
11
|
+
* (`conductor/deny-enforcement-preflight.ts`), and (BAPI-535 T3b) the local merge
|
|
12
|
+
* executor (`conductor/local-merge.ts`) plus the read-only Bridge API access +
|
|
13
|
+
* merge request/response types (`conductor/bridge-api-client.ts`) for the
|
|
14
|
+
* deterministic `merge` job.
|
|
15
|
+
* All subprocess / HTTP / filesystem / clock access is behind injected deps so
|
|
16
|
+
* every requirement is unit-testable on Linux CI with no real I/O.
|
|
17
|
+
*/
|
|
18
|
+
export {};
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Fixed-path verdict-artifact reading (BAPI-535, TDD §9).
|
|
3
|
+
*
|
|
4
|
+
* For verdict job types (`spec_review`, `smoke`) the executor reads exactly
|
|
5
|
+
* `<worktree>/.conductor/result.json` after the worker exits and posts the parsed
|
|
6
|
+
* object VERBATIM at `/complete`. A missing/unreadable/invalid artifact is a
|
|
7
|
+
* fail-loud named failure ({@link MissingVerdictArtifact}) — "a verdict can never
|
|
8
|
+
* be silently absent". The executor performs NO markdown parsing, NO verdict
|
|
9
|
+
* judgment, and NO schema-specific interpretation; per-`job_type` schema
|
|
10
|
+
* validation is server-owned at `/complete` (T2/T6).
|
|
11
|
+
*/
|
|
12
|
+
import { MissingVerdictArtifact, ExecutorNamedError } from "./job-errors.js";
|
|
13
|
+
import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
|
|
14
|
+
/** The fixed `.conductor` result directory (relative to the worktree). */
|
|
15
|
+
export const CONDUCTOR_RESULT_DIR = ".conductor";
|
|
16
|
+
/** The fixed verdict artifact filename. */
|
|
17
|
+
export const CONDUCTOR_RESULT_FILE = "result.json";
|
|
18
|
+
/** True only for verdict job types (`spec_review`, `smoke`). */
|
|
19
|
+
export function isVerdictJobType(jobType) {
|
|
20
|
+
return jobType === "spec_review" || jobType === "smoke";
|
|
21
|
+
}
|
|
22
|
+
/** Join `<worktree>/.conductor/result.json` with platform-correct separators. */
|
|
23
|
+
export function buildVerdictArtifactPath(worktreePath, platform = process.platform) {
|
|
24
|
+
const pathApi = pathApiForExecutorPlatform(platform);
|
|
25
|
+
return pathApi.join(worktreePath, CONDUCTOR_RESULT_DIR, CONDUCTOR_RESULT_FILE);
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Read and parse `<worktree>/.conductor/result.json`. Returns the parsed JSON
|
|
29
|
+
* OBJECT verbatim. Throws {@link ExecutorNamedError} kind
|
|
30
|
+
* {@link MissingVerdictArtifact} for a missing/unreadable file, invalid JSON, or
|
|
31
|
+
* a non-object top-level value (array, `null`, string, number, boolean).
|
|
32
|
+
*/
|
|
33
|
+
export async function readVerdictArtifact(worktreePath, deps) {
|
|
34
|
+
const artifactPath = buildVerdictArtifactPath(worktreePath, deps.platform ?? process.platform);
|
|
35
|
+
let raw;
|
|
36
|
+
try {
|
|
37
|
+
raw = await deps.readFile(artifactPath);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
throw new ExecutorNamedError(MissingVerdictArtifact, `verdict artifact missing or unreadable at ${CONDUCTOR_RESULT_DIR}/${CONDUCTOR_RESULT_FILE}`);
|
|
41
|
+
}
|
|
42
|
+
let parsed;
|
|
43
|
+
try {
|
|
44
|
+
parsed = JSON.parse(raw);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
throw new ExecutorNamedError(MissingVerdictArtifact, `verdict artifact ${CONDUCTOR_RESULT_DIR}/${CONDUCTOR_RESULT_FILE} is not valid JSON`);
|
|
48
|
+
}
|
|
49
|
+
if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
50
|
+
throw new ExecutorNamedError(MissingVerdictArtifact, `verdict artifact ${CONDUCTOR_RESULT_DIR}/${CONDUCTOR_RESULT_FILE} must be a JSON object`);
|
|
51
|
+
}
|
|
52
|
+
return parsed;
|
|
53
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Optional read-only viewer tabs (BAPI-535, TDD §8, G5).
|
|
3
|
+
*
|
|
4
|
+
* Opens a NON-OWNING terminal tab that runs `tail -f` against a job's worker log,
|
|
5
|
+
* visually identical to the v1 per-ticket tabs but strictly read-only: closing
|
|
6
|
+
* the tab kills nothing, and nothing typed into it affects the job. The tab
|
|
7
|
+
* command is ALWAYS `tail -f <log>` — never `claude`, Worktrunk, or any
|
|
8
|
+
* worker-owning process — so the viewing surface is fully decoupled from the
|
|
9
|
+
* execution surface. Failures are non-fatal: opening a viewer never throws,
|
|
10
|
+
* never kills the worker, and never changes completion/failure reporting.
|
|
11
|
+
*/
|
|
12
|
+
import { getDefaultSpawnTerminalTabForPlatform, } from "../start-tickets.js";
|
|
13
|
+
/** Env var that force-enables/disables viewer tabs regardless of platform. */
|
|
14
|
+
export const EXECUTOR_VIEWER_TABS_ENV = "BAPI_EXECUTOR_VIEWER_TABS";
|
|
15
|
+
const TRUE_TOKENS = new Set(["1", "true", "on"]);
|
|
16
|
+
const FALSE_TOKENS = new Set(["0", "false", "off"]);
|
|
17
|
+
/**
|
|
18
|
+
* Whether viewer tabs are enabled: default ON for `darwin`, OFF elsewhere, with
|
|
19
|
+
* `BAPI_EXECUTOR_VIEWER_TABS=1|true|on` / `0|false|off` overriding either way.
|
|
20
|
+
*/
|
|
21
|
+
export function executorViewerTabsEnabled(platform, env) {
|
|
22
|
+
const raw = env[EXECUTOR_VIEWER_TABS_ENV];
|
|
23
|
+
if (typeof raw === "string") {
|
|
24
|
+
const token = raw.trim().toLowerCase();
|
|
25
|
+
if (TRUE_TOKENS.has(token))
|
|
26
|
+
return true;
|
|
27
|
+
if (FALSE_TOKENS.has(token))
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
return platform === "darwin";
|
|
31
|
+
}
|
|
32
|
+
/** Single-quote a path for safe embedding in a POSIX shell command. */
|
|
33
|
+
function shellQuote(value) {
|
|
34
|
+
return `'${value.replace(/'/g, `'\\''`)}'`;
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Build the read-only tail-follow shell command. The executable action is always
|
|
38
|
+
* exactly `tail -f <worker-log-path>` — it can never own the worker process.
|
|
39
|
+
*/
|
|
40
|
+
export function buildTailFollowCommand(workerLogPath) {
|
|
41
|
+
return `tail -f ${shellQuote(workerLogPath)}`;
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* Open a read-only viewer tab tailing the worker log. Returns a `skipped` result
|
|
45
|
+
* when viewer tabs are disabled and a `failed` result (never a throw) when the
|
|
46
|
+
* spawn boundary reports/raises a failure — the worker lifecycle is untouched.
|
|
47
|
+
*/
|
|
48
|
+
export async function openExecutorViewerTab(options) {
|
|
49
|
+
if (!executorViewerTabsEnabled(options.platform, options.env)) {
|
|
50
|
+
return { status: "skipped", reason: "viewer tabs disabled for this platform/config" };
|
|
51
|
+
}
|
|
52
|
+
const command = buildTailFollowCommand(options.workerLogPath);
|
|
53
|
+
const spawnTab = options.spawnTab ?? getDefaultSpawnTerminalTabForPlatform(options.platform);
|
|
54
|
+
const terminal = options.terminal ?? "terminal";
|
|
55
|
+
// A minimal StartTicketsDeps for the shared spawner; `spawnTerminalTab` is
|
|
56
|
+
// self-referential (only used by higher-level orchestration, not the leaf
|
|
57
|
+
// spawner) and never spawns a worker here.
|
|
58
|
+
const startTicketsDeps = {
|
|
59
|
+
runCommand: options.runCommand,
|
|
60
|
+
platform: options.platform,
|
|
61
|
+
env: options.env,
|
|
62
|
+
cwd: options.cwd,
|
|
63
|
+
spawnTerminalTab: spawnTab,
|
|
64
|
+
};
|
|
65
|
+
try {
|
|
66
|
+
const result = await spawnTab(startTicketsDeps, terminal, command, {
|
|
67
|
+
key: options.ticketKey ?? "",
|
|
68
|
+
worktreePath: options.worktreePath,
|
|
69
|
+
});
|
|
70
|
+
if (result.ok)
|
|
71
|
+
return { status: "opened" };
|
|
72
|
+
return { status: "failed", reason: result.error };
|
|
73
|
+
}
|
|
74
|
+
catch (err) {
|
|
75
|
+
const reason = err instanceof Error ? err.message : String(err);
|
|
76
|
+
return { status: "failed", reason: reason.slice(0, 200) };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `executor watch <job>` subcommand (BAPI-535, TDD §8).
|
|
3
|
+
*
|
|
4
|
+
* Ad-hoc, READ-ONLY attachment to a running (or recently-run) job's worker log:
|
|
5
|
+
* it resolves the local job-log registry record, verifies the log file exists,
|
|
6
|
+
* and `tail -f`s it with inherited stdio. It NEVER sends a signal to the worker
|
|
7
|
+
* process and never owns the execution lifecycle — closing the watch leaves the
|
|
8
|
+
* worker untouched. Missing registry records or missing log files fail loud with
|
|
9
|
+
* actionable stderr text and a non-zero exit code.
|
|
10
|
+
*/
|
|
11
|
+
import { spawn } from "node:child_process";
|
|
12
|
+
import { access } from "node:fs/promises";
|
|
13
|
+
import os from "node:os";
|
|
14
|
+
import { readFile, writeFile, mkdir } from "node:fs/promises";
|
|
15
|
+
import { readExecutorJobLogRecord, } from "./job-log-registry.js";
|
|
16
|
+
export function getExecutorWatchUsage() {
|
|
17
|
+
return [
|
|
18
|
+
"Usage: mcp-server executor watch <job-id>",
|
|
19
|
+
"",
|
|
20
|
+
"Attach read-only to a running (or recently-run) executor job's worker log",
|
|
21
|
+
"via `tail -f`. Closing the watch never affects the worker process.",
|
|
22
|
+
"",
|
|
23
|
+
"Arguments:",
|
|
24
|
+
" <job-id> Numeric executor job id to watch.",
|
|
25
|
+
"",
|
|
26
|
+
"Options:",
|
|
27
|
+
" -h, --help Show this help.",
|
|
28
|
+
].join("\n");
|
|
29
|
+
}
|
|
30
|
+
/** Accept EXACTLY one numeric job id; reject missing/non-numeric/extra args. */
|
|
31
|
+
export function parseExecutorWatchArgs(argv) {
|
|
32
|
+
const positionals = [];
|
|
33
|
+
for (const arg of argv) {
|
|
34
|
+
if (arg === "-h" || arg === "--help")
|
|
35
|
+
return { kind: "help" };
|
|
36
|
+
if (arg.startsWith("-"))
|
|
37
|
+
return { kind: "error", message: `unknown argument: ${arg}` };
|
|
38
|
+
positionals.push(arg);
|
|
39
|
+
}
|
|
40
|
+
if (positionals.length === 0) {
|
|
41
|
+
return { kind: "error", message: "a numeric <job-id> is required" };
|
|
42
|
+
}
|
|
43
|
+
if (positionals.length > 1) {
|
|
44
|
+
return { kind: "error", message: "exactly one <job-id> is accepted" };
|
|
45
|
+
}
|
|
46
|
+
const raw = positionals[0];
|
|
47
|
+
if (!/^[0-9]+$/.test(raw)) {
|
|
48
|
+
return { kind: "error", message: `job id must be numeric: '${raw}'` };
|
|
49
|
+
}
|
|
50
|
+
return { kind: "ok", jobId: Number(raw) };
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Spawn `tail -f <log_path>` with inherited stdio for interactive CLI attachment.
|
|
54
|
+
* Resolves with the tail process's numeric exit code. NEVER signals the worker.
|
|
55
|
+
*/
|
|
56
|
+
export function spawnTailFollow(logPath, spawnImpl = spawn) {
|
|
57
|
+
return new Promise((resolve) => {
|
|
58
|
+
const child = spawnImpl("tail", ["-f", logPath], { stdio: "inherit" });
|
|
59
|
+
child.on("close", (code) => resolve(code ?? 0));
|
|
60
|
+
child.on("error", () => resolve(1));
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
/** Build the default (production) registry deps from real fs/env/os boundaries. */
|
|
64
|
+
export function createDefaultWatchRegistryDeps() {
|
|
65
|
+
return {
|
|
66
|
+
readFile: (p) => readFile(p, "utf-8"),
|
|
67
|
+
writeFile: (p, data) => writeFile(p, data, "utf-8"),
|
|
68
|
+
mkdir: (p, opts) => mkdir(p, opts),
|
|
69
|
+
env: process.env,
|
|
70
|
+
tmpdir: () => os.tmpdir(),
|
|
71
|
+
platform: process.platform,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
async function defaultLogExists(logPath) {
|
|
75
|
+
try {
|
|
76
|
+
await access(logPath);
|
|
77
|
+
return true;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
return false;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Resolve the job-log record and attach read-only via `tail -f`. Returns a
|
|
85
|
+
* numeric exit code: `0` on a clean tail exit, non-zero on a missing registry
|
|
86
|
+
* record or missing log file (with actionable stderr).
|
|
87
|
+
*/
|
|
88
|
+
export async function runExecutorWatchCli(argv, overrides = {}) {
|
|
89
|
+
const errorLog = overrides.errorLog ?? ((m) => console.error(m));
|
|
90
|
+
const parsed = parseExecutorWatchArgs(argv);
|
|
91
|
+
if (parsed.kind === "help") {
|
|
92
|
+
errorLog(getExecutorWatchUsage());
|
|
93
|
+
return 0;
|
|
94
|
+
}
|
|
95
|
+
if (parsed.kind === "error") {
|
|
96
|
+
errorLog(`Error: ${parsed.message}\n\n${getExecutorWatchUsage()}`);
|
|
97
|
+
return 1;
|
|
98
|
+
}
|
|
99
|
+
const registryDeps = overrides.registryDeps ?? createDefaultWatchRegistryDeps();
|
|
100
|
+
const readRecord = overrides.readRecord ?? readExecutorJobLogRecord;
|
|
101
|
+
const record = await readRecord(parsed.jobId, registryDeps);
|
|
102
|
+
if (!record.ok) {
|
|
103
|
+
errorLog(`Error: no worker log registered for job ${parsed.jobId}: ${record.reason}`);
|
|
104
|
+
return 1;
|
|
105
|
+
}
|
|
106
|
+
const logExists = overrides.logExists ?? defaultLogExists;
|
|
107
|
+
if (!(await logExists(record.record.log_path))) {
|
|
108
|
+
errorLog(`Error: worker log for job ${parsed.jobId} is missing at ${record.record.log_path}`);
|
|
109
|
+
return 1;
|
|
110
|
+
}
|
|
111
|
+
const spawnTail = overrides.spawnTail ?? ((logPath) => spawnTailFollow(logPath));
|
|
112
|
+
return spawnTail(record.record.log_path);
|
|
113
|
+
}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Headless Claude worker command construction (BAPI-534, TDD §7).
|
|
3
|
+
*
|
|
4
|
+
* Resolves the exact argv, model alias, and prompt for a spawned worker. Model
|
|
5
|
+
* aliases resolve through the agent registry (never a raw untrusted string); no
|
|
6
|
+
* MCP config is ever added. When no safe alias is available, `--model` is omitted.
|
|
7
|
+
*/
|
|
8
|
+
import { resolveAgentSpec, resolveModelAlias, isModelTier, isValidModelAlias, } from "../agent-registry.js";
|
|
9
|
+
import { isRecoveryJobType } from "./job-types.js";
|
|
10
|
+
/**
|
|
11
|
+
* Resolve the Claude model alias from a job payload. Accepts a direct
|
|
12
|
+
* `model_alias` only when it passes registry alias validation AND the static
|
|
13
|
+
* Claude allowlist; otherwise resolves from a valid `model_tier` through the
|
|
14
|
+
* registry. Returns `null` when no safe alias is available (so `--model` is
|
|
15
|
+
* omitted) — an unsafe/invalid string is never returned.
|
|
16
|
+
*/
|
|
17
|
+
export function resolveExecutorModelAlias(payload) {
|
|
18
|
+
const agent = resolveAgentSpec("claude");
|
|
19
|
+
if (!agent)
|
|
20
|
+
return null;
|
|
21
|
+
const p = payload && typeof payload === "object" ? payload : {};
|
|
22
|
+
const directRaw = p.model_alias;
|
|
23
|
+
if (typeof directRaw === "string" && directRaw.trim().length > 0) {
|
|
24
|
+
const direct = directRaw.trim();
|
|
25
|
+
const allowed = !agent.staticModelAliasAllowlist || agent.staticModelAliasAllowlist.includes(direct);
|
|
26
|
+
if (isValidModelAlias(direct) && allowed) {
|
|
27
|
+
return direct;
|
|
28
|
+
}
|
|
29
|
+
// An invalid/disallowed direct alias falls through to the tier path — never
|
|
30
|
+
// returned as-is.
|
|
31
|
+
}
|
|
32
|
+
const tier = isModelTier(p.model_tier) ? p.model_tier : null;
|
|
33
|
+
return resolveModelAlias(agent, tier, null);
|
|
34
|
+
}
|
|
35
|
+
/**
|
|
36
|
+
* Resolve the worker prompt for the explicit-prompt and legacy synthesis paths.
|
|
37
|
+
* Uses an explicit non-empty `payload.prompt` verbatim; otherwise, for
|
|
38
|
+
* `implement` and the recovery jobs, builds the conservative
|
|
39
|
+
* `/implement-ticket <ticket_key> --auto` prompt.
|
|
40
|
+
*
|
|
41
|
+
* Structured `payload.prompt_spec` rendering (the `spec_review` path) is NOT done
|
|
42
|
+
* here: it needs the prepared worktree/git state, so `job-runner.ts` renders it
|
|
43
|
+
* in the prepared-spawn path AFTER worktree preparation. `spec_review` therefore
|
|
44
|
+
* returns `ok:false` from this function by design — that non-ok result is the
|
|
45
|
+
* signal for the runner to fall through to prompt-spec rendering, not a terminal
|
|
46
|
+
* failure. A real spawn job with neither an explicit prompt, a synthesis path,
|
|
47
|
+
* nor a `prompt_spec` is the genuine contract failure.
|
|
48
|
+
*/
|
|
49
|
+
/**
|
|
50
|
+
* True for the job types that synthesize `/implement-ticket <KEY> --auto` when no
|
|
51
|
+
* explicit `payload.prompt` is present: the first-pass `implement` plus the
|
|
52
|
+
* recovery jobs (`remediate`/`ci_fix`/`rebase`), which re-enter the SAME
|
|
53
|
+
* correction loop on the ticket's existing branch/PR. Recovery classification is
|
|
54
|
+
* shared with dispatch and worktree reuse via `isRecoveryJobType` (`job-types.ts`)
|
|
55
|
+
* so eligibility cannot drift. Verdict jobs (`spec_review`/`smoke`) are NOT here —
|
|
56
|
+
* they carry an explicit `payload.prompt` and write a fixed-path artifact (T6/T5b).
|
|
57
|
+
*/
|
|
58
|
+
function synthesizesImplementPrompt(jobType) {
|
|
59
|
+
return jobType === "implement" || isRecoveryJobType(jobType);
|
|
60
|
+
}
|
|
61
|
+
export function resolveExecutorPrompt(job) {
|
|
62
|
+
const payload = job.payload && typeof job.payload === "object"
|
|
63
|
+
? job.payload
|
|
64
|
+
: {};
|
|
65
|
+
// An explicit prompt wins and is returned VERBATIM (un-trimmed), but a
|
|
66
|
+
// whitespace-only string is not a usable worker instruction — treat it as
|
|
67
|
+
// absent so a recovery/implement job still falls back to the synthesized
|
|
68
|
+
// `/implement-ticket <KEY> --auto` correction prompt below.
|
|
69
|
+
const explicit = payload.prompt;
|
|
70
|
+
if (typeof explicit === "string" && explicit.trim().length > 0) {
|
|
71
|
+
return { ok: true, prompt: explicit };
|
|
72
|
+
}
|
|
73
|
+
// `/implement-ticket <KEY> --auto` is the full correction engine, not just a
|
|
74
|
+
// first-pass implementer: its post-PR loop owns CI-failure correction, review-
|
|
75
|
+
// change addressing, and merge-conflict resolution (see .claude/commands/
|
|
76
|
+
// implement-ticket.md "Clean session exit"). So the recovery jobs that operate
|
|
77
|
+
// on the ticket's existing branch/PR — `remediate`/`ci_fix`/`rebase` — resume
|
|
78
|
+
// through the same command, which detects the outstanding CI/review/conflict
|
|
79
|
+
// state and continues rather than restarting. Without this, a code_review
|
|
80
|
+
// `changes_requested` (near-certain in real runs) enqueues a `remediate` job
|
|
81
|
+
// that fails `no usable prompt` and strands the run (BAPI-528 Milestone-A gap #3).
|
|
82
|
+
const ticketKey = typeof job.ticket_key === "string" && job.ticket_key.trim().length > 0
|
|
83
|
+
? job.ticket_key.trim()
|
|
84
|
+
: null;
|
|
85
|
+
if (synthesizesImplementPrompt(job.job_type) && ticketKey) {
|
|
86
|
+
return { ok: true, prompt: `/implement-ticket ${ticketKey} --auto` };
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
ok: false,
|
|
90
|
+
error: `no usable prompt for job_type '${job.job_type}' (no payload.prompt and no ticket_key)`,
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* Build the exact headless Claude argv. `--model <alias>` is included only when
|
|
95
|
+
* an alias is provided. No MCP config arguments are ever emitted.
|
|
96
|
+
*/
|
|
97
|
+
export function buildClaudeExecutorArgv(prompt, alias) {
|
|
98
|
+
const argv = ["-p", prompt, "--output-format", "stream-json", "--verbose"];
|
|
99
|
+
if (alias) {
|
|
100
|
+
argv.push("--model", alias);
|
|
101
|
+
}
|
|
102
|
+
argv.push("--dangerously-skip-permissions");
|
|
103
|
+
return argv;
|
|
104
|
+
}
|
|
105
|
+
/** The worker executable is always exactly `claude`. */
|
|
106
|
+
export const CLAUDE_EXECUTABLE = "claude";
|
|
@@ -0,0 +1,97 @@
|
|
|
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 a local
|
|
11
|
+
* `git ls-remote` check (no Bridge API credentials, no conductor identity).
|
|
12
|
+
*
|
|
13
|
+
* For `resume`/`remediate`/`ci_fix`/`rebase`, the branch is fetched from origin
|
|
14
|
+
* BEFORE the worker starts (worktree.ts's recovery reuse path, resume-pre-spawn's
|
|
15
|
+
* "recreate ONLY from the pushed branch" path) — so the ref existing on origin is
|
|
16
|
+
* not proof THIS session's commit landed; a stale pre-existing branch would pass
|
|
17
|
+
* an existence-only check. The check instead compares the origin branch's tip SHA
|
|
18
|
+
* against the worker's own HEAD commit (`headSha`, from git telemetry) whenever
|
|
19
|
+
* that comparison is available.
|
|
20
|
+
*/
|
|
21
|
+
import { secretFreeErrorMessage, WorkerFinalizationMissingRemoteBranchAndPr } from "./job-errors.js";
|
|
22
|
+
import { isImplementationStyleJobType } from "./job-types.js";
|
|
23
|
+
function extractPrUrl(result) {
|
|
24
|
+
const raw = result.pr_url;
|
|
25
|
+
return typeof raw === "string" && raw.trim().length > 0 ? raw.trim() : undefined;
|
|
26
|
+
}
|
|
27
|
+
/** Normalize to exactly one `refs/heads/` prefix — never `refs/heads/refs/heads/...`. */
|
|
28
|
+
function normalizeBranchRef(branch) {
|
|
29
|
+
return branch.startsWith("refs/heads/") ? branch : `refs/heads/${branch}`;
|
|
30
|
+
}
|
|
31
|
+
/** Resolve the origin branch's current tip SHA, or `null` if the ref is absent. */
|
|
32
|
+
async function resolveOriginBranchSha(runCommand, worktreePath, branch) {
|
|
33
|
+
const result = await runCommand("git", ["ls-remote", "--exit-code", "--heads", "origin", normalizeBranchRef(branch)], { cwd: worktreePath });
|
|
34
|
+
if (result.exitCode !== 0)
|
|
35
|
+
return null;
|
|
36
|
+
const sha = result.stdout.trim().split(/\s+/)[0];
|
|
37
|
+
return sha && sha.length > 0 ? sha : null;
|
|
38
|
+
}
|
|
39
|
+
function missingBranchAndPrFailure(job, detail) {
|
|
40
|
+
const label = job.ticket_key ? `${job.ticket_key} (job ${job.id})` : `job ${job.id}`;
|
|
41
|
+
return {
|
|
42
|
+
error_kind: WorkerFinalizationMissingRemoteBranchAndPr,
|
|
43
|
+
error_message: `${label} exited cleanly but ${detail}`,
|
|
44
|
+
classification: "crashed",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
/**
|
|
48
|
+
* Validate that an implementation-style job's clean exit actually produced a PR
|
|
49
|
+
* or a pushed origin branch. Non-implementation-style jobs (verdict jobs) and
|
|
50
|
+
* any result carrying a non-empty `pr_url` bypass the git check entirely.
|
|
51
|
+
*/
|
|
52
|
+
export async function validateWorkerFinalization(input) {
|
|
53
|
+
const { job, branch, worktreePath, result, runCommand, headSha } = input;
|
|
54
|
+
if (!isImplementationStyleJobType(job.job_type)) {
|
|
55
|
+
return { ok: true };
|
|
56
|
+
}
|
|
57
|
+
if (extractPrUrl(result)) {
|
|
58
|
+
return { ok: true };
|
|
59
|
+
}
|
|
60
|
+
const trimmedBranch = typeof branch === "string" ? branch.trim() : "";
|
|
61
|
+
if (!trimmedBranch) {
|
|
62
|
+
return {
|
|
63
|
+
ok: false,
|
|
64
|
+
failure: missingBranchAndPrFailure(job, "produced no PR URL and no expected branch to verify on origin"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
let remoteSha;
|
|
68
|
+
try {
|
|
69
|
+
remoteSha = await resolveOriginBranchSha(runCommand, worktreePath, trimmedBranch);
|
|
70
|
+
}
|
|
71
|
+
catch (err) {
|
|
72
|
+
return {
|
|
73
|
+
ok: false,
|
|
74
|
+
failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' could not be verified on origin: ${secretFreeErrorMessage(err)}`),
|
|
75
|
+
};
|
|
76
|
+
}
|
|
77
|
+
if (remoteSha === null) {
|
|
78
|
+
return {
|
|
79
|
+
ok: false,
|
|
80
|
+
failure: missingBranchAndPrFailure(job, `produced no PR URL and branch '${trimmedBranch}' is not on origin`),
|
|
81
|
+
};
|
|
82
|
+
}
|
|
83
|
+
// Recovery/resume jobs fetch `origin/<branch>` before the worker even starts,
|
|
84
|
+
// so the ref merely existing is not proof this session's commit landed. When
|
|
85
|
+
// the worker's own HEAD is known, require the origin tip to match it exactly —
|
|
86
|
+
// a stale pre-existing branch (this session's push never happened) fails loud
|
|
87
|
+
// instead of passing. When HEAD is unresolved (degraded telemetry), fall back
|
|
88
|
+
// to the existence-only check rather than inventing a new failure mode.
|
|
89
|
+
const trimmedHeadSha = typeof headSha === "string" ? headSha.trim() : "";
|
|
90
|
+
if (trimmedHeadSha && remoteSha !== trimmedHeadSha) {
|
|
91
|
+
return {
|
|
92
|
+
ok: false,
|
|
93
|
+
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`),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
return { ok: true };
|
|
97
|
+
}
|
|
@@ -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
|
+
}
|