@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
@@ -1,17 +1,34 @@
1
1
  /**
2
- * Local executor job-log registry (BAPI-535, TDD §8).
2
+ * Local executor job-log registry (BAPI-535, TDD §8; BAPI-1330).
3
3
  *
4
- * Persists a small, SECRET-FREE mapping from an executor job id to its worker-log
5
- * path so a SEPARATE CLI process (`executor watch <job>`) can attach read-only to
6
- * the log. Records carry only routing metadata (ids, repo, job type, paths,
7
- * timestamps) — never job payloads, API tokens, or auth material. All filesystem
8
- * / env access is injected so the registry is unit-testable with no real I/O.
4
+ * Persists a small, SECRET-FREE mapping from an executor job id to the worker-log
5
+ * file of EACH of its attempts, so a SEPARATE CLI process (`executor watch <job>`)
6
+ * can attach read-only to the right transcript. Records carry only routing
7
+ * metadata (ids, repo, job type, the stable attempt path, timestamps,
8
+ * availability) — never job payloads, API tokens, auth material, or transcript
9
+ * text. The paths stay LOCAL: nothing here is ever sent to the server. All
10
+ * filesystem / env access is injected so the registry is unit-testable with no
11
+ * real I/O.
12
+ *
13
+ * BAPI-1330 made the registry attempt-aware. One file per job holds a bounded
14
+ * list of attempt records keyed by the opaque per-claim identity; a later claim
15
+ * APPENDS rather than overwriting, the file is replaced atomically (temporary
16
+ * file + rename) under a per-job serialization, and a reader validates the
17
+ * record's job id, attempt, claim identity and stable filename before returning
18
+ * a pointer. A legacy record that names the retired fixed `worker.log` — whose
19
+ * contents may belong to any later attempt — is reported unavailable, never
20
+ * followed.
9
21
  */
22
+ import { CONDUCTOR_WORKER_LOG_FILE, isValidWorkerLogAttemptIdentity, WORKER_LOG_ATTEMPT_FILE_RE, workerLogAttemptFileName, } from "./worker-log.js";
10
23
  import { pathApiForExecutorPlatform } from "./worktree-inspection.js";
11
24
  /** Default state-dir basename under the system temp dir. */
12
25
  const DEFAULT_STATE_DIR_NAME = "bridge-executor-job-logs";
13
26
  /** Env var that overrides the executor state directory. */
14
27
  export const EXECUTOR_STATE_DIR_ENV = "BAPI_EXECUTOR_STATE_DIR";
28
+ /** The registry file format this module writes. Version 1 was the flat BAPI-535 record. */
29
+ export const EXECUTOR_JOB_LOG_REGISTRY_VERSION = 2;
30
+ /** Upper bound on retained attempt records per job; the oldest are dropped first. */
31
+ export const MAX_REGISTERED_ATTEMPTS = 32;
15
32
  /**
16
33
  * Resolve the executor state directory: the `BAPI_EXECUTOR_STATE_DIR` override
17
34
  * when set, else a stable directory under the system temp dir (independent of
@@ -34,92 +51,269 @@ export function jobLogRecordPath(stateDir, jobId, platform = process.platform) {
34
51
  const safeId = String(jobId).replace(/[^0-9]/g, "") || "0";
35
52
  return pathApi.join(stateDir, `job-${safeId}.json`);
36
53
  }
37
- /** Whitelist the persisted fields so no extra (possibly secret) field leaks in. */
38
- function normalizeRecord(record) {
54
+ function isNonEmptyString(value) {
55
+ return typeof value === "string" && value.length > 0;
56
+ }
57
+ /** Whitelist one attempt entry so no extra (possibly secret) field leaks in. */
58
+ function normalizeAttempt(entry) {
39
59
  const out = {
40
- job_id: record.job_id,
41
- repo_name: record.repo_name,
42
- job_type: record.job_type,
43
- log_path: record.log_path,
44
- started_at: record.started_at,
60
+ claim_id: entry.claim_id,
61
+ attempt: entry.attempt,
62
+ log_path: entry.log_path,
63
+ log_file: entry.log_file,
64
+ started_at: entry.started_at,
65
+ availability: entry.availability === "unavailable" ? "unavailable" : "available",
45
66
  };
46
- if (record.ticket_key)
47
- out.ticket_key = record.ticket_key;
48
- if (record.worktree_path)
49
- out.worktree_path = record.worktree_path;
50
- if (record.finished_at)
51
- out.finished_at = record.finished_at;
67
+ if (entry.finished_at)
68
+ out.finished_at = entry.finished_at;
69
+ if (out.availability === "unavailable" && isNonEmptyString(entry.unavailable_reason)) {
70
+ out.unavailable_reason = entry.unavailable_reason.slice(0, 64);
71
+ }
52
72
  return out;
53
73
  }
54
- function isValidRecord(value) {
55
- if (!value || typeof value !== "object")
74
+ function serialize(file) {
75
+ const out = {
76
+ version: EXECUTOR_JOB_LOG_REGISTRY_VERSION,
77
+ job_id: file.job_id,
78
+ repo_name: file.repo_name,
79
+ job_type: file.job_type,
80
+ attempts: file.attempts.slice(-MAX_REGISTERED_ATTEMPTS).map(normalizeAttempt),
81
+ };
82
+ if (file.ticket_key)
83
+ out.ticket_key = file.ticket_key;
84
+ if (file.worktree_path)
85
+ out.worktree_path = file.worktree_path;
86
+ return JSON.stringify(out, null, 2);
87
+ }
88
+ function isAttemptEntry(value) {
89
+ if (!value || typeof value !== "object" || Array.isArray(value))
56
90
  return false;
57
91
  const v = value;
58
- return (typeof v.job_id === "number" &&
59
- typeof v.repo_name === "string" &&
60
- typeof v.job_type === "string" &&
61
- typeof v.log_path === "string" &&
62
- typeof v.started_at === "string");
92
+ return (isNonEmptyString(v.claim_id) &&
93
+ typeof v.attempt === "number" &&
94
+ isNonEmptyString(v.log_path) &&
95
+ isNonEmptyString(v.log_file) &&
96
+ isNonEmptyString(v.started_at) &&
97
+ (v.availability === "available" || v.availability === "unavailable"));
98
+ }
99
+ /** Classify a parsed JSON value as a v2 registry file, a legacy record, or neither. */
100
+ function parseRegistry(value, jobId) {
101
+ if (!value || typeof value !== "object" || Array.isArray(value))
102
+ return { kind: "invalid" };
103
+ const v = value;
104
+ if (v.version === undefined && typeof v.log_path === "string") {
105
+ // The flat BAPI-535 record. Its `log_path` named the retired fixed alias,
106
+ // whose contents may belong to any later attempt.
107
+ return { kind: "legacy" };
108
+ }
109
+ if (v.version !== EXECUTOR_JOB_LOG_REGISTRY_VERSION ||
110
+ v.job_id !== jobId ||
111
+ !isNonEmptyString(v.repo_name) ||
112
+ !isNonEmptyString(v.job_type) ||
113
+ !Array.isArray(v.attempts)) {
114
+ return { kind: "invalid" };
115
+ }
116
+ const file = {
117
+ version: EXECUTOR_JOB_LOG_REGISTRY_VERSION,
118
+ job_id: jobId,
119
+ repo_name: v.repo_name,
120
+ job_type: v.job_type,
121
+ // Malformed entries are dropped here and so can never be selected; the
122
+ // well-formed ones are validated again, individually, before use.
123
+ attempts: v.attempts.filter(isAttemptEntry),
124
+ };
125
+ if (isNonEmptyString(v.ticket_key))
126
+ file.ticket_key = v.ticket_key;
127
+ if (isNonEmptyString(v.worktree_path))
128
+ file.worktree_path = v.worktree_path;
129
+ return { kind: "v2", file };
130
+ }
131
+ /** Per-job serialization of read/modify/write cycles within this process. */
132
+ const registryLocks = new Map();
133
+ function withJobLock(jobId, fn) {
134
+ const prior = registryLocks.get(jobId) ?? Promise.resolve();
135
+ const next = prior.then(fn, fn);
136
+ const settled = next.then(() => undefined, () => undefined);
137
+ registryLocks.set(jobId, settled);
138
+ void settled.then(() => {
139
+ if (registryLocks.get(jobId) === settled)
140
+ registryLocks.delete(jobId);
141
+ });
142
+ return next;
143
+ }
144
+ let tempCounter = 0;
145
+ /** Replace the record atomically when a rename seam exists. */
146
+ async function writeRegistryFile(filePath, data, deps) {
147
+ if (!deps.rename) {
148
+ await deps.writeFile(filePath, data);
149
+ return;
150
+ }
151
+ tempCounter += 1;
152
+ const tempPath = `${filePath}.${process.pid}.${tempCounter}.tmp`;
153
+ await deps.writeFile(tempPath, data);
154
+ await deps.rename(tempPath, filePath);
63
155
  }
64
- /** Write the secret-free registry record when a worker log is initialized. */
156
+ async function readParsed(filePath, jobId, deps) {
157
+ let raw;
158
+ try {
159
+ raw = await deps.readFile(filePath);
160
+ }
161
+ catch {
162
+ return "missing";
163
+ }
164
+ try {
165
+ return parseRegistry(JSON.parse(raw), jobId);
166
+ }
167
+ catch {
168
+ return "unreadable";
169
+ }
170
+ }
171
+ /**
172
+ * Record one attempt's log when its tee is initialized.
173
+ *
174
+ * Earlier attempts are RETAINED: the new attempt is appended to the job's list
175
+ * (a re-registration of the same claim identity replaces only its own entry). A
176
+ * legacy or unreadable prior record is superseded, because it cannot be read
177
+ * safely anyway. Throws only on an invalid identity or a write failure; callers
178
+ * treat the registry as a visibility nicety and swallow either.
179
+ */
65
180
  export async function registerExecutorJobLog(record, deps) {
181
+ const identity = { jobId: record.job_id, attempt: record.attempt, claimId: record.claim_id };
182
+ const logFile = workerLogAttemptFileName(identity);
183
+ const platform = deps.platform ?? process.platform;
66
184
  const stateDir = resolveExecutorStateDir(deps);
67
- await deps.mkdir(stateDir, { recursive: true });
68
- const filePath = jobLogRecordPath(stateDir, record.job_id, deps.platform ?? process.platform);
69
- await deps.writeFile(filePath, JSON.stringify(normalizeRecord(record), null, 2));
185
+ const filePath = jobLogRecordPath(stateDir, record.job_id, platform);
186
+ await withJobLock(record.job_id, async () => {
187
+ await deps.mkdir(stateDir, { recursive: true });
188
+ const parsed = await readParsed(filePath, record.job_id, deps);
189
+ const prior = typeof parsed === "object" && parsed.kind === "v2" ? parsed.file.attempts : [];
190
+ const entry = {
191
+ claim_id: record.claim_id,
192
+ attempt: record.attempt,
193
+ log_path: record.log_path,
194
+ log_file: logFile,
195
+ started_at: record.started_at,
196
+ availability: record.availability === "unavailable" ? "unavailable" : "available",
197
+ ...(record.unavailable_reason ? { unavailable_reason: record.unavailable_reason } : {}),
198
+ };
199
+ const file = {
200
+ version: EXECUTOR_JOB_LOG_REGISTRY_VERSION,
201
+ job_id: record.job_id,
202
+ repo_name: record.repo_name,
203
+ job_type: record.job_type,
204
+ ...(record.ticket_key ? { ticket_key: record.ticket_key } : {}),
205
+ ...(record.worktree_path ? { worktree_path: record.worktree_path } : {}),
206
+ attempts: [...prior.filter((a) => a.claim_id !== record.claim_id), entry],
207
+ };
208
+ await writeRegistryFile(filePath, serialize(file), deps);
209
+ });
70
210
  }
71
211
  /**
72
- * Stamp `finished_at` on an existing record after terminal completion/failure,
73
- * preserving all existing fields. A missing/invalid record is a no-op (the watch
74
- * registry is a visibility nicety, not load-bearing).
212
+ * Stamp `finished_at` on ONE attempt — the one whose claim identity is given —
213
+ * after terminal completion/failure, preserving every other attempt. A missing,
214
+ * legacy, or invalid record, or an unknown claim, is a no-op (the watch registry
215
+ * is a visibility nicety, not load-bearing).
75
216
  */
76
- export async function markExecutorJobLogFinished(jobId, finishedAt, deps) {
77
- const existing = await readExecutorJobLogRecord(jobId, deps);
78
- if (!existing.ok)
79
- return;
80
- const updated = { ...existing.record, finished_at: finishedAt };
217
+ export async function markExecutorJobLogFinished(jobId, claimId, finishedAt, deps, update = {}) {
81
218
  const stateDir = resolveExecutorStateDir(deps);
82
219
  const filePath = jobLogRecordPath(stateDir, jobId, deps.platform ?? process.platform);
83
- await deps.writeFile(filePath, JSON.stringify(normalizeRecord(updated), null, 2));
220
+ await withJobLock(jobId, async () => {
221
+ const parsed = await readParsed(filePath, jobId, deps);
222
+ if (typeof parsed !== "object" || parsed.kind !== "v2")
223
+ return;
224
+ const index = parsed.file.attempts.findIndex((a) => a.claim_id === claimId);
225
+ if (index < 0)
226
+ return;
227
+ const current = parsed.file.attempts[index];
228
+ const next = { ...current, finished_at: finishedAt };
229
+ if (update.availability === "unavailable") {
230
+ next.availability = "unavailable";
231
+ if (update.unavailable_reason)
232
+ next.unavailable_reason = update.unavailable_reason;
233
+ }
234
+ const attempts = [...parsed.file.attempts];
235
+ attempts[index] = next;
236
+ await writeRegistryFile(filePath, serialize({ ...parsed.file, attempts }), deps);
237
+ });
238
+ }
239
+ /**
240
+ * Validate that an attempt entry's stable reference is really that attempt's own
241
+ * file: the claim identity is well formed, the filename is exactly the one this
242
+ * identity allocates, and the path ends in that filename (inside the recorded
243
+ * worktree's `.conductor`, when a worktree is recorded).
244
+ */
245
+ function attemptReferenceIsValid(file, entry, platform) {
246
+ const identity = { jobId: file.job_id, attempt: entry.attempt, claimId: entry.claim_id };
247
+ if (!isValidWorkerLogAttemptIdentity(identity))
248
+ return false;
249
+ if (!WORKER_LOG_ATTEMPT_FILE_RE.test(entry.log_file))
250
+ return false;
251
+ if (entry.log_file !== workerLogAttemptFileName(identity))
252
+ return false;
253
+ const pathApi = pathApiForExecutorPlatform(platform);
254
+ if (pathApi.basename(entry.log_path) === CONDUCTOR_WORKER_LOG_FILE)
255
+ return false;
256
+ if (pathApi.basename(entry.log_path) !== entry.log_file)
257
+ return false;
258
+ if (file.worktree_path) {
259
+ const expected = pathApi.join(file.worktree_path, ".conductor", entry.log_file);
260
+ if (pathApi.normalize(entry.log_path) !== pathApi.normalize(expected))
261
+ return false;
262
+ }
263
+ return true;
84
264
  }
85
265
  // ---------------------------------------------------------------------------
86
266
  // Active worker logs (BAPI-828)
87
267
  // ---------------------------------------------------------------------------
88
268
  /**
89
- * In-memory map of job id → worker-log path for jobs whose tee is currently open.
269
+ * In-memory map of (job id, claim identity) → worker-log path for attempts whose
270
+ * tee is currently open.
90
271
  *
91
272
  * DELIBERATELY SEPARATE FROM THE PERSISTED RECORD ABOVE, and deliberately not
92
273
  * derived from it. The persisted record is a visibility nicety for a SEPARATE
93
274
  * `executor watch` process, it is written with best-effort error swallowing, and
94
- * it keeps its row after `finished_at` is stamped. This map answers a different
275
+ * it keeps its rows after `finished_at` is stamped. This map answers a different
95
276
  * question — "which worker logs can be appended to right now, in THIS process" —
96
- * and the suspend diagnostic depends on that answer being correct. Deriving it
97
- * from the persisted record would mean a failed registry write silently disables
98
- * suspend annotation for a worker whose tee is perfectly healthy.
277
+ * and the suspend diagnostic depends on that answer being correct.
99
278
  *
100
- * Process-scoped module state rather than runner-scoped, unlike the live worker
101
- * registry: entries are keyed by server-minted job id, so two concurrent
102
- * `runExecutor` invocations in one process cannot collide, and each value is a
103
- * path only its own job ever deactivates.
279
+ * BAPI-1330: keyed by job AND claim identity, so a late close from an earlier
280
+ * claim of the same job can never deactivate its replacement's log.
104
281
  *
105
282
  * SECRET-FREE: a filesystem path inside the job's own worktree, nothing else.
106
283
  */
107
284
  const activeJobLogPaths = new Map();
285
+ /** Legacy key for callers that do not pass a claim identity. */
286
+ const NO_CLAIM = "-";
287
+ function activeKey(jobId, claimId) {
288
+ return `${jobId}:${claimId ?? NO_CLAIM}`;
289
+ }
108
290
  /**
109
- * Mark a job's worker log as open and appendable.
291
+ * Mark an attempt's worker log as open and appendable.
110
292
  *
111
293
  * Called once the tee EXISTS, before any registry persistence — see the map's
112
- * docstring for why the two are not chained. Re-activating the same job id simply
113
- * overwrites the path.
294
+ * docstring for why the two are not chained. Re-activating the same attempt
295
+ * simply overwrites its path.
114
296
  */
115
- export function activateJobLog(jobId, logPath) {
297
+ export function activateJobLog(jobId, logPath, claimId) {
116
298
  if (typeof logPath !== "string" || logPath.length === 0)
117
299
  return;
118
- activeJobLogPaths.set(jobId, logPath);
300
+ activeJobLogPaths.set(activeKey(jobId, claimId), logPath);
119
301
  }
120
- /** Stop appending to a job's worker log. Idempotent. */
121
- export function deactivateJobLog(jobId) {
122
- activeJobLogPaths.delete(jobId);
302
+ /**
303
+ * Stop appending to an attempt's worker log. Idempotent. With a claim identity
304
+ * only that attempt is deactivated; without one (legacy callers) every entry of
305
+ * the job is.
306
+ */
307
+ export function deactivateJobLog(jobId, claimId) {
308
+ if (claimId !== undefined) {
309
+ activeJobLogPaths.delete(activeKey(jobId, claimId));
310
+ return;
311
+ }
312
+ const prefix = `${jobId}:`;
313
+ for (const key of [...activeJobLogPaths.keys()]) {
314
+ if (key.startsWith(prefix))
315
+ activeJobLogPaths.delete(key);
316
+ }
123
317
  }
124
318
  /** The distinct currently-appendable log paths (a snapshot, safe to iterate). */
125
319
  export function activeJobLogPathsSnapshot() {
@@ -152,28 +346,101 @@ export async function appendToActiveJobLogs(line, deps) {
152
346
  }));
153
347
  }
154
348
  /**
155
- * Load and validate a record for `executor watch <job>`. Returns a structured
156
- * missing/invalid result rather than throwing raw filesystem/JSON errors.
349
+ * Load and validate one attempt's log reference for `executor watch <job>`.
350
+ *
351
+ * Defaults to the NEWEST recorded attempt; `selector` picks an earlier one by
352
+ * attempt number or claim identity. Every failure is a named, secret-free
353
+ * `code` — a legacy fixed-alias record, a selector that names no attempt, a
354
+ * reference whose filename does not belong to its identity, or an attempt whose
355
+ * transcript was never recorded are all `ok: false`. Nothing is guessed: there
356
+ * is no archive lookup and no fallback to another attempt's file.
157
357
  */
158
- export async function readExecutorJobLogRecord(jobId, deps) {
358
+ export async function readExecutorJobLogRecord(jobId, deps, selector = {}) {
359
+ const platform = deps.platform ?? process.platform;
159
360
  const stateDir = resolveExecutorStateDir(deps);
160
- const filePath = jobLogRecordPath(stateDir, jobId, deps.platform ?? process.platform);
161
- let raw;
162
- try {
163
- raw = await deps.readFile(filePath);
361
+ const filePath = jobLogRecordPath(stateDir, jobId, platform);
362
+ const parsed = await readParsed(filePath, jobId, deps);
363
+ if (parsed === "missing") {
364
+ return {
365
+ ok: false,
366
+ code: "no_registry_record",
367
+ reason: `no registry record for job ${jobId} (expected ${filePath})`,
368
+ };
164
369
  }
165
- catch {
166
- return { ok: false, reason: `no registry record for job ${jobId} (expected ${filePath})` };
370
+ if (parsed === "unreadable") {
371
+ return {
372
+ ok: false,
373
+ code: "registry_unreadable",
374
+ reason: `registry record for job ${jobId} is not valid JSON`,
375
+ };
167
376
  }
168
- let parsed;
169
- try {
170
- parsed = JSON.parse(raw);
377
+ if (parsed.kind === "legacy") {
378
+ return {
379
+ ok: false,
380
+ code: "legacy_unstable_log_reference",
381
+ reason: `registry record for job ${jobId} predates per-attempt logs and names a shared log ` +
382
+ "file that later attempts may have reused; its transcript cannot be attributed to an attempt",
383
+ };
171
384
  }
172
- catch {
173
- return { ok: false, reason: `registry record for job ${jobId} is not valid JSON` };
385
+ if (parsed.kind === "invalid") {
386
+ return {
387
+ ok: false,
388
+ code: "registry_invalid",
389
+ reason: `registry record for job ${jobId} is missing required fields`,
390
+ };
391
+ }
392
+ const file = parsed.file;
393
+ const wantsSelection = selector.attempt !== undefined || selector.claimId !== undefined;
394
+ let entry;
395
+ if (wantsSelection) {
396
+ const matches = file.attempts.filter((a) => (selector.attempt === undefined || a.attempt === selector.attempt) &&
397
+ (selector.claimId === undefined || a.claim_id === selector.claimId));
398
+ // Two claims of the same attempt number (a reclaim) are ambiguous by number
399
+ // alone; the most recent registration is the newest of them.
400
+ entry = matches[matches.length - 1];
401
+ }
402
+ else {
403
+ entry = file.attempts[file.attempts.length - 1];
174
404
  }
175
- if (!isValidRecord(parsed)) {
176
- return { ok: false, reason: `registry record for job ${jobId} is missing required fields` };
405
+ if (entry === undefined) {
406
+ return {
407
+ ok: false,
408
+ code: "attempt_not_found",
409
+ reason: wantsSelection
410
+ ? `job ${jobId} has no recorded attempt matching the selector`
411
+ : `job ${jobId} has no recorded attempt`,
412
+ };
177
413
  }
178
- return { ok: true, record: parsed };
414
+ if (!attemptReferenceIsValid(file, entry, platform)) {
415
+ return {
416
+ ok: false,
417
+ code: "attempt_identity_invalid",
418
+ reason: `job ${jobId} attempt ${entry.attempt} has a log reference that does not match its identity`,
419
+ };
420
+ }
421
+ if (entry.availability === "unavailable") {
422
+ return {
423
+ ok: false,
424
+ code: "attempt_log_unavailable",
425
+ reason: `job ${jobId} attempt ${entry.attempt} has no recorded transcript` +
426
+ (entry.unavailable_reason ? ` (${entry.unavailable_reason})` : ""),
427
+ };
428
+ }
429
+ const record = {
430
+ job_id: file.job_id,
431
+ repo_name: file.repo_name,
432
+ job_type: file.job_type,
433
+ log_path: entry.log_path,
434
+ started_at: entry.started_at,
435
+ attempt: entry.attempt,
436
+ claim_id: entry.claim_id,
437
+ availability: entry.availability,
438
+ };
439
+ if (file.ticket_key)
440
+ record.ticket_key = file.ticket_key;
441
+ if (file.worktree_path)
442
+ record.worktree_path = file.worktree_path;
443
+ if (entry.finished_at)
444
+ record.finished_at = entry.finished_at;
445
+ return { ok: true, record };
179
446
  }