@nonbot/cli 0.9.13 → 0.10.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.
@@ -9,6 +9,41 @@ import { makeEvent, sanitizeForEgress, assertNoForbiddenFields } from './progres
9
9
  import { LOCAL_TEXT_MAX, SUMMARY_MAX, } from './types.js';
10
10
  const DEFAULT_POLL_MS = 5_000;
11
11
  const DEFAULT_HEARTBEAT_MS = 30_000;
12
+ const EGRESS_CHUNK = 25;
13
+ const CLIENT_MINTED_SESSION_PREFIX = 'choir_';
14
+ export function isClientMintedSessionId(sessionId) {
15
+ return typeof sessionId === 'string' && sessionId.startsWith(CLIENT_MINTED_SESSION_PREFIX);
16
+ }
17
+ export const EGRESS_RETRY_MAX = 200;
18
+ const EGRESS_RETRY_BASE_MS = 5_000;
19
+ const EGRESS_RETRY_MAX_MS = 5 * 60_000;
20
+ export function retryQueueKey(ev) {
21
+ const s = typeof ev.sessionId === 'string' ? ev.sessionId : '';
22
+ const p = typeof ev.paneId === 'string' ? ev.paneId : '';
23
+ return `${s}|${p}`;
24
+ }
25
+ export function trimRetryQueue(queue, cap, keyOf = retryQueueKey) {
26
+ const limit = Number.isFinite(cap) && cap > 0 ? Math.floor(cap) : 0;
27
+ if (queue.length <= limit)
28
+ return queue;
29
+ const cut = queue.length - limit;
30
+ const tail = queue.slice(cut);
31
+ const covered = new Set();
32
+ for (const ev of tail)
33
+ covered.add(keyOf(ev));
34
+ const survivors = [];
35
+ for (let i = cut - 1; i >= 0; i--) {
36
+ const k = keyOf(queue[i]);
37
+ if (covered.has(k))
38
+ continue;
39
+ covered.add(k);
40
+ survivors.push([i, queue[i]]);
41
+ }
42
+ if (survivors.length === 0)
43
+ return tail;
44
+ survivors.reverse();
45
+ return [...survivors.map(([, ev]) => ev), ...tail];
46
+ }
12
47
  const DEFAULT_WRITE_RATE_LIMIT = 60;
13
48
  const DEFAULT_WRITE_RATE_WINDOW_MS = 60_000;
14
49
  const READ_TOOLS = new Set(['run_radar', 'run_check', 'run_status']);
@@ -34,6 +69,7 @@ function constantTimeEqual(a, b) {
34
69
  export function createHub(opts) {
35
70
  const now = opts.now ?? (() => Date.now());
36
71
  const fetchImpl = opts.fetchImpl ?? fetch;
72
+ const log = opts.log ?? ((_s) => { });
37
73
  const spawnImpl = opts.spawnImpl;
38
74
  const journalFs = opts.journalFs ?? {
39
75
  appendFileSync: nodeAppendFileSync,
@@ -55,6 +91,10 @@ export function createHub(opts) {
55
91
  const rateWindows = new Map();
56
92
  const gate = new EmissionGate();
57
93
  let pendingEvents = [];
94
+ let retryQueue = [];
95
+ let retryNotBefore = 0;
96
+ let retryBackoffMs = EGRESS_RETRY_BASE_MS;
97
+ const localOnly = isClientMintedSessionId(opts.sessionId);
58
98
  let server = null;
59
99
  let pollTimer = null;
60
100
  let heartbeatTimer = null;
@@ -350,25 +390,76 @@ export function createHub(opts) {
350
390
  return null;
351
391
  }
352
392
  }
353
- function flushEgress() {
354
- if (pendingEvents.length === 0)
355
- return;
356
- const batch = pendingEvents.map((e) => sanitizeForEgress(e));
357
- pendingEvents = [];
358
- const safe = [];
359
- for (const ev of batch) {
360
- try {
361
- assertNoForbiddenFields(ev);
362
- safe.push(ev);
393
+ async function flushEgress() {
394
+ try {
395
+ if (pendingEvents.length > 0) {
396
+ const batch = pendingEvents.map((e) => sanitizeForEgress(e));
397
+ pendingEvents = [];
398
+ for (const ev of batch) {
399
+ try {
400
+ assertNoForbiddenFields(ev);
401
+ retryQueue.push(ev);
402
+ }
403
+ catch {
404
+ }
405
+ }
406
+ retryQueue = trimRetryQueue(retryQueue, EGRESS_RETRY_MAX);
363
407
  }
364
- catch {
408
+ if (localOnly) {
409
+ retryQueue = [];
410
+ return;
411
+ }
412
+ if (retryQueue.length === 0)
413
+ return;
414
+ if (now() < retryNotBefore)
415
+ return;
416
+ while (retryQueue.length > 0) {
417
+ const chunk = retryQueue.slice(0, EGRESS_CHUNK);
418
+ const outcome = await postEvents(chunk);
419
+ if (outcome === 'throttled') {
420
+ retryQueue = trimRetryQueue(retryQueue, EGRESS_RETRY_MAX);
421
+ retryNotBefore = now() + retryBackoffMs;
422
+ retryBackoffMs = Math.min(retryBackoffMs * 2, EGRESS_RETRY_MAX_MS);
423
+ return;
424
+ }
425
+ retryQueue = retryQueue.slice(chunk.length);
426
+ retryBackoffMs = EGRESS_RETRY_BASE_MS;
365
427
  }
366
428
  }
367
- if (safe.length === 0)
368
- return;
369
- postJson(`${baseUrl}/api/cli/choir/events`, { sessionId: state.sessionId, events: safe });
429
+ catch {
430
+ }
431
+ }
432
+ async function postEvents(events) {
433
+ const res = await postJson(`${baseUrl}/api/cli/choir/events`, {
434
+ sessionId: state.sessionId,
435
+ events,
436
+ });
437
+ if (!res)
438
+ return 'dropped';
439
+ const status = res.status;
440
+ if (status === 429)
441
+ return 'throttled';
442
+ try {
443
+ if (typeof res.json !== 'function')
444
+ return 'sent';
445
+ const body = (await res.json());
446
+ if (!body || typeof body !== 'object')
447
+ return 'sent';
448
+ const skipped = body.skipped;
449
+ if (typeof skipped === 'number' && skipped > 0) {
450
+ const accepted = body.accepted;
451
+ log(`⚠ choir events: server skipped ${skipped} of ${events.length} ` +
452
+ `(accepted ${typeof accepted === 'number' ? accepted : '?'}) — ` +
453
+ `session not persisted server-side\n`);
454
+ }
455
+ }
456
+ catch {
457
+ }
458
+ return 'sent';
370
459
  }
371
460
  function sendHeartbeat() {
461
+ if (localOnly)
462
+ return;
372
463
  const panes = Object.values(state.panes);
373
464
  const body = {
374
465
  sessionId: state.sessionId,
@@ -407,17 +498,21 @@ export function createHub(opts) {
407
498
  },
408
499
  body: JSON.stringify(body),
409
500
  });
410
- if (p && typeof p.catch === 'function') {
411
- ;
412
- p.catch(() => {
413
- });
501
+ if (p && typeof p.then === 'function') {
502
+ return p.catch(() => null);
414
503
  }
504
+ return Promise.resolve(p ?? null);
415
505
  }
416
506
  catch {
507
+ return Promise.resolve(null);
417
508
  }
418
509
  }
419
510
  return {
420
511
  start() {
512
+ if (localOnly) {
513
+ log('ℹ choir hub: local session telemetry stays LOCAL — the web only ' +
514
+ 'persists events for server-issued run sessions.\n');
515
+ }
421
516
  try {
422
517
  const impl = opts.netImpl;
423
518
  if (!impl)
@@ -459,6 +554,9 @@ export function createHub(opts) {
459
554
  getState() {
460
555
  return state;
461
556
  },
557
+ retryQueueSize() {
558
+ return retryQueue.length;
559
+ },
462
560
  dispatch,
463
561
  pollOnce,
464
562
  flushEgress,
@@ -0,0 +1,306 @@
1
+ import { spawn as nodeSpawn, spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import { readdirSync as nodeReaddirSync, rmSync as nodeRmSync, statSync as nodeStatSync, writeFileSync as nodeWriteFileSync, } from 'node:fs';
3
+ import { tmpdir } from 'node:os';
4
+ import path from 'node:path';
5
+ import { validateActivationId, validateRepoRef, repoUrlLooksSafe } from './payload-validator.js';
6
+ const defaultWorkspaceFs = {
7
+ readdirSync: (p) => nodeReaddirSync(p),
8
+ statSync: (p) => nodeStatSync(p),
9
+ rmSync: (p, opts) => nodeRmSync(p, opts),
10
+ writeFileSync: (p, data) => nodeWriteFileSync(p, data),
11
+ };
12
+ const CLONE_TIMEOUT_MS = 45_000;
13
+ const GIT_TIMEOUT_MS = 30_000;
14
+ export const DEFAULT_CLOUD_BASE_DIR = path.join(tmpdir(), 'nonbot-cloud');
15
+ export const MAX_CLOUD_WORKSPACES = 20;
16
+ export const WORKSPACE_MAX_AGE_MS = 24 * 60 * 60 * 1000;
17
+ export const REASON_TOO_MANY_WORKSPACES = 'too many workspaces';
18
+ export const REASON_ORIGIN_TOO_BROAD = 'allowlist entry too broad';
19
+ const OWN_REASONS = new Set([
20
+ 'invalid repo url',
21
+ 'origin not allowed',
22
+ REASON_ORIGIN_TOO_BROAD,
23
+ 'invalid repo ref',
24
+ 'invalid activation id',
25
+ REASON_TOO_MANY_WORKSPACES,
26
+ ]);
27
+ const EXIT_CODED_REASON_RE = /^(clone|checkout) failed \(exit [-\w]+\)$/;
28
+ const REASON_MAX = 200;
29
+ function runGit(spawnImpl, cwd, args) {
30
+ return spawnImpl('git', ['-C', cwd, ...args], {
31
+ encoding: 'utf-8',
32
+ timeout: GIT_TIMEOUT_MS,
33
+ windowsHide: true,
34
+ });
35
+ }
36
+ function exitLabel(r) {
37
+ const status = r?.status;
38
+ return status === null || status === undefined ? 'unknown' : String(status);
39
+ }
40
+ function exitLabelFor(code) {
41
+ return code === null ? 'unknown' : String(code);
42
+ }
43
+ function runGitAsync(spawnImpl, args, timeoutMs) {
44
+ return new Promise((resolve) => {
45
+ let settled = false;
46
+ const done = (code) => {
47
+ if (settled)
48
+ return;
49
+ settled = true;
50
+ resolve(code);
51
+ };
52
+ let child;
53
+ try {
54
+ child = spawnImpl('git', args, {
55
+ timeout: timeoutMs,
56
+ windowsHide: true,
57
+ stdio: 'ignore',
58
+ });
59
+ }
60
+ catch {
61
+ done(null);
62
+ return;
63
+ }
64
+ try {
65
+ child.on('error', () => done(null));
66
+ child.on('close', (code) => done(typeof code === 'number' ? code : null));
67
+ }
68
+ catch {
69
+ done(null);
70
+ }
71
+ });
72
+ }
73
+ export function countCloudWorkspaces(baseDir = DEFAULT_CLOUD_BASE_DIR, fsImpl = defaultWorkspaceFs) {
74
+ try {
75
+ return fsImpl.readdirSync(baseDir).length;
76
+ }
77
+ catch {
78
+ return 0;
79
+ }
80
+ }
81
+ export function removeCloudWorkspace(repoPath, opts = {}) {
82
+ const baseDir = opts.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
83
+ const fsImpl = opts.fsImpl ?? defaultWorkspaceFs;
84
+ if (typeof repoPath !== 'string' || repoPath.length === 0)
85
+ return false;
86
+ const resolved = path.resolve(repoPath);
87
+ const base = path.resolve(baseDir);
88
+ if (path.dirname(resolved) !== base)
89
+ return false;
90
+ try {
91
+ fsImpl.rmSync(resolved, { recursive: true, force: true });
92
+ return true;
93
+ }
94
+ catch {
95
+ return false;
96
+ }
97
+ }
98
+ export const WORKSPACE_LIVENESS_FILE = '.nonbot-live';
99
+ export function touchWorkspaceLiveness(repoPath, opts = {}) {
100
+ const baseDir = opts.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
101
+ const fsImpl = opts.fsImpl ?? defaultWorkspaceFs;
102
+ const now = opts.now ?? Date.now;
103
+ if (typeof repoPath !== 'string' || repoPath.length === 0)
104
+ return false;
105
+ const resolved = path.resolve(repoPath);
106
+ if (path.dirname(resolved) !== path.resolve(baseDir))
107
+ return false;
108
+ if (typeof fsImpl.writeFileSync !== 'function')
109
+ return false;
110
+ try {
111
+ fsImpl.writeFileSync(path.join(resolved, WORKSPACE_LIVENESS_FILE), `${now()}\n`);
112
+ return true;
113
+ }
114
+ catch {
115
+ return false;
116
+ }
117
+ }
118
+ export function reapStaleWorkspaces(opts = {}) {
119
+ const baseDir = opts.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
120
+ const maxAgeMs = opts.maxAgeMs ?? WORKSPACE_MAX_AGE_MS;
121
+ const now = opts.now ?? Date.now;
122
+ const fsImpl = opts.fsImpl ?? defaultWorkspaceFs;
123
+ const active = new Set(opts.activeIds ?? []);
124
+ let removed = 0;
125
+ let entries;
126
+ try {
127
+ entries = fsImpl.readdirSync(baseDir);
128
+ }
129
+ catch {
130
+ return 0;
131
+ }
132
+ const cutoff = now() - maxAgeMs;
133
+ for (const name of entries) {
134
+ if (active.has(name))
135
+ continue;
136
+ const full = path.join(baseDir, name);
137
+ try {
138
+ const st = fsImpl.statSync(full);
139
+ if (!st.isDirectory())
140
+ continue;
141
+ if (st.mtimeMs >= cutoff)
142
+ continue;
143
+ if (isMarkedLive(full, cutoff, fsImpl))
144
+ continue;
145
+ fsImpl.rmSync(full, { recursive: true, force: true });
146
+ removed++;
147
+ }
148
+ catch {
149
+ }
150
+ }
151
+ return removed;
152
+ }
153
+ function isMarkedLive(workspaceDir, cutoff, fsImpl) {
154
+ const marker = path.join(workspaceDir, WORKSPACE_LIVENESS_FILE);
155
+ let st;
156
+ try {
157
+ st = fsImpl.statSync(marker);
158
+ }
159
+ catch {
160
+ return false;
161
+ }
162
+ if (!st || typeof st.mtimeMs !== 'number' || !Number.isFinite(st.mtimeMs))
163
+ return true;
164
+ return st.mtimeMs >= cutoff;
165
+ }
166
+ export function normalizeRepoPrefix(url) {
167
+ let u;
168
+ try {
169
+ u = new URL(url);
170
+ }
171
+ catch {
172
+ return null;
173
+ }
174
+ let pathname = u.pathname.replace(/\/+$/, '');
175
+ while (pathname.endsWith('.git')) {
176
+ pathname = pathname.slice(0, -4).replace(/\/+$/, '');
177
+ }
178
+ return `${u.origin}${pathname}/`;
179
+ }
180
+ export function isHostWidePrefix(normalized) {
181
+ if (typeof normalized !== 'string' || normalized.length === 0)
182
+ return false;
183
+ try {
184
+ return new URL(normalized).pathname === '/';
185
+ }
186
+ catch {
187
+ return false;
188
+ }
189
+ }
190
+ export function classifyRepoUrl(repoUrl, allowedOrigins) {
191
+ if (!repoUrlLooksSafe(repoUrl))
192
+ return 'not_allowed';
193
+ if (!Array.isArray(allowedOrigins))
194
+ return 'not_allowed';
195
+ const candidate = normalizeRepoPrefix(repoUrl);
196
+ if (candidate === null)
197
+ return 'not_allowed';
198
+ let sawHostWideCover = false;
199
+ for (const o of allowedOrigins) {
200
+ if (typeof o !== 'string' || o.length === 0)
201
+ continue;
202
+ const entry = normalizeRepoPrefix(o);
203
+ if (entry === null)
204
+ continue;
205
+ if (!candidate.startsWith(entry))
206
+ continue;
207
+ if (isHostWidePrefix(entry)) {
208
+ sawHostWideCover = true;
209
+ continue;
210
+ }
211
+ return 'allowed';
212
+ }
213
+ return sawHostWideCover ? 'too_broad' : 'not_allowed';
214
+ }
215
+ export function repoUrlAllowed(repoUrl, allowedOrigins) {
216
+ return classifyRepoUrl(repoUrl, allowedOrigins) === 'allowed';
217
+ }
218
+ export function runBranchFor(activationId) {
219
+ return `run/${activationId.replace(/^act_/, '').slice(0, 8)}`;
220
+ }
221
+ export async function prepareCloneWorkspace(args) {
222
+ const spawnImpl = args.spawnImpl ?? nodeSpawn;
223
+ if (!repoUrlLooksSafe(args.repoUrl)) {
224
+ throw new Error('invalid repo url');
225
+ }
226
+ const verdict = classifyRepoUrl(args.repoUrl, args.allowedOrigins);
227
+ if (verdict === 'too_broad') {
228
+ throw new Error(REASON_ORIGIN_TOO_BROAD);
229
+ }
230
+ if (verdict !== 'allowed') {
231
+ throw new Error('origin not allowed');
232
+ }
233
+ let ref;
234
+ try {
235
+ ref = validateRepoRef(args.repoRef);
236
+ }
237
+ catch {
238
+ throw new Error('invalid repo ref');
239
+ }
240
+ try {
241
+ validateActivationId(args.activationId);
242
+ }
243
+ catch {
244
+ throw new Error('invalid activation id');
245
+ }
246
+ const baseDir = args.baseDir ?? DEFAULT_CLOUD_BASE_DIR;
247
+ if (countCloudWorkspaces(baseDir, args.fsImpl ?? defaultWorkspaceFs) >= MAX_CLOUD_WORKSPACES) {
248
+ throw new Error(REASON_TOO_MANY_WORKSPACES);
249
+ }
250
+ const repoPath = path.join(baseDir, args.activationId);
251
+ const branch = runBranchFor(args.activationId);
252
+ const cloneArgs = [
253
+ 'clone',
254
+ '--depth',
255
+ '1',
256
+ '--filter=blob:none',
257
+ ...(ref ? ['--branch', ref] : []),
258
+ args.repoUrl,
259
+ repoPath,
260
+ ];
261
+ const cloneCode = await runGitAsync(spawnImpl, cloneArgs, CLONE_TIMEOUT_MS);
262
+ if (cloneCode !== 0) {
263
+ throw new Error(`clone failed (exit ${exitLabelFor(cloneCode)})`);
264
+ }
265
+ const checkoutCode = await runGitAsync(spawnImpl, ['-C', repoPath, 'checkout', '-b', branch], GIT_TIMEOUT_MS);
266
+ if (checkoutCode !== 0) {
267
+ throw new Error(`checkout failed (exit ${exitLabelFor(checkoutCode)})`);
268
+ }
269
+ touchWorkspaceLiveness(repoPath, { baseDir, fsImpl: args.fsImpl ?? defaultWorkspaceFs });
270
+ return { repoPath, branch };
271
+ }
272
+ export function pushRunBranch(args) {
273
+ const spawnImpl = args.spawnImpl ?? nodeSpawnSync;
274
+ const { repoPath, branch, activationId } = args;
275
+ const status = runGit(spawnImpl, repoPath, ['status', '--porcelain']);
276
+ if (!status || status.status !== 0) {
277
+ return { pushed: false, reason: `status failed (exit ${exitLabel(status)})` };
278
+ }
279
+ const dirty = String(status.stdout ?? '').trim() !== '';
280
+ if (dirty) {
281
+ const added = runGit(spawnImpl, repoPath, ['add', '-A']);
282
+ if (!added || added.status !== 0) {
283
+ return { pushed: false, reason: `add failed (exit ${exitLabel(added)})` };
284
+ }
285
+ const committed = runGit(spawnImpl, repoPath, [
286
+ 'commit',
287
+ '-m',
288
+ `wip(run): ${activationId} — auto-push on exit`,
289
+ ]);
290
+ if (!committed || committed.status !== 0) {
291
+ return { pushed: false, reason: `commit failed (exit ${exitLabel(committed)})` };
292
+ }
293
+ }
294
+ const pushed = runGit(spawnImpl, repoPath, ['push', '-u', 'origin', branch]);
295
+ if (!pushed || pushed.status !== 0) {
296
+ return { pushed: false, reason: `push failed (exit ${exitLabel(pushed)})` };
297
+ }
298
+ return { pushed: true };
299
+ }
300
+ export function shortCloneFailureReason(e) {
301
+ const msg = e instanceof Error && typeof e.message === 'string' ? e.message : '';
302
+ if (OWN_REASONS.has(msg) || EXIT_CODED_REASON_RE.test(msg)) {
303
+ return msg.slice(0, REASON_MAX);
304
+ }
305
+ return 'workspace preparation failed';
306
+ }
@@ -12,6 +12,33 @@ const ESC_GREEN = '\\033[32m';
12
12
  const ESC_RED = '\\033[31m';
13
13
  const ESC_CYAN = '\\033[36m';
14
14
  const ESC_PRIMARY = '\\033[38;5;141m';
15
+ export const RUN_TIMEOUT_DEFAULT_MIN = 95;
16
+ export const RUN_TIMEOUT_KILL_AFTER = '60s';
17
+ export const RUN_TIMEOUT_BIN_VAR = 'NB_RUN_TIMEOUT_BIN';
18
+ export function resolveRunTimeoutMinutes(env = process.env) {
19
+ const raw = env.NONBOT_RUN_TIMEOUT_MIN;
20
+ if (typeof raw !== 'string' || raw.trim() === '')
21
+ return RUN_TIMEOUT_DEFAULT_MIN;
22
+ const n = Number(raw.trim());
23
+ if (!Number.isInteger(n) || n < 1)
24
+ return RUN_TIMEOUT_DEFAULT_MIN;
25
+ return n;
26
+ }
27
+ export function wallClockExceededReason(minutes) {
28
+ return `wall clock exceeded (${minutes}m)`;
29
+ }
30
+ const RUN_TIMEOUT_MISSING_NOTE = 'wall clock unenforced: no timeout/gtimeout on PATH (install coreutils)';
31
+ export function buildRunTimeoutPrelude() {
32
+ const warn = emitBanner(' ' + ESC_RED + '⚠' + ESC_RESET + ' ' + ESC_DIM + RUN_TIMEOUT_MISSING_NOTE + ESC_RESET + '\n');
33
+ return (`if command -v timeout >/dev/null 2>&1; then ${RUN_TIMEOUT_BIN_VAR}=timeout; ` +
34
+ `elif command -v gtimeout >/dev/null 2>&1; then ${RUN_TIMEOUT_BIN_VAR}=gtimeout; ` +
35
+ `else ${RUN_TIMEOUT_BIN_VAR}=; ${warn}; fi; `);
36
+ }
37
+ export function buildRunTimeoutPrefix() {
38
+ return (`\${${RUN_TIMEOUT_BIN_VAR}:+\$${RUN_TIMEOUT_BIN_VAR} --signal=TERM ` +
39
+ `--kill-after=${RUN_TIMEOUT_KILL_AFTER} ` +
40
+ `\${NONBOT_RUN_TIMEOUT_MIN:-${RUN_TIMEOUT_DEFAULT_MIN}}m} `);
41
+ }
15
42
  export const PROVIDER_PROFILES = {
16
43
  claude: {
17
44
  realCli: 'claude',
@@ -294,7 +321,11 @@ export function buildRealCommand(params) {
294
321
  const settingsFlag = wantHook
295
322
  ? `--settings ${shellQuoteSingle(params.hookSettingsPath)} `
296
323
  : '';
297
- return `${head}${agentsMdPrefix}${hookHeredocPrefix}${bannerEmit}${cli} ${settingsFlag}${shellQuoteSingle(prompt)}`;
324
+ const wallClock = params.wallClock !== false;
325
+ const timeoutPrelude = wallClock ? buildRunTimeoutPrelude() : '';
326
+ const timeoutPrefix = wallClock ? buildRunTimeoutPrefix() : '';
327
+ return (`${head}${agentsMdPrefix}${hookHeredocPrefix}${timeoutPrelude}` +
328
+ `${bannerEmit}${timeoutPrefix}${cli} ${settingsFlag}${shellQuoteSingle(prompt)}`);
298
329
  }
299
330
  export function buildCommandFromParams(params) {
300
331
  switch (params.template) {
@@ -1,4 +1,6 @@
1
1
  import { spawnSync as nodeSpawnSync } from 'node:child_process';
2
+ import * as nodeFs from 'node:fs';
3
+ import { exitFilePathFor } from './activations.js';
2
4
  export function listLivePaneIds(spawnImpl = nodeSpawnSync) {
3
5
  try {
4
6
  const r = spawnImpl('tmux', ['list-panes', '-a', '-F', '#{pane_id}'], {
@@ -43,6 +45,55 @@ export async function postCompletion(baseUrl, pat, activationId, fetchImpl = fet
43
45
  return false;
44
46
  }
45
47
  }
48
+ export function readExitCodeFile(activationId, fsImpl = nodeFs) {
49
+ try {
50
+ const raw = fsImpl.readFileSync(exitFilePathFor(activationId), 'utf-8').trim();
51
+ if (raw.length === 0)
52
+ return null;
53
+ const n = Number(raw);
54
+ return Number.isInteger(n) && n >= 0 && n <= 255 ? n : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ export function removeExitFile(activationId, fsImpl = nodeFs) {
61
+ try {
62
+ fsImpl.unlinkSync(exitFilePathFor(activationId));
63
+ return true;
64
+ }
65
+ catch {
66
+ return false;
67
+ }
68
+ }
69
+ export async function postFailure(baseUrl, pat, activationId, failureReason, fetchImpl = fetch) {
70
+ try {
71
+ const res = await fetchImpl(`${baseUrl}/api/portfolio/activations/${activationId}`, {
72
+ method: 'PATCH',
73
+ headers: {
74
+ Authorization: `Bearer ${pat}`,
75
+ 'Content-Type': 'application/json',
76
+ 'X-Requested-With': 'ConradPM-Native',
77
+ },
78
+ body: JSON.stringify({ status: 'failed', failureReason: failureReason.slice(0, 1000) }),
79
+ });
80
+ return res.ok || res.status === 409;
81
+ }
82
+ catch {
83
+ return false;
84
+ }
85
+ }
86
+ export const MAX_REPORT_ATTEMPTS = 8;
87
+ export const TIMEOUT_EXIT_CODE = 124;
88
+ const reportAttemptsByTracker = new WeakMap();
89
+ function reportAttemptsFor(tracked) {
90
+ let m = reportAttemptsByTracker.get(tracked);
91
+ if (!m) {
92
+ m = new Map();
93
+ reportAttemptsByTracker.set(tracked, m);
94
+ }
95
+ return m;
96
+ }
46
97
  export async function checkCompletions(opts) {
47
98
  const { tracked, killed, baseUrl, pat } = opts;
48
99
  if (tracked.size === 0)
@@ -51,14 +102,84 @@ export async function checkCompletions(opts) {
51
102
  if (live === null)
52
103
  return [];
53
104
  const completed = detectCompletedActivations(tracked, live, killed);
105
+ const attempts = opts.cloud ? reportAttemptsFor(tracked) : null;
106
+ const noteFailedReport = (id) => {
107
+ if (!attempts)
108
+ return;
109
+ const n = (attempts.get(id) ?? 0) + 1;
110
+ if (n < MAX_REPORT_ATTEMPTS) {
111
+ attempts.set(id, n);
112
+ return;
113
+ }
114
+ attempts.delete(id);
115
+ tracked.delete(id);
116
+ opts.cloud?.forgetWorkspace?.(id);
117
+ opts.log?.(`⚠ ${id} · giving up after ${MAX_REPORT_ATTEMPTS} failed completion reports\n`);
118
+ };
54
119
  for (const [id, pane] of [...tracked.entries()]) {
55
120
  if (!live.has(pane) && killed.has(id)) {
56
121
  tracked.delete(id);
57
122
  killed.delete(id);
123
+ opts.cloud?.forgetWorkspace?.(id);
124
+ attempts?.delete(id);
58
125
  }
59
126
  }
60
127
  const reported = [];
128
+ const outcomes = new Map();
61
129
  for (const id of completed) {
130
+ if (opts.cloud) {
131
+ const push = opts.cloud.pushWorkspace(id);
132
+ const code = opts.cloud.readExitCode(id);
133
+ const branchNote = (push.pushed
134
+ ? `branch ${push.branch ?? 'unknown'} pushed`
135
+ : push.reason === 'no workspace'
136
+ ? 'no workspace'
137
+ : `push failed: ${push.reason ?? 'unknown'}`).slice(0, 200);
138
+ if (code === 0 && (push.pushed || push.kind === 'no-workspace')) {
139
+ const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
140
+ if (!ok) {
141
+ noteFailedReport(id);
142
+ continue;
143
+ }
144
+ attempts?.delete(id);
145
+ tracked.delete(id);
146
+ reported.push(id);
147
+ outcomes.set(id, 'completed');
148
+ const card = opts.renderComplete?.(id);
149
+ opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
150
+ continue;
151
+ }
152
+ const codeLabel = code === null
153
+ ? 'unknown (no exit file)'
154
+ : code === TIMEOUT_EXIT_CODE
155
+ ? `${TIMEOUT_EXIT_CODE} (wall clock timeout)`
156
+ : String(code);
157
+ const ok = await postFailure(baseUrl, pat, id, `agent exited ${codeLabel} · ${branchNote}`, opts.fetchImpl);
158
+ if (!ok) {
159
+ noteFailedReport(id);
160
+ continue;
161
+ }
162
+ attempts?.delete(id);
163
+ tracked.delete(id);
164
+ reported.push(id);
165
+ outcomes.set(id, 'failed');
166
+ opts.log?.(`✗ ${id} · run failed (exit ${codeLabel})\n`);
167
+ continue;
168
+ }
169
+ const localCode = opts.readExitCode ? opts.readExitCode(id) : null;
170
+ if (localCode !== null && localCode !== 0) {
171
+ const label = localCode === TIMEOUT_EXIT_CODE
172
+ ? `${TIMEOUT_EXIT_CODE} (wall clock timeout)`
173
+ : String(localCode);
174
+ const failedOk = await postFailure(baseUrl, pat, id, `agent exited ${label}`, opts.fetchImpl);
175
+ if (!failedOk)
176
+ continue;
177
+ tracked.delete(id);
178
+ reported.push(id);
179
+ outcomes.set(id, 'failed');
180
+ opts.log?.(`✗ ${id} · run failed (exit ${label})\n`);
181
+ continue;
182
+ }
62
183
  const ok = await postCompletion(baseUrl, pat, id, opts.fetchImpl);
63
184
  if (!ok) {
64
185
  continue;
@@ -68,5 +189,8 @@ export async function checkCompletions(opts) {
68
189
  const card = opts.renderComplete?.(id);
69
190
  opts.log?.(card && card.length > 0 ? card : `✓ ${id} · run completed (pane closed)\n`);
70
191
  }
71
- return reported;
192
+ const result = reported;
193
+ if (opts.cloud || opts.readExitCode)
194
+ result.outcomes = outcomes;
195
+ return result;
72
196
  }