@lyupro/codex-bridge 0.3.1 → 0.4.0

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.
@@ -1,6 +1,8 @@
1
1
  /** Finds a live run for an order and waits for its verdict when a repeated launcher call attaches. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { readJsonFileSync } from '../json-file.mjs';
5
+ import { IDENTITY_DEAD, IDENTITY_FOREIGN, processAlive, processIdentity } from '../process-identity.mjs';
4
6
  import { exitCodeFor, writeFailure, chainRuns } from '../write-meta.mjs';
5
7
 
6
8
  // The attach call is the only process that waits for a worker it did not spawn. Keeping that
@@ -10,36 +12,40 @@ const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
10
12
 
11
13
  export const readJsonFile = (file) => {
12
14
  try {
13
- return JSON.parse(fs.readFileSync(file, 'utf8'));
15
+ return readJsonFileSync(file);
14
16
  } catch {
15
17
  return null;
16
18
  }
17
19
  };
18
20
 
19
21
  /**
20
- * Is this pid still running? EPERM means the process exists and belongs to someone else,
21
- * which for our purposes is alive. Same reading write-meta.mjs uses — two answers to "is
22
- * this run still going" would be one answer too many.
22
+ * Is this pid still running for this run? Unknown identity remains live so an attach does not
23
+ * declare a worker dead merely because the identity probe was unavailable.
23
24
  */
24
- export const alive = (pid) => {
25
- if (!Number.isInteger(pid) || pid <= 0) return false;
26
- try {
27
- process.kill(pid, 0);
28
- return true;
29
- } catch (err) {
30
- return err.code === 'EPERM';
31
- }
25
+ export const alive = (pid, runDir, status = {}) => {
26
+ const identity = processIdentity({ runDir, status: { ...status, pid } });
27
+ return identity !== IDENTITY_DEAD && identity !== IDENTITY_FOREIGN;
32
28
  };
33
29
 
34
- /** Blocks until the worker's reply exists, or until the worker is provably gone without one. */
35
- export async function waitForReply(runDir, workerPid) {
30
+ /**
31
+ * Blocks until the worker's reply exists, or until the worker is provably gone without one.
32
+ *
33
+ * Identity is settled once, before the loop: a recycled pid would otherwise keep this call waiting
34
+ * for a reply nobody is going to write, and asking again every 500 ms would spawn the start-time
35
+ * probe for the whole length of a 20-minute run. Inside the loop the question is only whether the
36
+ * number is still busy, which signal 0 answers for free.
37
+ */
38
+ export async function waitForReply(runDir, workerPid, status = {}) {
36
39
  const replyPath = path.join(runDir, 'reply.txt');
40
+ if (!alive(workerPid, runDir, status)) {
41
+ return fs.existsSync(replyPath) ? fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '') : null;
42
+ }
37
43
  let pollsSinceDeath = 0;
38
44
  for (;;) {
39
45
  if (fs.existsSync(replyPath)) return fs.readFileSync(replyPath, 'utf8').replace(/\s+$/, '');
40
46
  // A dead worker will never write it. Two extra polls before believing that: the pid is
41
47
  // released around the same moment as the last write, and the order is not guaranteed.
42
- if (alive(workerPid)) {
48
+ if (processAlive(workerPid)) {
43
49
  pollsSinceDeath = 0;
44
50
  } else {
45
51
  pollsSinceDeath += 1;
@@ -84,7 +90,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
84
90
  console.log(fs.readFileSync(path.join(dir, 'reply.txt'), 'utf8').replace(/\s+$/, ''));
85
91
  return exitCodeFor(readJsonFile(path.join(dir, 'meta.json'))?.status);
86
92
  }
87
- if (alive(entry.status.pid)) {
93
+ if (alive(entry.status.pid, dir, entry.status)) {
88
94
  candidate = entry;
89
95
  break;
90
96
  }
@@ -93,7 +99,7 @@ export async function attach({ runsRoot, repo, slug, taskHash, orderId, chain, i
93
99
 
94
100
  const runDir = path.join(runsRoot, candidate.run);
95
101
  console.log(`ATTACH=${runDir} started=${candidate.status.started_at}`);
96
- const replyText = await waitForReply(runDir, candidate.status.pid);
102
+ const replyText = await waitForReply(runDir, candidate.status.pid, candidate.status);
97
103
  if (replyText !== null) {
98
104
  console.log(replyText);
99
105
  return exitCodeFor(readJsonFile(path.join(runDir, 'meta.json'))?.status);
@@ -263,7 +263,7 @@ export function requireCodex(runDir, agent) {
263
263
  const why = (probe.stderr || probe.error?.message || 'codex --version is not responding').trim();
264
264
  const { reply } = writeFailure(runDir, agent, `Codex CLI unavailable: ${why}`, [
265
265
  'Operator check: codex --version (and codex login if authorization is rejected)',
266
- ]);
266
+ ], true);
267
267
  console.log(reply);
268
268
  process.exit(1);
269
269
  }
@@ -4,11 +4,13 @@
4
4
  *
5
5
  * The order it leaves for the worker is worker.json in the run folder — after the split
6
6
  * that file is the only connection between the two halves. It is written here with
7
- * `agent`, `slug`, `order_id`, `repo`, `is_git_repo`, `launcher_pid`, `budget_minutes` and `args`;
8
- * worker.mjs reads `repo`, `agent`, `args`, `is_git_repo` and `budget_minutes`. The three extra fields are deliberate: `slug`,
9
- * `order_id` and `launcher_pid` are what a run folder read back months later needs in order to
10
- * explain itself — which order it belonged to included. Nothing may be dropped from this shape
11
- * without changing worker.mjs and saying so out loud.
7
+ * `agent`, `slug`, `order_id`, `repo`, `is_git_repo`, `launcher_pid`, `budget_minutes`, `scope_new`
8
+ * and `args`;
9
+ * worker.mjs reads `repo`, `agent`, `args`, `is_git_repo` and `budget_minutes`. The extra fields
10
+ * are deliberate: `slug`, `order_id`, `launcher_pid` and `scope_new` are what a run folder read
11
+ * back months later needs in order to explain itself — which order it belonged to and which new
12
+ * paths it declared included. Nothing may be dropped from this shape without changing worker.mjs
13
+ * and saying so out loud.
12
14
  */
13
15
  import fs from 'node:fs';
14
16
  import path from 'node:path';
@@ -19,8 +21,9 @@ import {
19
21
  writeStatus,
20
22
  markAbandoned,
21
23
  abandonedBranchDrift,
22
- activeRun,
24
+ activeRunDetails,
23
25
  chainRuns,
26
+ startedRuns,
24
27
  taskFingerprint,
25
28
  readJson,
26
29
  } from '../write-meta.mjs';
@@ -38,6 +41,7 @@ import { runsRoot } from './runs-root.mjs';
38
41
  import { resolveProjectRunsDir } from './project-dir.mjs';
39
42
  import { cleanupRetention } from '../retention.mjs';
40
43
  import { renderConventions } from './conventions.mjs';
44
+ import { validateScope } from './scope-check.mjs';
41
45
 
42
46
  /**
43
47
  * The worker is this same program re-invoked as `--worker <runDir>`, so the path spawned
@@ -88,6 +92,18 @@ export async function launcher() {
88
92
  const topLevel = git(opts.repo, ['rev-parse', '--show-toplevel']);
89
93
  const isGitRepo = topLevel.status === 0;
90
94
  const repoRoot = isGitRepo ? topLevel.stdout.trim() : opts.repo;
95
+ // Plan_27 moved impossible scope failures ahead of the run directory: an absolute pattern had
96
+ // already cost 18 minutes before the verdict could prove it matched nothing.
97
+ // Every agent, not only the writing one: a pattern that cannot match gives a scout empty coverage
98
+ // instead of an answer. Only codex-build may declare a not-yet-existing path, so only its scope
99
+ // carries --scope-new; for the other two the list is empty and every pattern must match.
100
+ const scopeRefusal = validateScope(repoRoot, opts.scopePatterns, opts.scopeNewPatterns);
101
+ if (scopeRefusal) {
102
+ die(
103
+ `--scope pattern ${JSON.stringify(scopeRefusal.pattern)} refused: ${scopeRefusal.reason}. ` +
104
+ `Action: ${scopeRefusal.action}. The run folder was not created; quota was not spent.`,
105
+ );
106
+ }
91
107
  const projectRunsRoot = resolveProjectRunsDir(runsRoot(), repoRoot).dir;
92
108
 
93
109
  // Folders left behind by a runner that was killed mid-run get an explicit state before
@@ -118,6 +134,7 @@ export async function launcher() {
118
134
  // like --scope.
119
135
  const taskHash = taskFingerprint(taskText);
120
136
  const chain = chainRuns(projectRunsRoot, repoRoot, opts.slug, taskHash, opts.orderId);
137
+ const startedChain = startedRuns(projectRunsRoot, chain);
121
138
  const continuationGrant = opts.continue ? parseContinuationGrant(taskText) : null;
122
139
 
123
140
  // One order produced six Codex runs on 2026-08-03 because the caller's time ceiling made it
@@ -139,15 +156,15 @@ export async function launcher() {
139
156
  // attach(), while --continue must refuse to overlap that live work.
140
157
  const continuationError = continuationRefusal(
141
158
  projectRunsRoot,
142
- chain,
159
+ startedChain,
143
160
  opts.continue,
144
161
  opts.orderId,
145
162
  continuationGrant,
146
163
  );
147
164
  if (continuationError) die(continuationError);
148
165
 
149
- if (chain.length && !opts.continue) {
150
- const last = chain[chain.length - 1];
166
+ if (startedChain.length && !opts.continue) {
167
+ const last = startedChain[startedChain.length - 1];
151
168
  const lastSlug = String(readJson(path.join(projectRunsRoot, last, 'status.json'))?.slug || '');
152
169
  const renamed = lastSlug && lastSlug.toLowerCase() !== String(opts.slug).toLowerCase();
153
170
  die(
@@ -155,7 +172,7 @@ export async function launcher() {
155
172
  renamed
156
173
  ? `this task already ran in this repository under the name “${lastSlug}”`
157
174
  : `runs for task “${opts.slug}” already exist in this repository`
158
- } (${chain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
175
+ } (${startedChain.length}), latest: ${path.join(projectRunsRoot, last)}. ` +
159
176
  'A repeat run is allowed, but the orchestrator decides, not the runner: it read the ' +
160
177
  'previous response and knows whether work remains. Add --continue if you are finishing ' +
161
178
  'the same task; changing --slug with the same task text does not stop it being a repeat. ' +
@@ -166,7 +183,7 @@ export async function launcher() {
166
183
  // Asked before this run registers itself, so it cannot find itself. Two writing runs share
167
184
  // one worktree with no isolation: the second one's before/after snapshot picks up the
168
185
  // first one's edits, and an honest run gets failed for work it never did.
169
- const busy = opts.agent === 'codex-build' ? activeRun(projectRunsRoot, repoRoot) : null;
186
+ const busy = opts.agent === 'codex-build' ? activeRunDetails(projectRunsRoot, repoRoot) : null;
170
187
 
171
188
  let retention = null;
172
189
  try {
@@ -192,6 +209,7 @@ export async function launcher() {
192
209
  state: 'running',
193
210
  pid: process.pid,
194
211
  launcher_pid: process.pid,
212
+ process_started_at: performance.timeOrigin,
195
213
  agent: opts.agent,
196
214
  slug: opts.slug,
197
215
  order_id: opts.orderId,
@@ -202,7 +220,7 @@ export async function launcher() {
202
220
  started_at: new Date().toISOString(),
203
221
  // Which run of this task started the chain — the base every later pass is measured
204
222
  // against. Absent means this is the first pass.
205
- ...(chain.length ? { continues: chain[0] } : {}),
223
+ ...(startedChain.length ? { continues: startedChain[0] } : {}),
206
224
  // `continued_from` is the exact run the orchestrator named; `continues` above remains the chain base.
207
225
  ...(continuationGrant ? { continued_from: continuationGrant.run } : {}),
208
226
  ...(retention ? { retention } : {}),
@@ -214,11 +232,15 @@ export async function launcher() {
214
232
 
215
233
  // Before requireCodex, so a blocked run costs nothing at all.
216
234
  if (busy) {
235
+ const identityNote = busy.identity === 'unverified'
236
+ ? '; process identity could not be confirmed'
237
+ : '';
217
238
  const { reply } = writeFailure(
218
239
  runDir,
219
240
  opts.agent,
220
- `run ${busy} is already active for this repository; two writing runs in one tree are prohibited`,
221
- [`Active run: ${path.join(projectRunsRoot, busy)}`, 'Codex was not started; quota was not spent'],
241
+ `run ${busy.run} is already active for this repository; two writing runs in one tree are prohibited${identityNote}`,
242
+ [`Active run: ${path.join(projectRunsRoot, busy.run)}`, 'Codex was not started; quota was not spent'],
243
+ true,
222
244
  );
223
245
  console.log(reply);
224
246
  process.exit(1);
@@ -307,6 +329,7 @@ export async function launcher() {
307
329
  opts.agent,
308
330
  `argument cannot be passed through cmd.exe (contains % or "): ${unsafe}`,
309
331
  ['Codex was not started; quota was not spent'],
332
+ true,
310
333
  );
311
334
  console.log(reply);
312
335
  process.exit(1);
@@ -324,6 +347,7 @@ export async function launcher() {
324
347
  // The mode's wall-clock budget, resolved here and never re-read by the worker: a run
325
348
  // that consulted run-config.json twice could end up honouring two different limits.
326
349
  budget_minutes: RUN_ENV?.budgets?.[runMode(opts.agent)],
350
+ scope_new: opts.scopeNewPatterns,
327
351
  args: codexArgv,
328
352
  },
329
353
  null,
@@ -343,12 +367,12 @@ export async function launcher() {
343
367
  worker.on('error', (err) => {
344
368
  const { reply } = writeFailure(runDir, opts.agent, `run worker process failed to start: ${err.message}`, [
345
369
  'Codex was not started; quota was not spent',
346
- ]);
370
+ ], true);
347
371
  console.log(reply);
348
372
  process.exit(1);
349
373
  });
350
374
  worker.unref();
351
- writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid });
375
+ writeStatus(runDir, { pid: worker.pid, runner_pid: worker.pid, process_started_at: null });
352
376
 
353
377
  console.log(
354
378
  `STARTED agent=${opts.agent} slug=${opts.slug} order-id=${opts.orderId} worker-pid=${worker.pid}`,
@@ -1,6 +1,7 @@
1
1
  /** Assigns a stable runs directory to one repository. */
2
2
  import fs from 'node:fs';
3
3
  import path from 'node:path';
4
+ import { readJsonFileSync } from '../json-file.mjs';
4
5
  import { projectFolder } from '../write-meta.mjs';
5
6
 
6
7
  export const PROJECT_MARKER = '.project.json';
@@ -18,9 +19,9 @@ function readMarker(dir) {
18
19
  if (!fs.existsSync(markerPath)) return null;
19
20
  let marker;
20
21
  try {
21
- marker = JSON.parse(fs.readFileSync(markerPath, 'utf8'));
22
+ marker = readJsonFileSync(markerPath);
22
23
  } catch (err) {
23
- throw new Error(`Cannot read project marker ${markerPath}: ${err.message}`);
24
+ throw new Error(`Cannot read project marker ${markerPath}: ${err.cause?.message || err.message}`);
24
25
  }
25
26
  if (!marker || typeof marker.repo !== 'string' || !marker.repo) {
26
27
  throw new Error(`Project marker ${markerPath} must contain a non-empty repo path`);
@@ -65,7 +66,7 @@ function legacyOwner(dir) {
65
66
  .sort((a, b) => a.name.localeCompare(b.name));
66
67
  const statuses = entries.map((entry) => {
67
68
  try {
68
- return JSON.parse(fs.readFileSync(path.join(dir, entry.name, 'status.json'), 'utf8'));
69
+ return readJsonFileSync(path.join(dir, entry.name, 'status.json'));
69
70
  } catch {
70
71
  return null;
71
72
  }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * Answers whether each scope pattern can match an existing repository file.
3
+ *
4
+ * Plan_27 moved impossible scope failures before the run folder: an absolute pattern had already
5
+ * spent 18 minutes before the verdict proved that it matched nothing.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { spawnSync } from 'node:child_process';
10
+ import { globToRegExp, normalizePath } from '../meta/paths.mjs';
11
+
12
+ // Directories a self-walk must never descend into. Only reached when the repository has no git:
13
+ // with git present the file list comes from the repository itself, which already knows what is
14
+ // ignored. The first Plan_27 pass walked everything, `.git` and `node_modules` included — invisible
15
+ // here, where the package has no dependencies, and tens of thousands of files in a working monorepo.
16
+ const SKIP_DIRECTORIES = new Set(['.git', 'node_modules']);
17
+
18
+ function absolutePattern(pattern) {
19
+ return (
20
+ /^[A-Za-z]:/.test(pattern) ||
21
+ pattern.startsWith('/') ||
22
+ pattern.startsWith('\\\\?\\') ||
23
+ pattern.startsWith('\\\\')
24
+ );
25
+ }
26
+
27
+ function structuralRefusal(pattern) {
28
+ if (absolutePattern(pattern)) {
29
+ return {
30
+ reason: 'is an absolute or drive-qualified path',
31
+ action: 'use a path relative to the repository root with forward slashes',
32
+ };
33
+ }
34
+ if (pattern.includes('\\')) {
35
+ return {
36
+ reason: 'uses backslash separators',
37
+ action: 'replace backslashes with forward slashes',
38
+ };
39
+ }
40
+ if (pattern.split('/').some((segment) => segment === '..')) {
41
+ return {
42
+ reason: 'contains a parent-directory (..) segment',
43
+ action: 'remove the .. segment and keep the path relative to the repository root',
44
+ };
45
+ }
46
+ return null;
47
+ }
48
+
49
+ function noMatchRefusal(pattern) {
50
+ return {
51
+ pattern,
52
+ reason: 'does not match any existing path in the repository',
53
+ action: 'correct the pattern or declare an intentionally new path with --scope-new',
54
+ };
55
+ }
56
+
57
+ function repositoryPath(repoRoot, absolutePath) {
58
+ const relative = path.relative(repoRoot, absolutePath).split(path.sep).join('/');
59
+ return normalizePath(relative);
60
+ }
61
+
62
+ /**
63
+ * The repository's own answer to "which files are there": tracked plus new-but-uncommitted, minus
64
+ * everything ignored. It is the same boundary the run is judged by afterwards, it costs one process
65
+ * instead of a full walk, and it cannot wander into `.git` or a dependency tree.
66
+ */
67
+ function gitPaths(repoRoot) {
68
+ const result = spawnSync(
69
+ 'git',
70
+ ['-C', repoRoot, 'ls-files', '--cached', '--others', '--exclude-standard'],
71
+ { encoding: 'utf8', windowsHide: true, maxBuffer: 64 * 1024 * 1024 },
72
+ );
73
+ if (result.error || result.status !== 0) return null;
74
+ return result.stdout.split('\n').map((line) => line.trim()).filter(Boolean);
75
+ }
76
+
77
+ /** Walks a directory that has no git, skipping dependency trees and anything unreadable. */
78
+ function walkPaths(repoRoot) {
79
+ const pending = [repoRoot];
80
+ const found = [];
81
+ while (pending.length) {
82
+ const current = pending.pop();
83
+ let entries;
84
+ try {
85
+ entries = fs.readdirSync(current, { withFileTypes: true });
86
+ } catch {
87
+ // An unreadable subdirectory is not a reason to refuse a run: the pattern may well match
88
+ // somewhere else, and a permission error here would fail work that has nothing to do with it.
89
+ continue;
90
+ }
91
+ for (const entry of entries) {
92
+ const absolute = path.join(current, entry.name);
93
+ if (entry.isDirectory() && !entry.isSymbolicLink()) {
94
+ if (!SKIP_DIRECTORIES.has(entry.name)) pending.push(absolute);
95
+ continue;
96
+ }
97
+ if (!entry.isFile() && !entry.isSymbolicLink()) continue;
98
+ found.push(repositoryPath(repoRoot, absolute));
99
+ }
100
+ }
101
+ return found;
102
+ }
103
+
104
+ function matchingPaths(repoRoot, matchers) {
105
+ const candidates = gitPaths(repoRoot)?.map((relative) => normalizePath(relative))
106
+ ?? walkPaths(repoRoot);
107
+ const matched = new Set();
108
+ for (const candidate of candidates) {
109
+ for (const matcher of matchers) {
110
+ if (!matched.has(matcher.pattern) && matcher.regexp.test(candidate)) {
111
+ matched.add(matcher.pattern);
112
+ }
113
+ }
114
+ if (matched.size === matchers.length) break;
115
+ }
116
+ return matched;
117
+ }
118
+
119
+ function patternList(value, label) {
120
+ if (!Array.isArray(value)) throw new TypeError(`${label} must be an array`);
121
+ return value.map((pattern) => String(pattern ?? '').trim());
122
+ }
123
+
124
+ /**
125
+ * Returns the first scope refusal, or null when every ordinary pattern can match a repository
126
+ * path. New-file declarations are still structurally checked; only their existence check is
127
+ * exempted.
128
+ */
129
+ export function validateScope(repoRoot, patterns, scopeNewPatterns = []) {
130
+ if (typeof repoRoot !== 'string' || !repoRoot) throw new TypeError('repoRoot must be a non-empty string');
131
+ const declared = patternList(patterns, 'patterns');
132
+ const newPaths = patternList(scopeNewPatterns, 'scopeNewPatterns');
133
+ const allPatterns = [...declared, ...newPaths];
134
+
135
+ for (const pattern of allPatterns) {
136
+ const refusal = structuralRefusal(pattern);
137
+ if (refusal) return { pattern, ...refusal };
138
+ if (!pattern) return noMatchRefusal(pattern);
139
+ }
140
+
141
+ const newPathKeys = new Set(newPaths.map((pattern) => normalizePath(pattern)));
142
+ const required = allPatterns.filter((pattern) => !newPathKeys.has(normalizePath(pattern)));
143
+ if (!required.length) return null;
144
+
145
+ let matchers;
146
+ try {
147
+ matchers = required.map((pattern) => ({ pattern, regexp: globToRegExp(pattern) }));
148
+ } catch (error) {
149
+ return {
150
+ pattern: required[0].pattern,
151
+ reason: `could not parse the pattern: ${error.message}`,
152
+ action: 'correct the pattern and retry',
153
+ };
154
+ }
155
+
156
+ const matched = matchingPaths(repoRoot, matchers);
157
+ const missing = matchers.find((matcher) => !matched.has(matcher.pattern));
158
+ return missing ? noMatchRefusal(missing.pattern) : null;
159
+ }
@@ -4,14 +4,15 @@
4
4
  * It takes its whole order from worker.json, written by launcher.mjs into the run folder —
5
5
  * after the split that file is the only connection between the two halves. Read here:
6
6
  * `repo`, `agent`, `args`, `is_git_repo`, `budget_minutes`. Written there: those five plus
7
- * `slug` and `launcher_pid`, which nothing here needs but a run folder read back months later
8
- * does. The launcher must write the selected mode's budget into worker.json before spawning
9
- * this half, so the worker never re-reads run-config.json and one run cannot acquire two
7
+ * `slug`, `launcher_pid` and `scope_new`, which nothing here needs but a run folder read back
8
+ * months later does. The launcher must write the selected mode's budget into worker.json before
9
+ * spawning this half, so the worker never re-reads run-config.json and one run cannot acquire two
10
10
  * configurations.
11
11
  * Neither side may change the shape alone.
12
12
  */
13
13
  import fs from 'node:fs';
14
14
  import path from 'node:path';
15
+ import { readJsonFileSync } from '../json-file.mjs';
15
16
  import { collect, exitCodeFor, AGENTS } from '../write-meta.mjs';
16
17
  import { writeStatus } from '../meta/run-state.mjs';
17
18
  import { setRun, emitReply } from './run-context.mjs';
@@ -23,9 +24,16 @@ import { runCodex } from './codex-cmd.mjs';
23
24
  * may depend on anyone still being on the other end of a pipe.
24
25
  */
25
26
  export async function worker(runDir) {
26
- const cfg = JSON.parse(fs.readFileSync(path.join(runDir, 'worker.json'), 'utf8'));
27
+ const cfg = readJsonFileSync(path.join(runDir, 'worker.json'));
27
28
  const repoRoot = cfg.repo;
28
29
  setRun(runDir, cfg.agent);
30
+ // The launcher cannot know this detached process's clock origin; takeover must record the
31
+ // worker's own identity before it starts producing the run's artifacts.
32
+ writeStatus(runDir, {
33
+ pid: process.pid,
34
+ runner_pid: process.pid,
35
+ process_started_at: performance.timeOrigin,
36
+ });
29
37
 
30
38
  const run = await runCodex(
31
39
  cfg.args,
@@ -58,7 +66,7 @@ export async function worker(runDir) {
58
66
  const resultPath = path.join(runDir, AGENTS[cfg.agent].result);
59
67
  if (cfg.agent !== 'codex-review') {
60
68
  try {
61
- const result = JSON.parse(fs.readFileSync(resultPath, 'utf8'));
69
+ const result = readJsonFileSync(resultPath);
62
70
  if (result.report_markdown) fs.writeFileSync(path.join(runDir, 'report.md'), `${result.report_markdown}\n`);
63
71
  } catch {
64
72
  // No parseable result — write-meta.mjs turns that into FAIL with the log reason.
@@ -31,7 +31,14 @@ import { AGENTS, failReply, limitReply } from './meta/reply.mjs';
31
31
 
32
32
  export { expandDeclared, globToRegExp, readJson } from './meta/paths.mjs';
33
33
  export { chainBaseline, chainRuns, taskFingerprint } from './meta/chain.mjs';
34
- export { abandonedBranchDrift, activeRun, markAbandoned, writeFailure } from './meta/run-state.mjs';
34
+ export { abortedPreStart, startedRuns } from './meta/pre-start.mjs';
35
+ export {
36
+ abandonedBranchDrift,
37
+ activeRun,
38
+ activeRunDetails,
39
+ markAbandoned,
40
+ writeFailure,
41
+ } from './meta/run-state.mjs';
35
42
  export { outOfScope, reportVersusWork } from './meta/verdict.mjs';
36
43
  export { AGENTS, writeStatus };
37
44
 
package/cli/sweep.mjs DELETED
@@ -1,144 +0,0 @@
1
- #!/usr/bin/env node
2
- /** Closes dead-pid running records without deleting any run artifacts. */
3
- import fs from 'node:fs';
4
- import path from 'node:path';
5
- import { formatSilence, heartbeatAge, isHeartbeatFresh } from '../src/heartbeat.mjs';
6
- import { markAbandoned, pidAlive } from '../src/meta/run-state.mjs';
7
- import { runsRoot } from '../src/runner/runs-root.mjs';
8
- import { readJson } from '../src/write-meta.mjs';
9
- import { renderTable } from './table.mjs';
10
-
11
- const CLOSED_COLUMNS = [
12
- { key: 'project', label: 'project' },
13
- { key: 'run', label: 'run' },
14
- { key: 'state', label: 'state' },
15
- ];
16
- const STALE_COLUMNS = [
17
- { key: 'project', label: 'project' },
18
- { key: 'run', label: 'run' },
19
- { key: 'silence', label: 'silent for' },
20
- ];
21
-
22
- function result(exitCode, output) {
23
- return { exitCode, output };
24
- }
25
-
26
- function parseArgs(argv) {
27
- const positional = [];
28
- for (const arg of argv) {
29
- if (typeof arg !== 'string' || arg.startsWith('-')) {
30
- return { error: `codex-bridge sweep: unknown option "${arg}".` };
31
- }
32
- positional.push(arg);
33
- }
34
- if (positional.length > 1) {
35
- return { error: 'codex-bridge sweep accepts at most one project name.' };
36
- }
37
- const project = positional[0] || null;
38
- if (project && (path.isAbsolute(project) || path.dirname(project) !== '.')) {
39
- return { error: 'codex-bridge sweep requires a bare project name.' };
40
- }
41
- return { project };
42
- }
43
-
44
- function directories(root) {
45
- try {
46
- return fs.readdirSync(root, { withFileTypes: true })
47
- .filter((entry) => entry.isDirectory())
48
- .sort((left, right) => left.name.localeCompare(right.name));
49
- } catch {
50
- return [];
51
- }
52
- }
53
-
54
- function selectedProjects(root, projectName) {
55
- const entries = directories(root);
56
- if (!projectName) {
57
- return entries.map((entry) => ({ name: entry.name, dir: path.join(root, entry.name) }));
58
- }
59
- const entry = entries.find((candidate) => candidate.name === projectName);
60
- if (!entry) return { error: `codex-bridge sweep: unknown project "${projectName}".` };
61
- return [{ name: entry.name, dir: path.join(root, entry.name) }];
62
- }
63
-
64
- function staleLiveRuns(project) {
65
- // The 2026-08-06 incident showed why stale heartbeat is report-only: a live worker remains
66
- // the sole writer until stop kills it and records the outcome.
67
- const stale = [];
68
- for (const entry of directories(project.dir)) {
69
- const dir = path.join(project.dir, entry.name);
70
- const status = readJson(path.join(dir, 'status.json'));
71
- if (!status || status.state !== 'running' || !pidAlive(status.pid) || isHeartbeatFresh(dir)) continue;
72
- stale.push({
73
- project: project.name,
74
- run: entry.name,
75
- silence: formatSilence(heartbeatAge(dir)),
76
- });
77
- }
78
- return stale;
79
- }
80
-
81
- export function sweepPlan(argv = [], options = {}) {
82
- const parsed = parseArgs(argv);
83
- if (parsed.error) return parsed;
84
- const root = path.resolve(options.runsRootPath || runsRoot());
85
- const projects = selectedProjects(root, parsed.project);
86
- if (projects.error) return projects;
87
- return { root, project: parsed.project, projects };
88
- }
89
-
90
- function closeDeadRuns(plan) {
91
- const closed = [];
92
- const failures = [];
93
- for (const project of plan.projects) {
94
- try {
95
- // No worktree snapshot is passed, and that is the choice rather than an omission: `stop`
96
- // closes one named run and knows which repository to look at, while a sweep of the whole
97
- // store would have to run git in every repository every project ever pointed at. The runs
98
- // it closes died long ago, so that list would describe today's tree, not their work — the
99
- // reason text already says a file list is unavailable, which is the honest answer here.
100
- for (const change of markAbandoned(project.dir)) {
101
- closed.push({ project: project.name, run: change.run, state: change.state });
102
- }
103
- } catch (error) {
104
- failures.push({ project: project.name, message: error.message });
105
- }
106
- }
107
- return { closed, failures };
108
- }
109
-
110
- function renderReport(plan, closed, stale, failures, options) {
111
- const scope = plan.project ? `project "${plan.project}"` : 'all projects';
112
- const lines = [`Sweep of ${scope} completed.`];
113
- if (closed.length) {
114
- lines.push(`Closed ${closed.length} running record${closed.length === 1 ? '' : 's'}:`);
115
- lines.push(renderTable(CLOSED_COLUMNS, closed, options));
116
- } else {
117
- lines.push('Nothing to close; the store is unchanged.');
118
- }
119
- if (stale.length) {
120
- lines.push(`Left ${stale.length} live-pid run${stale.length === 1 ? '' : 's'} with stale heartbeats untouched:`);
121
- lines.push(renderTable(STALE_COLUMNS, stale, options));
122
- lines.push('End each one explicitly:');
123
- for (const run of stale) lines.push(` codex-bridge stop ${run.run}`);
124
- } else {
125
- lines.push('Live-pid runs with stale heartbeats left untouched: none.');
126
- }
127
- if (failures.length) {
128
- lines.push('Some project records could not be swept:');
129
- for (const failure of failures) lines.push(` ${failure.project}: ${failure.message}`);
130
- }
131
- return lines.join('\n');
132
- }
133
-
134
- export function sweep(argv = [], options = {}) {
135
- const plan = sweepPlan(argv, options);
136
- if (plan.error) return result(2, plan.error);
137
- const { closed, failures } = closeDeadRuns(plan);
138
- // This is a report-only probe. markAbandoned owns the closure decision and is the only writer.
139
- const stale = plan.projects.flatMap((project) => staleLiveRuns(project));
140
- return result(
141
- failures.length ? 1 : 0,
142
- renderReport(plan, closed, stale, failures, options),
143
- );
144
- }