@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.
- package/README.md +66 -15
- package/build/agent-launchers/claude-executor-adapter.js +145 -20
- package/build/agent-launchers/executor-adapter.js +81 -2
- package/build/agents.generated.js +10 -1
- package/build/bounded-wait.js +32 -1
- package/build/bridge-config.js +104 -0
- package/build/codex-bridge-profile.js +10 -1
- package/build/commands.generated.js +7 -6
- package/build/conductor/bridge-api-client.js +565 -32
- package/build/conductor/bring-up-facts.js +3 -1
- package/build/conductor/doctor.js +23 -14
- package/build/conductor/epic-child-continuation.js +351 -0
- package/build/conductor/epic-implementer-cli.js +154 -17
- package/build/conductor/git-hooks.js +529 -118
- package/build/conductor/guided-repair.js +323 -0
- package/build/conductor/hook-runtime-qualification.js +340 -0
- package/build/conductor/readiness-cli.js +30 -9
- package/build/conductor/readiness-sections.js +5 -4
- package/build/conductor/readiness.js +45 -12
- package/build/conductor/recovery-cli.js +859 -27
- package/build/conductor/recovery-operations.js +574 -5
- package/build/conductor/repair-contract.js +69 -3
- package/build/conductor-bundle-artifacts.js +1 -0
- package/build/credential-store.js +52 -13
- package/build/diagnostic-text.js +36 -0
- package/build/docs.generated.js +3 -2
- package/build/doctor.js +147 -21
- package/build/drive-epic-key.js +12 -0
- package/build/drive-epic.js +1537 -125
- package/build/epic-coherence-input-cli.js +137 -0
- package/build/epic-coherence-input.js +920 -0
- package/build/epic-coherence-input.test-fixtures.js +175 -0
- package/build/epic-implementer-bin.js +41 -33
- package/build/epic-implementer-bundle-cli.js +14 -1
- package/build/epic-launch-diagnostic.js +31 -0
- package/build/epic-launch-profile.js +58 -19
- package/build/epic-run-status.js +5 -0
- package/build/epic-shared/bridge-client.js +46 -4
- package/build/executor/cli.js +39 -0
- package/build/executor/completion-contract.js +423 -0
- package/build/executor/completion-supervision.js +291 -0
- package/build/executor/correction-dispatch.js +60 -0
- package/build/executor/credentials.js +3 -1
- package/build/executor/deps.js +65 -1
- package/build/executor/env.js +12 -0
- package/build/executor/heartbeat.js +13 -3
- package/build/executor/http-client.js +91 -4
- package/build/executor/integration-job.js +6 -1
- package/build/executor/job-errors.js +61 -4
- package/build/executor/job-log-registry.js +340 -73
- package/build/executor/job-runner.js +1550 -55
- package/build/executor/job-types.js +9 -6
- package/build/executor/preflight.js +189 -1
- package/build/executor/process-containment.js +756 -0
- package/build/executor/process.js +235 -5
- package/build/executor/prompt-spec.js +366 -1
- package/build/executor/provider-checks.js +69 -0
- package/build/executor/rebase-finalization.js +351 -0
- package/build/executor/reconcile-overlap-job.js +77 -84
- package/build/executor/required-verification.js +313 -0
- package/build/executor/required-work-ledger.js +1121 -0
- package/build/executor/results.js +142 -0
- package/build/executor/resume-pre-spawn.js +87 -4
- package/build/executor/runner.js +168 -0
- package/build/executor/stale-artifacts.js +5 -4
- package/build/executor/test-declaration.js +20 -0
- package/build/executor/types.js +13 -0
- package/build/executor/watch-cli.js +35 -8
- package/build/executor/worker-command.js +70 -34
- package/build/executor/worker-finalization.js +168 -26
- package/build/executor/worker-guard-hook.js +75 -14
- package/build/executor/worker-log.js +137 -70
- package/build/executor/worktree-gc.js +30 -6
- package/build/executor/worktree.js +13 -0
- package/build/executor-launch.js +658 -0
- package/build/index.js +594 -1152
- package/build/init.js +107 -4
- package/build/install-bridge-conductor.js +231 -41
- package/build/install-bridge.js +284 -20
- package/build/install-doctor.js +172 -30
- package/build/install-reexec.js +69 -7
- package/build/macos-terminal-permission-guidance.js +16 -0
- package/build/merge-pull-request.js +1 -1
- package/build/model-thresholds-tool.js +185 -0
- package/build/pipelines.generated.js +9 -8
- package/build/plan-discovery.js +137 -0
- package/build/plan-epic-implementer-eligibility.js +2 -2
- package/build/plane/cli.js +37 -3
- package/build/plane/member-logs.js +3 -0
- package/build/plane/member-roster.js +7 -1
- package/build/plane/shutdown.js +59 -1
- package/build/plane/status.js +4 -0
- package/build/plane/supervisor.js +146 -0
- package/build/plane/worker-exit-code.js +2 -0
- package/build/project-root.js +99 -0
- package/build/readme.generated.js +1 -1
- package/build/review-tickets.js +156 -13
- package/build/scripts/executor-protocol-contract-driver.js +13 -3
- package/build/setup-epic.js +208 -125
- package/build/sfcc/setup-status.js +2 -0
- package/build/sfcc/tool-wrapper.js +2 -0
- package/build/sfcc/tools-configured.js +96 -0
- package/build/start-tickets.js +163 -58
- package/build/upgrade-cli.js +109 -24
- package/build/version.generated.js +3 -3
- package/build/windows-npm-entry.js +103 -0
- package/build/worker-guard-hook-bin.js +5 -4
- package/build/worktree-core.js +6 -5
- package/docs/CONDUCTOR.md +116 -2
- package/docs/install/mcp-tool-integrations.md +1 -0
- package/docs/install/sfcc-integration.md +12 -7
- package/package.json +2 -1
- 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
|
|
5
|
-
*
|
|
6
|
-
* the
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
|
|
38
|
-
|
|
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
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
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 (
|
|
47
|
-
out.
|
|
48
|
-
if (
|
|
49
|
-
out.
|
|
50
|
-
|
|
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
|
|
55
|
-
|
|
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 (
|
|
59
|
-
typeof v.
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
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
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
|
73
|
-
* preserving
|
|
74
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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.
|
|
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
|
-
*
|
|
101
|
-
*
|
|
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
|
|
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
|
|
113
|
-
* overwrites
|
|
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
|
-
/**
|
|
121
|
-
|
|
122
|
-
|
|
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
|
|
156
|
-
*
|
|
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,
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
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
|
-
|
|
166
|
-
return {
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
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
|
-
|
|
173
|
-
return {
|
|
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 (
|
|
176
|
-
return {
|
|
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
|
-
|
|
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
|
}
|