@magnusekdahl/parallix 1.2.0 → 1.2.1

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 (51) hide show
  1. package/lib/agents/agents.js +737 -838
  2. package/lib/agents/agents.ts +930 -0
  3. package/lib/agents/claude-telemetry.js +149 -154
  4. package/lib/agents/claude-telemetry.ts +233 -0
  5. package/lib/agents/claude.js +121 -131
  6. package/lib/agents/claude.ts +156 -0
  7. package/lib/agents/codex-telemetry.js +176 -164
  8. package/lib/agents/codex-telemetry.ts +205 -0
  9. package/lib/agents/codex.js +168 -201
  10. package/lib/agents/codex.ts +236 -0
  11. package/lib/agents/limit-hit.js +216 -226
  12. package/lib/agents/limit-hit.ts +262 -0
  13. package/lib/agents/mistral-telemetry.js +7 -10
  14. package/lib/agents/mistral-telemetry.ts +44 -0
  15. package/lib/agents/mistral.js +40 -58
  16. package/lib/agents/mistral.ts +84 -0
  17. package/lib/agents/opencode-export.js +126 -127
  18. package/lib/agents/opencode-export.ts +160 -0
  19. package/lib/agents/opencode-telemetry.js +282 -261
  20. package/lib/agents/opencode-telemetry.ts +350 -0
  21. package/lib/agents/opencode.js +263 -305
  22. package/lib/agents/opencode.ts +370 -0
  23. package/lib/agents/stage-telemetry.js +23 -24
  24. package/lib/agents/stage-telemetry.ts +47 -0
  25. package/lib/commands/integrate.js +9 -3
  26. package/lib/core/fmt.js +140 -211
  27. package/lib/core/fmt.ts +189 -0
  28. package/lib/core/git.js +121 -102
  29. package/lib/core/git.ts +146 -0
  30. package/lib/core/gitignore.js +59 -106
  31. package/lib/core/gitignore.ts +103 -0
  32. package/lib/core/mission-utils.js +833 -875
  33. package/lib/core/mission-utils.ts +998 -0
  34. package/lib/core/persistent-data-migration.js +247 -205
  35. package/lib/core/persistent-data-migration.ts +255 -0
  36. package/lib/core/product-config.js +400 -476
  37. package/lib/core/product-config.ts +518 -0
  38. package/lib/core/runtime-matrix.js +41 -87
  39. package/lib/core/runtime-matrix.ts +84 -0
  40. package/lib/core/spawn-tee.js +152 -192
  41. package/lib/core/spawn-tee.ts +202 -0
  42. package/lib/core/state-map.js +92 -92
  43. package/lib/core/state-map.ts +115 -0
  44. package/lib/core/storage.js +101 -129
  45. package/lib/core/storage.ts +182 -0
  46. package/lib/core/verification.js +148 -138
  47. package/lib/core/verification.ts +200 -0
  48. package/package.json +4 -2
  49. package/prompts/draft.md +1 -0
  50. package/prompts/portfolio.md +1 -1
  51. package/px.js +0 -2
@@ -0,0 +1,370 @@
1
+ import { spawnAndTee } from '../core/spawn-tee.js';
2
+ import { extractOpencodeTelemetryFromExport } from './opencode-telemetry.js';
3
+ import { captureOpencodeExport } from './opencode-export.js';
4
+ import { detectLimitHit } from './limit-hit.js';
5
+ // tools/sessions and core/subagent-limit are still CJS (not converted in this
6
+ // wave); require keeps them untyped (any) without pulling non-included .js into
7
+ // the typecheck program.
8
+ const sessions = require('../tools/sessions');
9
+ const { buildSubagentLimitPrefix } = require('../core/subagent-limit');
10
+
11
+ interface BuildOpencodeInvocationOptions {
12
+ prompt: string;
13
+ worktree: string;
14
+ env?: object;
15
+ resume?: boolean;
16
+ sessionId?: string | null;
17
+ model?: string | null;
18
+ preferJson?: boolean;
19
+ }
20
+
21
+ interface StartOpencodeAgentOptions {
22
+ prompt: string;
23
+ worktree: string;
24
+ env?: object;
25
+ resume?: boolean;
26
+ sessionId?: string | null;
27
+ model?: string | null;
28
+ teeOptions?: object;
29
+ slug?: string | null;
30
+ role?: string | null;
31
+ maxTransientRetries?: number;
32
+ }
33
+
34
+ // Injectable I/O for tests. Production uses the real spawn-tee / export capture.
35
+ let _spawnAndTee: any = spawnAndTee;
36
+ let _captureExport: any = captureOpencodeExport;
37
+ let _sessions: any = sessions;
38
+
39
+ // Test hooks: override the launcher's I/O without touching the public signature.
40
+ function __setSpawnAndTeeForTest(fn: any) { _spawnAndTee = fn || spawnAndTee; }
41
+ function __setExportCaptureForTest(fn: any) { _captureExport = fn || captureOpencodeExport; }
42
+ function __setSessionsForTest(mod: any) { _sessions = mod || sessions; }
43
+
44
+ // Test hook: override the cached feature-detect for `--format json` support.
45
+ // Set to true/false to force inclusion/exclusion of the flag regardless of
46
+ // the real binary. Pass null to reset to the live detect.
47
+ function __setJsonFormatSupportForTest(val: boolean | null) {
48
+ _jsonFormatSupported = val;
49
+ _jsonFormatDetectFn = null;
50
+ }
51
+
52
+ // Test hook: inject a feature-detect function for tests. This replaces the
53
+ // live shell-out with a stubbed function, making tests hermetic.
54
+ function __setJsonFormatDetectForTest(fn: any) {
55
+ _jsonFormatDetectFn = fn;
56
+ _jsonFormatSupported = null; // invalidate cache so the detect fn runs
57
+ }
58
+
59
+ // Session-id recovery for telemetry. After session completion `opencode export`
60
+ // is invoked to extract token-usage data (see opencode-telemetry.js), so the
61
+ // session id MUST be recoverable from the launcher's stdout. Stats hooks in
62
+ // active.js and review-loop.js call recordStageStats which defaults to '0' for
63
+ // tokens when telemetry is null.
64
+ //
65
+ // Two shapes are recognized:
66
+ // 1. Legacy footer: "Continue opencode -s ses_<id>" (older opencode TUI).
67
+ // 2. JSON stream: `"sessionID":"ses_<id>"` (opencode v2.0.0
68
+ // `run --format json`). v2.0.0's plain `run` no longer prints the footer,
69
+ // so without the JSON field the id is lost and custom telemetry drops to all
70
+ // zeros (task-1339). We request `--format json` in buildOpencodeInvocation
71
+ // so this field is always present.
72
+ const OPENCODE_SESSION_ID_RE = /opencode\s+-s\s+(ses_\S+)/i;
73
+ const OPENCODE_JSON_SESSION_ID_RE = /"sessionID"\s*:\s*"(ses_[^"]+)"/;
74
+
75
+ function extractOpencodeSessionId(stdout: string) {
76
+ if (!stdout) {return null;}
77
+ const footer = OPENCODE_SESSION_ID_RE.exec(stdout);
78
+ if (footer) {return footer[1];}
79
+ const json = OPENCODE_JSON_SESSION_ID_RE.exec(stdout);
80
+ return json ? json[1] : null;
81
+ }
82
+
83
+ // Cached feature-detect for `--format json` support. Set to `false` only when
84
+ // a real invocation proves the flag is rejected (older opencode versions).
85
+ // Tests can inject a canned result via __setJsonFormatSupportForTest.
86
+ let _jsonFormatSupported: boolean | null = null;
87
+
88
+ // Injectable feature-detect function for tests. When set, checkJsonFormatSupport()
89
+ // calls this function instead of shelling out, making tests hermetic.
90
+ let _jsonFormatDetectFn: (() => any) | null = null;
91
+
92
+ function resolveOpencodeCommand() {
93
+ return 'opencode';
94
+ }
95
+
96
+ // Lightweight feature-detect: try `opencode --format json --help`.
97
+ // Returns true if the flag is accepted (exit 0 or non-zero for unrelated reasons),
98
+ // false if the flag itself is rejected ("unrecognized" / "unknown option").
99
+ // Results are cached so the check runs at most once in production.
100
+ // Tests can inject a canned result via __setJsonFormatSupportForTest.
101
+ function checkJsonFormatSupport() {
102
+ if (_jsonFormatSupported !== null) {return _jsonFormatSupported;}
103
+ if (_jsonFormatDetectFn) {
104
+ _jsonFormatSupported = _jsonFormatDetectFn();
105
+ return _jsonFormatSupported;
106
+ }
107
+ try {
108
+ const { spawnSync } = require('node:child_process');
109
+ const result = spawnSync('opencode', ['--format', 'json', '--help'], {
110
+ timeout: 3000,
111
+ stdio: ['ignore', 'pipe', 'pipe'],
112
+ });
113
+ const stderr = (result.stderr && result.stderr.toString()) || '';
114
+ const combined = stderr;
115
+ // Flag rejected → fallback to legacy invocation.
116
+ if (/unrecognized|unknown option|no such option|invalid option/i.test(combined)) {
117
+ _jsonFormatSupported = false;
118
+ return false;
119
+ }
120
+ _jsonFormatSupported = true;
121
+ return true;
122
+ } catch (_) {
123
+ // ENOENT or other spawn errors → assume legacy (no JSON flag).
124
+ _jsonFormatSupported = false;
125
+ return false;
126
+ }
127
+ }
128
+
129
+ function buildOpencodeInvocation({ prompt, worktree, env, resume = false, sessionId = null, model = null, preferJson = true }: BuildOpencodeInvocationOptions) {
130
+ // `--format json` makes opencode stream NDJSON events that each carry a
131
+ // "sessionID":"ses_..." field. opencode v2.0.0's default `run` output no
132
+ // longer prints the "Continue opencode -s ses_..." footer, so JSON is the
133
+ // only reliable way to recover the session id needed for `opencode export`
134
+ // telemetry capture (task-1339).
135
+ //
136
+ // Compatibility guard: feature-detect the flag on first call; older opencode
137
+ // versions that reject `--format json` will silently fall back to the legacy
138
+ // invocation, preserving launch behaviour at the cost of telemetry.
139
+ const useJson = preferJson && checkJsonFormatSupport();
140
+ const args = ['run', '--pure', '--dangerously-skip-permissions'];
141
+ if (useJson) {args.push('--format', 'json');}
142
+ if (model) {args.push('-m', model);}
143
+ // Only resume when the marker explicitly says so (resume=true).
144
+ // sessionId from a stale cross-family marker must be ignored when resume=false.
145
+ if (resume && sessionId) {
146
+ args.push('-s', sessionId);
147
+ } else if (resume) {
148
+ args.push('--continue');
149
+ }
150
+ args.push(prompt);
151
+ return {
152
+ command: resolveOpencodeCommand(),
153
+ args,
154
+ options: {
155
+ stdio: 'inherit',
156
+ cwd: worktree,
157
+ env: { ...process.env, ...env }
158
+ }
159
+ };
160
+ }
161
+
162
+ // Transient custom/vLLM backend failures that opencode surfaces as a non-zero
163
+ // exit. These are recoverable: the same prompt frequently succeeds on a second
164
+ // attempt, so the launcher retries once in-family before agents.js reroutes to a
165
+ // different agent family. Limit-hit phrases are intentionally NOT listed here —
166
+ // detectLimitHit() in agents.js owns that accounting (lib/agents/limit-hit.js).
167
+ // Hard errors (model-not-found, auth, ENOENT/EACCES) are never retried.
168
+ const TRANSIENT_OPENCODE_PATTERNS = [
169
+ /\bECONNRESET\b/i,
170
+ /\bECONNREFUSED\b/i,
171
+ /\bETIMEDOUT\b/i,
172
+ /\bENETUNREACH\b/i,
173
+ /\bENOTFOUND\b/i,
174
+ /\bEAI_AGAIN\b/i,
175
+ /\bEPIPE\b/i,
176
+ /socket hang ?up/i,
177
+ /\bfetch failed\b/i,
178
+ /network (?:error|timeout)/i,
179
+ /connection (?:error|reset|closed|refused|timed? ?out)/i,
180
+ /\b(?:502|503|504|529)\b/,
181
+ /\bbad gateway\b/i,
182
+ /\bservice (?:is )?unavailable\b/i,
183
+ /\bgateway time-?out\b/i,
184
+ /\b(?:server |api |model )?overloaded\b/i,
185
+ /\btemporarily unavailable\b/i,
186
+ /\bplease (?:try|retry) again\b/i,
187
+ /\bretry[- ]after\b/i,
188
+ ];
189
+
190
+ // Hard, non-retryable failures: retrying cannot help and only wastes time/quota.
191
+ const HARD_OPENCODE_PATTERNS = [
192
+ /\bmodel not found\b/i,
193
+ /\bno such model\b/i,
194
+ /\bunknown model\b/i,
195
+ /\binvalid (?:api )?key\b/i,
196
+ /\b401\b[^\n]*\bunauthorized\b/i,
197
+ /\bauthentication (?:failed|error)\b/i,
198
+ ];
199
+
200
+ function failureText(result: any) {
201
+ return `${(result && result.stdout) || ''}\n${(result && result.stderr) || ''}`;
202
+ }
203
+
204
+ function isHardOpencodeFailure(result: any) {
205
+ if (!result) {return false;}
206
+ if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {return true;}
207
+ const text = failureText(result);
208
+ return HARD_OPENCODE_PATTERNS.some((re) => re.test(text));
209
+ }
210
+
211
+ // Opencode v2.0.0 JSON-mode sometimes exits with code 1 during post-run
212
+ // cleanup (race closing the stdout pipe, EPIPE, or draining pending events).
213
+ // When the transcript contains a valid "reason":"stop" completion event and
214
+ // no "type":"error" event, the exit is spurious – the agent actually
215
+ // completed successfully. See opencode issues #31446 and #33653.
216
+ function isSpuriousOpencodeExit(result: any) {
217
+ if (!result || result.status !== 1) {return false;}
218
+ const text = failureText(result);
219
+ const hasStop = /"reason"\s*:\s*"stop"/.test(text);
220
+ const hasError = /"type"\s*:\s*"error"/.test(text);
221
+ return hasStop && !hasError;
222
+ }
223
+
224
+ function isTransientOpencodeFailure(result: any) {
225
+ if (!result) {return false;}
226
+ const text = failureText(result);
227
+ return TRANSIENT_OPENCODE_PATTERNS.some((re) => re.test(text));
228
+ }
229
+
230
+ // Decide whether a failed opencode run should be retried once in-family.
231
+ // Only genuine non-zero exits carrying a transient backend signature qualify;
232
+ // clean exits, spawn errors (ENOENT/EACCES), killing signals, limit-hits, and
233
+ // hard errors never retry — so real failures still surface to the reroute loop.
234
+ function shouldRetryOpencodeFailure(result: any) {
235
+ if (!result) {return false;}
236
+ if (result.error && (result.error.code === 'ENOENT' || result.error.code === 'EACCES')) {return false;}
237
+ if (result.signal) {return false;}
238
+ if (!(typeof result.status === 'number' && result.status !== 0)) {return false;}
239
+ if (detectLimitHit({
240
+ agent: 'custom',
241
+ stdout: result.stdout,
242
+ stderr: result.stderr,
243
+ status: result.status,
244
+ signal: result.signal,
245
+ error: result.error,
246
+ })) {return false;}
247
+ if (isHardOpencodeFailure(result)) {return false;}
248
+ return isTransientOpencodeFailure(result);
249
+ }
250
+
251
+ function startOpencodeAgent({
252
+ prompt,
253
+ worktree,
254
+ env,
255
+ resume = false,
256
+ sessionId = null,
257
+ model = null,
258
+ teeOptions = {},
259
+ slug = null,
260
+ role = null,
261
+ maxTransientRetries = 1
262
+ }: StartOpencodeAgentOptions) {
263
+ // Prepend the subagent-limit advisory prefix to the prompt.
264
+ // When maxParallel is unset/null/zero the prefix is empty (no-op).
265
+ const subagentPrefix = buildSubagentLimitPrefix();
266
+ const injectedPrompt = subagentPrefix + prompt;
267
+
268
+ function isStaleSessionResult(result: any) {
269
+ if (!result) {return false;}
270
+ const stderr = result.stderr || '';
271
+ const stdout = result.stdout || '';
272
+ return stderr.includes('Session not found') || stdout.includes('Session not found');
273
+ }
274
+
275
+ function runInvocation(invocation: any) {
276
+ const spawnOptions = { ...invocation.options, ...teeOptions };
277
+ return _spawnAndTee(invocation.command, invocation.args, spawnOptions);
278
+ }
279
+
280
+ async function runInvocationWithRetry(invocation: any) {
281
+ let attempts = 0;
282
+ let result = await runInvocation(invocation);
283
+ while (attempts < maxTransientRetries && shouldRetryOpencodeFailure(result)) {
284
+ attempts += 1;
285
+ result = await runInvocation(invocation);
286
+ }
287
+ if (result) {result.transientRetries = attempts;}
288
+ return result;
289
+ }
290
+
291
+ // Detect whether `--format json` was rejected at runtime (e.g. older opencode
292
+ // that wasn't caught by the feature-detect). If so, retry the invocation
293
+ // without the flag so the prompt still runs (telemetry will be lost but the
294
+ // agent won't fail outright).
295
+ function isJsonFlagError(result: any) {
296
+ if (!result) {return false;}
297
+ const text = failureText(result);
298
+ return (/\bunrecognized option\b|\bunknown option\b|\bno such option\b|\binvalid option\b|\bunrecognized flag\b|\bunknown flag\b/i).test(text);
299
+ }
300
+
301
+ async function processResult(result: any) {
302
+ if (result && result.stdout) {
303
+ result.sessionId = extractOpencodeSessionId(result.stdout) || undefined;
304
+ }
305
+ if (result && result.sessionId) {
306
+ try {
307
+ const exportJson = await _captureExport(result.sessionId, { worktree, env });
308
+ if (exportJson) {
309
+ const telemetry = extractOpencodeTelemetryFromExport(exportJson, model || undefined);
310
+ if (telemetry) {
311
+ result.telemetry = telemetry;
312
+ if ((telemetry as any).model) {result.model = (telemetry as any).model;}
313
+ if ((telemetry as any).provider) {result.provider = (telemetry as any).provider;}
314
+ }
315
+ }
316
+ } catch (_) {
317
+ return result;
318
+ }
319
+ }
320
+ return result;
321
+ }
322
+
323
+ async function runWithJsonFallback(invocation: any) {
324
+ let result = await runInvocationWithRetry(invocation);
325
+ // Runtime fallback: if the first invocation used --format json but the
326
+ // binary rejected it, retry without the flag so the prompt still executes.
327
+ if (isJsonFlagError(result) && invocation.args.includes('--format')) {
328
+ const legacyInv = buildOpencodeInvocation({
329
+ prompt: injectedPrompt, worktree, env, resume, sessionId, model, preferJson: false,
330
+ });
331
+ result = await runInvocationWithRetry(legacyInv);
332
+ // Mark that we fell back so callers know telemetry may be absent.
333
+ if (result) {result._jsonFallback = true;}
334
+ }
335
+ return result;
336
+ }
337
+
338
+ async function staleSessionHandler(invocation: any) {
339
+ let result = await runWithJsonFallback(invocation);
340
+ if (isStaleSessionResult(result) && worktree && resume) {
341
+ try {
342
+ _sessions.clearSession(worktree, slug || '', role || '');
343
+ } catch (_) { /* best-effort */ }
344
+ const freshInv = buildOpencodeInvocation({ prompt: injectedPrompt, worktree, env, resume: false, sessionId: null, model });
345
+ result = await runWithJsonFallback(freshInv);
346
+ }
347
+ return processResult(result);
348
+ }
349
+
350
+ const invocation = buildOpencodeInvocation({ prompt: injectedPrompt, worktree, env, resume, sessionId, model });
351
+ const resultPromise = staleSessionHandler(invocation);
352
+
353
+ return { invocation, resultPromise };
354
+ }
355
+
356
+ export {
357
+ buildOpencodeInvocation,
358
+ extractOpencodeSessionId,
359
+ resolveOpencodeCommand,
360
+ startOpencodeAgent,
361
+ isSpuriousOpencodeExit,
362
+ isTransientOpencodeFailure,
363
+ isHardOpencodeFailure,
364
+ shouldRetryOpencodeFailure,
365
+ __setSpawnAndTeeForTest,
366
+ __setExportCaptureForTest,
367
+ __setSessionsForTest,
368
+ __setJsonFormatSupportForTest,
369
+ __setJsonFormatDetectForTest,
370
+ };
@@ -1,5 +1,6 @@
1
1
  'use strict';
2
-
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.resolveStageTelemetry = resolveStageTelemetry;
3
4
  // Shared resolution of the most faithful telemetry for a recorded workflow
4
5
  // stage (execute/review). Codex's authoritative usage is the windowed sum read
5
6
  // from its on-disk rollout — `sinceMs` bounds the read to just this stage — so
@@ -13,30 +14,28 @@
13
14
  // fixes was active.js computing `sinceMs` and then discarding it, recording
14
15
  // cumulative Codex usage for the execute phase instead of the windowed delta
15
16
  // (task-1285 review F1/F6).
16
-
17
- const { codexHomeRoot, extractCodexTelemetry } = require('./codex');
18
-
17
+ const codex_js_1 = require("./codex.js");
19
18
  /**
20
- * @param {{ worktree: string, result: { telemetry?: { provider?: string } & object }, sinceMs?: number }} opts
21
- * @returns {object|null} normalized telemetry, or null when none is available.
19
+ * @returns normalized telemetry, or null when none is available.
22
20
  */
23
- function resolveStageTelemetry({ worktree, result, sinceMs = 0 } = /** @type {{ worktree: string, result: { telemetry?: { provider?: string } & object }, sinceMs?: number }} */({})) {
24
- if (!result || !result.telemetry) {return null;}
25
- try {
26
- const codexT = extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: sinceMs || 0 });
27
- // Guard against stale Codex rollouts bleeding into non-Codex stages:
28
- // if the launcher attached telemetry with a provider that differs from
29
- // what Codex would produce (e.g. anthropic vs openai), discard the Codex
30
- // read and use the launcher-attached telemetry instead.
31
- if (codexT && codexT.provider && result.telemetry.provider
32
- && codexT.provider.toLowerCase() !== result.telemetry.provider.toLowerCase()) {
33
- return result.telemetry;
21
+ function resolveStageTelemetry({ worktree, result, sinceMs = 0 } = {}) {
22
+ if (!result || !result.telemetry) {
23
+ return null;
24
+ }
25
+ try {
26
+ const codexT = (0, codex_js_1.extractCodexTelemetry)((0, codex_js_1.codexHomeRoot)(worktree), { sinceMs: sinceMs || 0 });
27
+ // Guard against stale Codex rollouts bleeding into non-Codex stages:
28
+ // if the launcher attached telemetry with a provider that differs from
29
+ // what Codex would produce (e.g. anthropic vs openai), discard the Codex
30
+ // read and use the launcher-attached telemetry instead.
31
+ if (codexT && codexT.provider && result.telemetry.provider
32
+ && codexT.provider.toLowerCase() !== result.telemetry.provider.toLowerCase()) {
33
+ return result.telemetry;
34
+ }
35
+ return codexT || result.telemetry;
36
+ }
37
+ catch (_) {
38
+ // Codex rollout unreadable: fall back to this stage's launcher telemetry.
39
+ return result.telemetry;
34
40
  }
35
- return codexT || result.telemetry;
36
- } catch (_) {
37
- // Codex rollout unreadable: fall back to this stage's launcher telemetry.
38
- return result.telemetry;
39
- }
40
41
  }
41
-
42
- module.exports = { resolveStageTelemetry };
@@ -0,0 +1,47 @@
1
+ 'use strict';
2
+
3
+ // Shared resolution of the most faithful telemetry for a recorded workflow
4
+ // stage (execute/review). Codex's authoritative usage is the windowed sum read
5
+ // from its on-disk rollout — `sinceMs` bounds the read to just this stage — so
6
+ // prefer it when present. For non-Codex agents (e.g. Claude, whose usage the
7
+ // launcher parses from stdout via claude-telemetry.js) no codex rollout exists
8
+ // and extractCodexTelemetry returns null *without throwing*, so we fall back to
9
+ // the telemetry the launcher already attached.
10
+ //
11
+ // This lives in one place so the execute hook (commands/active.js) and the
12
+ // review-loop hook (review/review-loop.js) cannot drift apart — the bug this
13
+ // fixes was active.js computing `sinceMs` and then discarding it, recording
14
+ // cumulative Codex usage for the execute phase instead of the windowed delta
15
+ // (task-1285 review F1/F6).
16
+
17
+ import { codexHomeRoot, extractCodexTelemetry } from './codex.js';
18
+
19
+ interface StageTelemetryOptions {
20
+ worktree: string;
21
+ result: { telemetry?: { provider?: string } & Record<string, any> };
22
+ sinceMs?: number;
23
+ }
24
+
25
+ /**
26
+ * @returns normalized telemetry, or null when none is available.
27
+ */
28
+ function resolveStageTelemetry({ worktree, result, sinceMs = 0 }: StageTelemetryOptions = {} as StageTelemetryOptions) {
29
+ if (!result || !result.telemetry) {return null;}
30
+ try {
31
+ const codexT = extractCodexTelemetry(codexHomeRoot(worktree), { sinceMs: sinceMs || 0 });
32
+ // Guard against stale Codex rollouts bleeding into non-Codex stages:
33
+ // if the launcher attached telemetry with a provider that differs from
34
+ // what Codex would produce (e.g. anthropic vs openai), discard the Codex
35
+ // read and use the launcher-attached telemetry instead.
36
+ if (codexT && codexT.provider && result.telemetry.provider
37
+ && codexT.provider.toLowerCase() !== result.telemetry.provider.toLowerCase()) {
38
+ return result.telemetry;
39
+ }
40
+ return codexT || result.telemetry;
41
+ } catch (_) {
42
+ // Codex rollout unreadable: fall back to this stage's launcher telemetry.
43
+ return result.telemetry;
44
+ }
45
+ }
46
+
47
+ export { resolveStageTelemetry };
@@ -599,7 +599,9 @@ async function integrate(args) {
599
599
  }
600
600
  throw new IntegrationAbort();
601
601
  }
602
- nextActionMessage = `Next: cd ${baseWorktree}`;
602
+ if (fs.existsSync(baseWorktree)) {
603
+ nextActionMessage = `Next: cd ${baseWorktree}`;
604
+ }
603
605
  recordPostIntegrationStatsOrAbort(slug, { rootDir: baseWorktree });
604
606
  if (!cleanupMissionWorktree(slug)) {
605
607
  fmt.log.fail('Mission worktree cleanup failed for Variant A.');
@@ -644,7 +646,9 @@ async function integrate(args) {
644
646
  } else {
645
647
  fmt.log.info('Step 6 (resume): Skipping Forgejo sync (review provider is not forgejo).');
646
648
  }
647
- nextActionMessage = `Next: cd ${baseWorktree}`;
649
+ if (fs.existsSync(baseWorktree)) {
650
+ nextActionMessage = `Next: cd ${baseWorktree}`;
651
+ }
648
652
  recordPostIntegrationStatsOrAbort(slug, { rootDir: baseWorktree });
649
653
  fmt.log.info('Step 7 (resume): Cleaning up the local mission worktree...');
650
654
  if (!cleanupMissionWorktree(slug)) {
@@ -769,7 +773,9 @@ async function integrate(args) {
769
773
  fmt.log.info('Step 6: Skipping Forgejo sync (review provider is not forgejo).');
770
774
  }
771
775
 
772
- nextActionMessage = `Next: cd ${baseWorktree}`;
776
+ if (fs.existsSync(baseWorktree)) {
777
+ nextActionMessage = `Next: cd ${baseWorktree}`;
778
+ }
773
779
  recordPostIntegrationStatsOrAbort(slug, { rootDir: baseWorktree });
774
780
  fmt.log.info('Step 7: Cleaning up the local mission worktree...');
775
781
  if (!cleanupMissionWorktree(slug)) {