@bridge_gpt/mcp-server 0.2.62 → 0.2.66

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.
Files changed (113) hide show
  1. package/README.md +66 -15
  2. package/build/agent-launchers/claude-executor-adapter.js +145 -20
  3. package/build/agent-launchers/executor-adapter.js +81 -2
  4. package/build/agents.generated.js +10 -1
  5. package/build/bounded-wait.js +32 -1
  6. package/build/bridge-config.js +104 -0
  7. package/build/codex-bridge-profile.js +10 -1
  8. package/build/commands.generated.js +7 -6
  9. package/build/conductor/bridge-api-client.js +565 -32
  10. package/build/conductor/bring-up-facts.js +3 -1
  11. package/build/conductor/doctor.js +23 -14
  12. package/build/conductor/epic-child-continuation.js +351 -0
  13. package/build/conductor/epic-implementer-cli.js +154 -17
  14. package/build/conductor/git-hooks.js +529 -118
  15. package/build/conductor/guided-repair.js +323 -0
  16. package/build/conductor/hook-runtime-qualification.js +340 -0
  17. package/build/conductor/readiness-cli.js +30 -9
  18. package/build/conductor/readiness-sections.js +5 -4
  19. package/build/conductor/readiness.js +45 -12
  20. package/build/conductor/recovery-cli.js +859 -27
  21. package/build/conductor/recovery-operations.js +574 -5
  22. package/build/conductor/repair-contract.js +69 -3
  23. package/build/conductor-bundle-artifacts.js +1 -0
  24. package/build/credential-store.js +52 -13
  25. package/build/diagnostic-text.js +36 -0
  26. package/build/docs.generated.js +3 -2
  27. package/build/doctor.js +147 -21
  28. package/build/drive-epic-key.js +12 -0
  29. package/build/drive-epic.js +1537 -125
  30. package/build/epic-coherence-input-cli.js +137 -0
  31. package/build/epic-coherence-input.js +920 -0
  32. package/build/epic-coherence-input.test-fixtures.js +175 -0
  33. package/build/epic-implementer-bin.js +41 -33
  34. package/build/epic-implementer-bundle-cli.js +14 -1
  35. package/build/epic-launch-diagnostic.js +31 -0
  36. package/build/epic-launch-profile.js +58 -19
  37. package/build/epic-run-status.js +5 -0
  38. package/build/epic-shared/bridge-client.js +46 -4
  39. package/build/executor/cli.js +39 -0
  40. package/build/executor/completion-contract.js +423 -0
  41. package/build/executor/completion-supervision.js +291 -0
  42. package/build/executor/correction-dispatch.js +60 -0
  43. package/build/executor/credentials.js +3 -1
  44. package/build/executor/deps.js +65 -1
  45. package/build/executor/env.js +12 -0
  46. package/build/executor/heartbeat.js +13 -3
  47. package/build/executor/http-client.js +91 -4
  48. package/build/executor/integration-job.js +6 -1
  49. package/build/executor/job-errors.js +61 -4
  50. package/build/executor/job-log-registry.js +340 -73
  51. package/build/executor/job-runner.js +1550 -55
  52. package/build/executor/job-types.js +9 -6
  53. package/build/executor/preflight.js +189 -1
  54. package/build/executor/process-containment.js +756 -0
  55. package/build/executor/process.js +235 -5
  56. package/build/executor/prompt-spec.js +366 -1
  57. package/build/executor/provider-checks.js +69 -0
  58. package/build/executor/rebase-finalization.js +351 -0
  59. package/build/executor/reconcile-overlap-job.js +77 -84
  60. package/build/executor/required-verification.js +313 -0
  61. package/build/executor/required-work-ledger.js +1121 -0
  62. package/build/executor/results.js +142 -0
  63. package/build/executor/resume-pre-spawn.js +87 -4
  64. package/build/executor/runner.js +168 -0
  65. package/build/executor/stale-artifacts.js +5 -4
  66. package/build/executor/test-declaration.js +20 -0
  67. package/build/executor/types.js +13 -0
  68. package/build/executor/watch-cli.js +35 -8
  69. package/build/executor/worker-command.js +70 -34
  70. package/build/executor/worker-finalization.js +168 -26
  71. package/build/executor/worker-guard-hook.js +75 -14
  72. package/build/executor/worker-log.js +137 -70
  73. package/build/executor/worktree-gc.js +30 -6
  74. package/build/executor/worktree.js +13 -0
  75. package/build/executor-launch.js +658 -0
  76. package/build/index.js +594 -1152
  77. package/build/init.js +107 -4
  78. package/build/install-bridge-conductor.js +231 -41
  79. package/build/install-bridge.js +284 -20
  80. package/build/install-doctor.js +172 -30
  81. package/build/install-reexec.js +69 -7
  82. package/build/macos-terminal-permission-guidance.js +16 -0
  83. package/build/merge-pull-request.js +1 -1
  84. package/build/model-thresholds-tool.js +185 -0
  85. package/build/pipelines.generated.js +9 -8
  86. package/build/plan-discovery.js +137 -0
  87. package/build/plan-epic-implementer-eligibility.js +2 -2
  88. package/build/plane/cli.js +37 -3
  89. package/build/plane/member-logs.js +3 -0
  90. package/build/plane/member-roster.js +7 -1
  91. package/build/plane/shutdown.js +59 -1
  92. package/build/plane/status.js +4 -0
  93. package/build/plane/supervisor.js +146 -0
  94. package/build/plane/worker-exit-code.js +2 -0
  95. package/build/project-root.js +99 -0
  96. package/build/readme.generated.js +1 -1
  97. package/build/review-tickets.js +156 -13
  98. package/build/scripts/executor-protocol-contract-driver.js +13 -3
  99. package/build/setup-epic.js +208 -125
  100. package/build/sfcc/setup-status.js +2 -0
  101. package/build/sfcc/tool-wrapper.js +2 -0
  102. package/build/sfcc/tools-configured.js +96 -0
  103. package/build/start-tickets.js +163 -58
  104. package/build/upgrade-cli.js +109 -24
  105. package/build/version.generated.js +3 -3
  106. package/build/windows-npm-entry.js +103 -0
  107. package/build/worker-guard-hook-bin.js +5 -4
  108. package/build/worktree-core.js +6 -5
  109. package/docs/CONDUCTOR.md +116 -2
  110. package/docs/install/mcp-tool-integrations.md +1 -0
  111. package/docs/install/sfcc-integration.md +12 -7
  112. package/package.json +2 -1
  113. package/pipelines/implement-ticket.json +1 -1
@@ -63,6 +63,12 @@ export const WORKER_GUARD_MALFORMED_LINE = `${WORKER_GUARD_REASON_PREFIX}: paylo
63
63
  * than a rule a caller is asked to remember.
64
64
  */
65
65
  export function renderWorkerGuardReason(category, family) {
66
+ if (category === "git-ambiguous") {
67
+ return (`${WORKER_GUARD_REASON_PREFIX}: denied ${family} (${category}). ` +
68
+ "This shell form cannot be safely resolved: substitution, redirection, or " +
69
+ "stdin-fed arguments hide what git will run. Use literal arguments instead; " +
70
+ "for a commit message, write it to a file and run git commit -F <file>.");
71
+ }
66
72
  return (`${WORKER_GUARD_REASON_PREFIX}: denied ${family} (${category}). ` +
67
73
  "This command family is restricted to the worker's own branch, worktree, and " +
68
74
  "read-only network access. Use a different approach and continue.");
@@ -618,11 +624,30 @@ function classify(executable) {
618
624
  return "network";
619
625
  return null;
620
626
  }
627
+ /**
628
+ * The category an unmodelable `git` segment denies with.
629
+ *
630
+ * CLASSIFICATION ONLY. The caller has already decided to deny; this picks the
631
+ * truthful reason. A literal `push`, `reset`, or `clean` subcommand keeps its own
632
+ * category, because the worker does need to know it hit that restriction. Anything
633
+ * else — a literal `commit`, or a subcommand token built from `$…` or a backtick —
634
+ * is `git-ambiguous`: no dynamic token is ever guessed to be `push`.
635
+ */
636
+ function unsafeGitCategory(argv) {
637
+ const { subcommand } = parseGitInvocation(argv, undefined);
638
+ if (subcommand === "push")
639
+ return "git-push";
640
+ if (subcommand === "reset")
641
+ return "git-reset-hard";
642
+ if (subcommand === "clean")
643
+ return "filesystem-clean";
644
+ return "git-ambiguous";
645
+ }
621
646
  /** The category a governed family denies with when its segment is unmodelable. */
622
- function unsafeSegmentDecision(family) {
647
+ function unsafeSegmentDecision(family, argv) {
623
648
  switch (family) {
624
649
  case "git":
625
- return { kind: "deny", category: "git-push", family: "git" };
650
+ return { kind: "deny", category: unsafeGitCategory(argv), family: "git" };
626
651
  case "rm":
627
652
  return { kind: "deny", category: "filesystem-remove", family: "rm" };
628
653
  case "database":
@@ -810,11 +835,11 @@ function evaluateSimpleCommand(segment, ctx, payloadCwd, depth = 0, stdinFed = f
810
835
  // under `stdinFed` can never be proven safe regardless of how its visible argv
811
836
  // looks — the fail-CLOSED rule, applied after every wrapper has been resolved.
812
837
  if (stdinFed)
813
- return unsafeSegmentDecision(family);
838
+ return unsafeSegmentDecision(family, segment.argv);
814
839
  // A governed family inside a segment carrying substitution or redirection can
815
840
  // never be proven safe — deny, per the fail-CLOSED half of the contract.
816
841
  if (segment.unsafe)
817
- return unsafeSegmentDecision(family);
842
+ return unsafeSegmentDecision(family, segment.argv);
818
843
  const cwd = resolveEffectiveCwd(ctx, payloadCwd);
819
844
  if (family === "rm")
820
845
  return evaluateRemove(segment.argv, ctx, cwd);
@@ -854,19 +879,45 @@ function evaluateSimpleCommand(segment, ctx, payloadCwd, depth = 0, stdinFed = f
854
879
  */
855
880
  export function evaluateWorkerGuardPayload(payload, ctx) {
856
881
  const root = asRecord(payload);
882
+ if (ctx.correctionContextInvalid)
883
+ return { kind: "deny", category: "workflow-entry", family: "workflow" };
857
884
  if (root === undefined)
858
- return { kind: "malformed" };
885
+ return ctx.correctionEnforced
886
+ ? { kind: "deny", category: "workflow-entry", family: "workflow" }
887
+ : { kind: "malformed" };
859
888
  const toolName = root.tool_name ?? root.toolName;
860
889
  if (typeof toolName !== "string")
861
- return { kind: "malformed" };
890
+ return ctx.correctionEnforced
891
+ ? { kind: "deny", category: "workflow-entry", family: "workflow" }
892
+ : { kind: "malformed" };
893
+ const input = asRecord(root.tool_input ?? root.toolInput);
894
+ if (ctx.correctionEnforced) {
895
+ if (input === undefined)
896
+ return { kind: "deny", category: "workflow-entry", family: "workflow" };
897
+ const args = Object.values(input).filter((v) => typeof v === "string").join(" ");
898
+ const fullEntry = /(?:^|\s|\/)\/?(?:bridge-)?(?:implement-ticket|plan-ticket|idea-to-pr|review-and-implement)(?:\s|$|\.)/i;
899
+ const recipePath = /(?:commands\/src|\.claude\/commands|\.cursor\/commands|pipelines)\/(?:implement-ticket|plan-ticket)(?:\.md|\.json)?/i;
900
+ const plannerTool = /(?:request_plan_generation|get_pipeline_recipe|plan_ticket|implement_ticket)/i;
901
+ if (recipePath.test(args) || fullEntry.test(args) ||
902
+ (plannerTool.test(toolName) && toolName !== "Bash")) {
903
+ return { kind: "deny", category: "workflow-entry", family: "workflow" };
904
+ }
905
+ if (/^(?:Task|Agent|Skill|SlashCommand|mcp__)/i.test(toolName) &&
906
+ (fullEntry.test(args) || plannerTool.test(args))) {
907
+ return { kind: "deny", category: "workflow-entry", family: "workflow" };
908
+ }
909
+ }
862
910
  if (toolName !== "Bash")
863
911
  return { kind: "allow" };
864
- const input = asRecord(root.tool_input ?? root.toolInput);
865
912
  if (input === undefined)
866
- return { kind: "malformed" };
913
+ return ctx.correctionEnforced
914
+ ? { kind: "deny", category: "workflow-entry", family: "workflow" }
915
+ : { kind: "malformed" };
867
916
  const command = input.command;
868
917
  if (typeof command !== "string")
869
- return { kind: "malformed" };
918
+ return ctx.correctionEnforced
919
+ ? { kind: "deny", category: "workflow-entry", family: "workflow" }
920
+ : { kind: "malformed" };
870
921
  const rawCwd = root.cwd ?? input.cwd;
871
922
  const payloadCwd = typeof rawCwd === "string" ? rawCwd : undefined;
872
923
  for (const segment of tokenizeShellCommand(command)) {
@@ -883,18 +934,21 @@ export function evaluateWorkerGuardPayload(payload, ctx) {
883
934
  * exception: the binary's job is to emit one bounded line and get out of the way.
884
935
  */
885
936
  export function evaluateWorkerGuardInput(raw, ctx) {
937
+ const unreadable = () => ctx.correctionEnforced || ctx.correctionContextInvalid
938
+ ? { kind: "deny", category: "workflow-entry", family: "workflow" }
939
+ : { kind: "malformed" };
886
940
  if (typeof raw !== "string")
887
- return { kind: "malformed" };
941
+ return unreadable();
888
942
  if (raw.length > WORKER_GUARD_MAX_PAYLOAD_BYTES)
889
- return { kind: "malformed" };
943
+ return unreadable();
890
944
  if (raw.trim().length === 0)
891
- return { kind: "malformed" };
945
+ return unreadable();
892
946
  let parsed;
893
947
  try {
894
948
  parsed = JSON.parse(raw);
895
949
  }
896
950
  catch {
897
- return { kind: "malformed" };
951
+ return unreadable();
898
952
  }
899
953
  try {
900
954
  return evaluateWorkerGuardPayload(parsed, ctx);
@@ -902,7 +956,7 @@ export function evaluateWorkerGuardInput(raw, ctx) {
902
956
  catch {
903
957
  // Defensive: an unexpected evaluator failure is a HOOK problem, not a worker
904
958
  // problem, so it takes the fail-open path rather than blocking the tool loop.
905
- return { kind: "malformed" };
959
+ return unreadable();
906
960
  }
907
961
  }
908
962
  /** The flag through which provisioning templates the trusted worktree root. */
@@ -943,5 +997,12 @@ export function resolveWorkerGuardContext(env, cwd, argv = []) {
943
997
  const branch = env.BAPI_WORKER_BRANCH;
944
998
  if (typeof branch === "string" && branch.trim().length > 0)
945
999
  ctx.workerBranch = branch.trim();
1000
+ const flagIndex = argv.indexOf("--correction-intent-version");
1001
+ const flagValue = flagIndex >= 0 ? argv[flagIndex + 1] : undefined;
1002
+ const envValue = env.BAPI_CORRECTION_INTENT_VERSION;
1003
+ ctx.correctionEnforced = flagValue === "1" || envValue === "1";
1004
+ ctx.correctionContextInvalid = flagIndex >= 0 && flagValue !== "1" ||
1005
+ (envValue !== undefined && envValue !== "1") ||
1006
+ (flagIndex >= 0 && envValue !== undefined && envValue !== flagValue);
946
1007
  return ctx;
947
1008
  }
@@ -1,21 +1,70 @@
1
1
  /**
2
- * Per-job worker log tee primitives (BAPI-535, TDD §8, G5).
2
+ * Per-attempt worker log tee primitives (BAPI-535, TDD §8, G5; BAPI-1330).
3
+ *
4
+ * Every worker's output is teed to a log file that belongs to exactly ONE claimed
5
+ * attempt: `<worktree>/.conductor/worker-job<id>-attempt<n>-<claim>.log`, where
6
+ * `<claim>` is an opaque identifier minted fresh for each claim. The file is
7
+ * created EXCLUSIVELY before the first write, so a retry, a reclaim, or a reused
8
+ * worktree can never truncate, rotate, or append into an earlier attempt's
9
+ * transcript — and a reference to one attempt can never resolve to another's
10
+ * contents.
11
+ *
12
+ * BAPI-1330 retired the fixed `.conductor/worker.log` path. Its BAPI-1021
13
+ * rotation (rename the old file to a timestamped archive, truncate a new one)
14
+ * left the job-log registry pointing at a MUTABLE path that the next attempt
15
+ * reused, and a timestamp is not collision protection. There is no active alias
16
+ * any more: readers go through the registry, which names the stable file.
3
17
  *
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
18
  * 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.
19
+ * process, and a logging failure never changes a worker's disposition — it only
20
+ * marks this attempt's transcript unavailable. All filesystem access is behind
21
+ * injected boundaries so this is unit-testable with no real I/O.
9
22
  */
23
+ import { randomBytes as nodeRandomBytes } from "node:crypto";
10
24
  import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
11
25
  /** The fixed `.conductor` worker-log directory (relative to the worktree). */
12
26
  export const CONDUCTOR_WORKER_LOG_DIR = ".conductor";
13
- /** The fixed worker-log filename. */
27
+ /**
28
+ * The RETIRED fixed worker-log filename (BAPI-1330). Nothing writes it any more;
29
+ * it is kept only so readers can recognize a legacy registry record that names
30
+ * it and report that record as unavailable instead of following a mutable alias.
31
+ */
14
32
  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) {
33
+ /** An opaque per-claim identifier: 32 lower-case hex characters. */
34
+ export const WORKER_LOG_CLAIM_ID_RE = /^[0-9a-f]{32}$/;
35
+ /**
36
+ * The stable attempt-log filename shape. Groups: job id, attempt, claim id.
37
+ * Readers validate a registry record against this before returning a pointer.
38
+ */
39
+ export const WORKER_LOG_ATTEMPT_FILE_RE = /^worker-job([1-9][0-9]*)-attempt([0-9]+)-([0-9a-f]{32})\.log$/;
40
+ /** Mint a fresh opaque claim identifier (16 random bytes, hex). */
41
+ export function createWorkerLogClaimId(randomBytes = nodeRandomBytes) {
42
+ return randomBytes(16).toString("hex");
43
+ }
44
+ /** True for a well-formed attempt identity. */
45
+ export function isValidWorkerLogAttemptIdentity(identity) {
46
+ return (Number.isInteger(identity.jobId) &&
47
+ identity.jobId > 0 &&
48
+ Number.isInteger(identity.attempt) &&
49
+ identity.attempt >= 0 &&
50
+ typeof identity.claimId === "string" &&
51
+ WORKER_LOG_CLAIM_ID_RE.test(identity.claimId));
52
+ }
53
+ /**
54
+ * The stable filename for one attempt. Throws on a malformed identity: every
55
+ * component is validated so nothing can escape `.conductor` or collide with
56
+ * another attempt's name.
57
+ */
58
+ export function workerLogAttemptFileName(identity) {
59
+ if (!isValidWorkerLogAttemptIdentity(identity)) {
60
+ throw new Error("invalid worker log attempt identity");
61
+ }
62
+ return `worker-job${identity.jobId}-attempt${identity.attempt}-${identity.claimId}.log`;
63
+ }
64
+ /** Resolve `<worktree>/.conductor/<attempt file>` with platform-correct separators. */
65
+ export function buildWorkerLogPath(worktreePath, identity, platform = process.platform) {
17
66
  const pathApi = pathApiForExecutorPlatform(platform);
18
- return pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR, CONDUCTOR_WORKER_LOG_FILE);
67
+ return pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR, workerLogAttemptFileName(identity));
19
68
  }
20
69
  /**
21
70
  * Render one stdout/stderr chunk as human-readable log text. The worker's stream
@@ -26,81 +75,97 @@ export function buildWorkerLogPath(worktreePath, platform = process.platform) {
26
75
  export function renderWorkerLogChunk(chunk, _stream = "stdout") {
27
76
  return chunk;
28
77
  }
29
- /**
30
- * True for a Node-`fs`-shaped "does not exist" rejection (`code: "ENOENT"`).
31
- * The normal, expected outcome of probing a first-ever attempt's log path —
32
- * never itself a reason to warn.
33
- */
34
- function isMissingFileError(err) {
35
- return typeof err === "object" && err !== null && err.code === "ENOENT";
78
+ function errorCode(err) {
79
+ return typeof err === "object" && err !== null ? err.code : undefined;
36
80
  }
37
81
  /**
38
- * The bounded, secret-free archive-preservation warning (BAPI-1021, AC-6).
39
- * Carries ONLY the stable category and the current/archive path identifiers —
40
- * never the caught exception's message or stack. Emitted at most once per tee
41
- * creation, whether the existence probe or the rename itself failed.
82
+ * The bounded, secret-free transcript-unavailable warning (BAPI-1330). Carries
83
+ * only the job id, attempt number and a closed reason — never a path, an
84
+ * exception message, or transcript content.
42
85
  */
43
- export function formatWorkerLogArchivePreservationWarning(currentLogPath, archivePath) {
44
- return archivePath
45
- ? `worker log archive: could not preserve the prior attempt's log ` +
46
- `(${currentLogPath} -> ${archivePath}); the new attempt continues.`
47
- : `worker log archive: could not confirm whether a prior attempt's log exists ` +
48
- `at ${currentLogPath}; the new attempt continues.`;
86
+ export function formatWorkerLogUnavailableWarning(identity, reason) {
87
+ return (`worker log unavailable for job ${identity.jobId} attempt ${identity.attempt} (${reason}); ` +
88
+ "the attempt continues without a recorded transcript.");
49
89
  }
50
- /**
51
- * Create the `.conductor` directory, initialize an empty `worker.log`, and return
52
- * a tee handle. When an `appendFile` boundary is supplied, chunks are appended
53
- * incrementally; otherwise a buffered fallback re-writes the accumulated content
54
- * via `writeFile` so all consumed chunks are preserved in order.
55
- *
56
- * BAPI-1021 (AC-6): before writing the new empty log, archives any PRIOR
57
- * attempt's log under a timestamped filename in the same directory — so a
58
- * retried job in the same worktree does not silently overwrite what the
59
- * previous attempt wrote. Best-effort throughout: a missing prior log is the
60
- * normal first-attempt case (no warning); a probe or rename failure degrades
61
- * to exactly one bounded warning and tee creation still proceeds.
62
- */
63
- export async function createWorkerLogTee(worktreePath, deps) {
64
- const platform = deps.platform ?? process.platform;
65
- const pathApi = pathApiForExecutorPlatform(platform);
66
- const dir = pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR);
67
- const logPath = pathApi.join(dir, CONDUCTOR_WORKER_LOG_FILE);
68
- await deps.mkdir(dir, { recursive: true });
69
- if (deps.stat && deps.rename) {
90
+ /** Create the attempt file exclusively. Resolves with a reason when it could not. */
91
+ async function allocateAttemptLog(logPath, deps) {
92
+ try {
93
+ if (deps.writeFileExclusive) {
94
+ await deps.writeFileExclusive(logPath, "");
95
+ return null;
96
+ }
97
+ if (!deps.stat)
98
+ return "allocation_failed";
70
99
  try {
71
100
  await deps.stat(logPath);
72
101
  }
73
102
  catch (err) {
74
- if (!isMissingFileError(err)) {
75
- deps.errorLog?.(formatWorkerLogArchivePreservationWarning(logPath));
76
- }
77
- // Missing (or unconfirmable) is the normal first-attempt case either
78
- // way: fall through to the truncating write below without a rename.
103
+ if (errorCode(err) !== "ENOENT")
104
+ return "allocation_failed";
79
105
  await deps.writeFile(logPath, "");
80
- return createTeeHandle(logPath, deps);
81
- }
82
- // The current log exists: archive it before truncating.
83
- const clock = deps.now ?? Date.now;
84
- const timestamp = new Date(clock()).toISOString().replace(/:/g, "-");
85
- const archivePath = pathApi.join(dir, `worker.${timestamp}.log`);
86
- try {
87
- await deps.rename(logPath, archivePath);
88
- }
89
- catch {
90
- deps.errorLog?.(formatWorkerLogArchivePreservationWarning(logPath, archivePath));
106
+ return null;
91
107
  }
108
+ return "allocation_collision";
109
+ }
110
+ catch (err) {
111
+ return errorCode(err) === "EEXIST" ? "allocation_collision" : "allocation_failed";
92
112
  }
93
- await deps.writeFile(logPath, "");
94
- return createTeeHandle(logPath, deps);
95
113
  }
96
- /** Shared tee-handle construction, used by every `createWorkerLogTee` return path. */
97
- function createTeeHandle(logPath, deps) {
114
+ /**
115
+ * Allocate this attempt's log file and return a tee for it.
116
+ *
117
+ * The file is created EXCLUSIVELY before any append. An existing file is refused,
118
+ * never truncated or archived: a collision means some other claim already owns
119
+ * that name, and its transcript must survive. Allocation failure does NOT throw —
120
+ * the tee comes back `available: false` (appends become no-ops), one bounded
121
+ * diagnostic is emitted, and the worker runs exactly as it would have.
122
+ */
123
+ export async function createWorkerLogTee(worktreePath, deps, identity) {
124
+ const platform = deps.platform ?? process.platform;
125
+ const pathApi = pathApiForExecutorPlatform(platform);
126
+ const dir = pathApi.join(worktreePath, CONDUCTOR_WORKER_LOG_DIR);
127
+ const logPath = buildWorkerLogPath(worktreePath, identity, platform);
128
+ let reason;
129
+ try {
130
+ await deps.mkdir(dir, { recursive: true });
131
+ reason = await allocateAttemptLog(logPath, deps);
132
+ }
133
+ catch {
134
+ reason = "allocation_failed";
135
+ }
136
+ const handle = createTeeHandle(logPath, identity, deps);
137
+ if (reason !== null)
138
+ handle.markUnavailable(reason);
139
+ return handle;
140
+ }
141
+ /** Shared tee-handle construction. */
142
+ function createTeeHandle(logPath, identity, deps) {
98
143
  let buffer = "";
99
144
  let closed = false;
145
+ let unavailableReason;
146
+ const markUnavailable = (reason) => {
147
+ if (unavailableReason !== undefined)
148
+ return;
149
+ unavailableReason = reason;
150
+ try {
151
+ deps.errorLog?.(formatWorkerLogUnavailableWarning(identity, reason));
152
+ }
153
+ catch {
154
+ /* the diagnostic is best-effort too */
155
+ }
156
+ };
100
157
  return {
101
158
  logPath,
159
+ identity,
160
+ get available() {
161
+ return unavailableReason === undefined;
162
+ },
163
+ get unavailableReason() {
164
+ return unavailableReason;
165
+ },
166
+ markUnavailable,
102
167
  async append(chunk) {
103
- if (closed)
168
+ if (closed || unavailableReason !== undefined)
104
169
  return;
105
170
  try {
106
171
  if (deps.appendFile) {
@@ -112,7 +177,9 @@ function createTeeHandle(logPath, deps) {
112
177
  }
113
178
  }
114
179
  catch {
115
- /* the log tee is a visibility nicety — a write failure never fails a job */
180
+ // A write failure never fails a job; it makes this attempt's transcript
181
+ // incomplete, which readers must be told rather than left to guess.
182
+ markUnavailable("append_failed");
116
183
  }
117
184
  },
118
185
  async close() {
@@ -146,7 +213,7 @@ export const MAX_SESSION_SCAN_CHARS = 2_000_000;
146
213
  * The DISTINCT `session_id` values present in a worker log (BAPI-793).
147
214
  *
148
215
  * The incident signature this exists to catch is two Claude sessions writing
149
- * into one worktree's single `.conductor/worker.log`. Every stream-json record a
216
+ * into one attempt's worker log. Every stream-json record a
150
217
  * worker emits carries its `session_id`, so one healthy job produces MANY
151
218
  * records and exactly ONE distinct id — which is why this counts DISTINCT ids
152
219
  * rather than occurrences. A count-based check would flag every normal job.
@@ -210,7 +277,7 @@ export function distinctWorkerLogSessionIds(logText) {
210
277
  */
211
278
  export function formatWorkerLogSessionInvariantWarning(sessionCount) {
212
279
  return (`worker log session invariant violated: ${sessionCount} distinct Claude sessions wrote to this ` +
213
- "job's .conductor/worker.log, so more than one worker was active in this worktree. The " +
280
+ "attempt's .conductor worker log, so more than one worker was active in this worktree. The " +
214
281
  "worktree lock should have refused the second one — treat this as evidence it was bypassed " +
215
282
  "(a pre-existing stale lock, a worktree entered outside the executor, or a manual claude run).");
216
283
  }
@@ -10,6 +10,7 @@
10
10
  * never abort the whole sweep. All I/O is injected for offline unit testing.
11
11
  */
12
12
  import { listGitWorktrees, pathApiForExecutorPlatform, } from "./worktree-inspection.js";
13
+ import { WORKER_LOG_ATTEMPT_FILE_RE } from "./worker-log.js";
13
14
  /** Idle cutoff: a worktree must be idle strictly longer than 24h to be eligible. */
14
15
  export const EXECUTOR_WORKTREE_GC_IDLE_MS = 24 * 60 * 60 * 1000;
15
16
  function conductorMarkerPaths(worktreePath, platform) {
@@ -17,30 +18,53 @@ function conductorMarkerPaths(worktreePath, platform) {
17
18
  const dir = pathApi.join(worktreePath, ".conductor");
18
19
  return {
19
20
  dir,
20
- workerLog: pathApi.join(dir, "worker.log"),
21
21
  resultJson: pathApi.join(dir, "result.json"),
22
22
  };
23
23
  }
24
+ /**
25
+ * The per-attempt worker logs in `.conductor` (BAPI-1330). Only names matching
26
+ * the stable attempt-log shape count; the retired fixed `worker.log` is never
27
+ * probed. A listing failure yields none — the other markers still apply.
28
+ */
29
+ async function attemptLogPaths(dir, deps) {
30
+ if (!deps.listDir)
31
+ return [];
32
+ let names;
33
+ try {
34
+ names = await deps.listDir(dir);
35
+ }
36
+ catch {
37
+ return [];
38
+ }
39
+ const pathApi = pathApiForExecutorPlatform(deps.platform ?? process.platform);
40
+ return names.filter((name) => WORKER_LOG_ATTEMPT_FILE_RE.test(name)).map((name) => pathApi.join(dir, name));
41
+ }
24
42
  /**
25
43
  * Conservatively identify an executor/conductor-managed worktree by the presence
26
- * of a `.conductor` directory, `.conductor/worker.log`, or `.conductor/result.json`.
44
+ * of a `.conductor` directory or `.conductor/result.json`.
27
45
  */
28
46
  export async function isExecutorManagedWorktree(worktreePath, deps) {
29
47
  const markers = conductorMarkerPaths(worktreePath, deps.platform ?? process.platform);
30
- for (const marker of [markers.workerLog, markers.resultJson, markers.dir]) {
48
+ for (const marker of [markers.resultJson, markers.dir]) {
31
49
  if ((await deps.statMtimeMs(marker)) !== null)
32
50
  return true;
33
51
  }
34
52
  return false;
35
53
  }
36
54
  /**
37
- * Compute last activity as the NEWEST mtime among `.conductor/worker.log`,
38
- * `.conductor/result.json`, and the worktree directory itself. Returns `null`
55
+ * Compute last activity as the NEWEST mtime among the per-attempt worker logs
56
+ * in `.conductor` (the newest attempt log wins), `.conductor/result.json`, the
57
+ * `.conductor` directory, and the worktree directory itself. Returns `null`
39
58
  * when none can be stat'd (activity cannot be proven → the caller retains).
40
59
  */
41
60
  export async function getWorktreeLastActivityMs(worktreePath, deps) {
42
61
  const markers = conductorMarkerPaths(worktreePath, deps.platform ?? process.platform);
43
- const candidates = [markers.workerLog, markers.resultJson, worktreePath];
62
+ const candidates = [
63
+ ...(await attemptLogPaths(markers.dir, deps)),
64
+ markers.resultJson,
65
+ markers.dir,
66
+ worktreePath,
67
+ ];
44
68
  let newest = null;
45
69
  for (const candidate of candidates) {
46
70
  const mtime = await deps.statMtimeMs(candidate);
@@ -95,6 +95,19 @@ async function finalizeExecutorWorktree(worktreePath, branch, expectedHeadSha, d
95
95
  }
96
96
  return { ok: true, worktreePath, branch, expectedHeadSha };
97
97
  }
98
+ /**
99
+ * BAPI-1300 — finish a RETAINED continuation worktree the same way a created one
100
+ * is finished: pin its current HEAD as the pre-spawn expectation and provision
101
+ * its commands. The worktree itself is not touched: no fetch, no reset, no
102
+ * recreation — its (already verified and checkpointed) local state is the work.
103
+ */
104
+ export async function finalizeRetainedExecutorWorktree(worktreePath, branch, deps) {
105
+ const headSha = await resolveCommitSha(deps, worktreePath, "HEAD");
106
+ if (headSha === null) {
107
+ return { ok: false, error: `failed to resolve HEAD of the retained worktree for '${branch}'` };
108
+ }
109
+ return finalizeExecutorWorktree(worktreePath, branch, headSha, deps);
110
+ }
98
111
  /** A full 40-character hex object name. */
99
112
  const FULL_SHA_PATTERN = /^[0-9a-f]{40}$/i;
100
113
  /**