@tokenfactory/acc-runner 0.8.1 → 0.12.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.
Files changed (53) hide show
  1. package/README.md +175 -69
  2. package/dist/cli.js +42 -0
  3. package/dist/cli.js.map +1 -1
  4. package/dist/config.d.ts +22 -1
  5. package/dist/config.d.ts.map +1 -1
  6. package/dist/config.js +89 -3
  7. package/dist/config.js.map +1 -1
  8. package/dist/doctor.d.ts.map +1 -1
  9. package/dist/doctor.js +64 -5
  10. package/dist/doctor.js.map +1 -1
  11. package/dist/failure-classifier.d.ts +33 -0
  12. package/dist/failure-classifier.d.ts.map +1 -0
  13. package/dist/failure-classifier.js +128 -0
  14. package/dist/failure-classifier.js.map +1 -0
  15. package/dist/login.d.ts +3 -0
  16. package/dist/login.d.ts.map +1 -1
  17. package/dist/login.js +5 -2
  18. package/dist/login.js.map +1 -1
  19. package/dist/runtime/conflict-resolver.d.ts +65 -0
  20. package/dist/runtime/conflict-resolver.d.ts.map +1 -0
  21. package/dist/runtime/conflict-resolver.js +477 -0
  22. package/dist/runtime/conflict-resolver.js.map +1 -0
  23. package/dist/runtime/quarantine.d.ts +16 -0
  24. package/dist/runtime/quarantine.d.ts.map +1 -0
  25. package/dist/runtime/quarantine.js +50 -0
  26. package/dist/runtime/quarantine.js.map +1 -0
  27. package/dist/runtime/reviewer.d.ts +4 -0
  28. package/dist/runtime/reviewer.d.ts.map +1 -1
  29. package/dist/runtime/reviewer.js +102 -30
  30. package/dist/runtime/reviewer.js.map +1 -1
  31. package/dist/runtime/rework.d.ts +48 -0
  32. package/dist/runtime/rework.d.ts.map +1 -0
  33. package/dist/runtime/rework.js +136 -0
  34. package/dist/runtime/rework.js.map +1 -0
  35. package/dist/runtime/worktree.d.ts +11 -0
  36. package/dist/runtime/worktree.d.ts.map +1 -1
  37. package/dist/runtime/worktree.js +11 -5
  38. package/dist/runtime/worktree.js.map +1 -1
  39. package/dist/task-runner.d.ts +21 -0
  40. package/dist/task-runner.d.ts.map +1 -1
  41. package/dist/task-runner.js +347 -19
  42. package/dist/task-runner.js.map +1 -1
  43. package/dist/token-provider.d.ts +50 -0
  44. package/dist/token-provider.d.ts.map +1 -0
  45. package/dist/token-provider.js +177 -0
  46. package/dist/token-provider.js.map +1 -0
  47. package/dist/types.d.ts +11 -0
  48. package/dist/types.d.ts.map +1 -1
  49. package/dist/watch.d.ts +30 -0
  50. package/dist/watch.d.ts.map +1 -1
  51. package/dist/watch.js +358 -45
  52. package/dist/watch.js.map +1 -1
  53. package/package.json +1 -1
package/dist/watch.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * `acc-runner watch` — long-running command. Subscribes to broadcast
3
- * tasks, runs them serially, heartbeats every 4s. v0.2.5 refreshes the
4
- * access token on startup + every 30 min, and exits loudly after 5
5
- * consecutive heartbeat failures instead of silent-zombie-ing.
3
+ * tasks, dispatches them concurrently (v0.10+), heartbeats every 4s.
4
+ * v0.2.5 refreshes the access token on startup + every 30 min, and exits
5
+ * loudly after 5 consecutive heartbeat failures.
6
6
  *
7
7
  * v0.3 adds a 30s polling fallback: if a Realtime broadcast is dropped
8
8
  * during a reconnect, the next poll pass picks the task up via the
@@ -11,17 +11,29 @@
11
11
  *
12
12
  * v0.6.2 (v0.12-REVIEW-LOCAL) listens for `review_assigned` on the same
13
13
  * `runner:<id>` channel and dispatches to runtime/reviewer.ts. Reviews
14
- * are independently tracked (separate `seenReviews` set) so a task
15
- * and a review for that task can both run in this process — though
16
- * not concurrently with each other: the existing single-flight pump()
17
- * serializes task work, and reviews queue behind whatever is running.
14
+ * are independently tracked (separate `seenReviews` set).
15
+ *
16
+ * v0.10 (T-49-2): concurrent task dispatch. pump() now launches up to
17
+ * `concurrencyLimit` (default 2) tasks simultaneously. Each task runs in
18
+ * its own isolated git worktree so concurrent tasks never share files.
19
+ * `createTaskAsAdmin` in api/_lib/supabase-admin.ts is the sanctioned
20
+ * Manager seed path that feeds this dispatch loop.
21
+ *
22
+ * v0.12.0 (T-52-7): ephemeral-runner support. Sessions load through the
23
+ * token-provider abstraction (keychain by default — byte-identical desktop
24
+ * behaviour; ACC_RUNNER_TOKEN_MODE=env for containers). Env-token runners
25
+ * register an acc-eph-<random> identity on boot, and ACC_RUNNER_IDLE_TTL_MIN
26
+ * arms a clean self-termination after n minutes without work.
18
27
  */
19
28
  import chalk from "chalk";
20
29
  import { loadConfig } from "./config.js";
21
- import { loadSession, saveSession } from "./keychain.js";
30
+ import { getTokenProvider } from "./token-provider.js";
31
+ import { machineIdentity, detectInstalledModels, RUNNER_CAPS } from "./login.js";
22
32
  import { createRunnerClient } from "./supabase.js";
23
33
  import { runTask } from "./task-runner.js";
24
34
  import { runReview, } from "./runtime/reviewer.js";
35
+ import { getQuarantine, setQuarantine, } from "./runtime/quarantine.js";
36
+ import { postRunnerStateMessage } from "./messaging.js";
25
37
  import { checkVersion, compareSemver } from "./version-check.js";
26
38
  import { PROTOCOL_VERSION } from "./types.js";
27
39
  import { PACKAGE_VERSION } from "./pkg-version.js";
@@ -33,6 +45,9 @@ export class RefreshReuseDetectedError extends Error {
33
45
  }
34
46
  const ONE_HOUR_MS = 60 * 60 * 1000;
35
47
  const REFRESH_CHECK_MS = 30 * 60 * 1000;
48
+ // v0.12.0 (T-52-7): idle-TTL check cadence ceiling. The actual cadence is
49
+ // min(IDLE_CHECK_MS, ttl) so a tiny test TTL still gets checked in time.
50
+ const IDLE_CHECK_MS = 30_000;
36
51
  const HEARTBEAT_REFRESH_AFTER = 3;
37
52
  const HEARTBEAT_EXIT_AFTER = 5;
38
53
  const POLL_INTERVAL_MS = 30_000;
@@ -107,6 +122,26 @@ export function nextHeartbeatDelayMs(consecutiveFailures, baseMs) {
107
122
  const idx = Math.min(consecutiveFailures - 1, HEARTBEAT_BACKOFF_MS.length - 1);
108
123
  return Math.max(baseMs, HEARTBEAT_BACKOFF_MS[idx]);
109
124
  }
125
+ /**
126
+ * v0.12.0 (T-52-7): resolve ACC_RUNNER_IDLE_TTL_MIN into ms. Returns null
127
+ * (TTL disabled) when unset; warns and returns null on junk so a typo'd
128
+ * env never strands a container in "never exits" mode silently.
129
+ */
130
+ export function resolveIdleTtlMs(env = process.env) {
131
+ const raw = env.ACC_RUNNER_IDLE_TTL_MIN?.trim();
132
+ if (!raw)
133
+ return null;
134
+ const minutes = Number(raw);
135
+ if (!Number.isFinite(minutes) || minutes <= 0) {
136
+ process.stderr.write(`[acc-runner] ACC_RUNNER_IDLE_TTL_MIN=${JSON.stringify(raw)} is not a positive number; idle TTL disabled\n`);
137
+ return null;
138
+ }
139
+ return minutes * 60_000;
140
+ }
141
+ /** v0.12.0 (T-52-7): record work-related activity for the idle TTL clock. */
142
+ function touchActivity(state) {
143
+ state.lastActivityMs = Date.now();
144
+ }
110
145
  function expiresSoon(session, leadMs = ONE_HOUR_MS) {
111
146
  const expires = Date.parse(session.access_expires_at);
112
147
  if (!Number.isFinite(expires))
@@ -159,12 +194,14 @@ function failOnReuse(err) {
159
194
  function enqueue(state, taskId, factory) {
160
195
  if (state.seen.has(taskId))
161
196
  return false;
162
- if (state.current && state.current.taskId === taskId) {
197
+ // v0.10 T-49-2: check the running Map (replaces v0.9 state.current check).
198
+ if (state.running.has(taskId)) {
163
199
  state.seen.add(taskId);
164
200
  return false;
165
201
  }
166
202
  state.seen.add(taskId);
167
203
  state.queue.push(taskId);
204
+ touchActivity(state);
168
205
  void pump(state, factory);
169
206
  return true;
170
207
  }
@@ -180,6 +217,7 @@ function enqueueReview(state, payload) {
180
217
  task_id: p.task_id,
181
218
  pr_number: p.pr_number,
182
219
  });
220
+ touchActivity(state);
183
221
  void pumpReviews(state);
184
222
  }
185
223
  function subscribeChannel(state, taskRunnerFactory) {
@@ -196,8 +234,10 @@ function subscribeChannel(state, taskRunnerFactory) {
196
234
  const taskId = payload?.task_id;
197
235
  if (!taskId)
198
236
  return;
199
- if (state.current && state.current.taskId === taskId) {
200
- state.current.cancel();
237
+ // v0.10 T-49-2: cancel from the running Map; fall back to queue removal.
238
+ const ctrl = state.running.get(taskId);
239
+ if (ctrl) {
240
+ ctrl.cancel();
201
241
  }
202
242
  else {
203
243
  state.queue = state.queue.filter((t) => t !== taskId);
@@ -248,13 +288,20 @@ async function pollOnce(state, factory) {
248
288
  // Single-flight refresh. Recreates the Supabase client (so future RPC +
249
289
  // channel auth use the new JWT) and resubscribes the broadcast channel.
250
290
  async function doRefresh(state, taskRunnerFactory) {
291
+ // v0.12.0 (T-52-7): an env-token runner without ACC_RUNNER_REFRESH_TOKEN
292
+ // cannot rotate. Refusing here (instead of POSTing an empty token) keeps
293
+ // the failure mode a clear stderr line rather than a server-side 400.
294
+ if (!state.tokenProvider.canRefresh()) {
295
+ process.stderr.write("[acc-runner] token refresh unavailable (no refresh token in env); runner will exit when the access token expires\n");
296
+ return false;
297
+ }
251
298
  if (state.refreshing)
252
299
  return state.refreshing;
253
300
  state.refreshing = (async () => {
254
301
  try {
255
302
  const updated = await refreshAccessToken(state.cfg, state.session);
256
303
  state.session = updated;
257
- await saveSession(updated);
304
+ await state.tokenProvider.save(updated);
258
305
  try {
259
306
  await state.supabase.removeChannel(state.channel);
260
307
  }
@@ -279,9 +326,12 @@ async function doRefresh(state, taskRunnerFactory) {
279
326
  }
280
327
  export async function watchCommand(options = {}) {
281
328
  const cfg = loadConfig();
282
- let session = await loadSession();
329
+ const tokenProvider = getTokenProvider();
330
+ let session = await tokenProvider.load();
283
331
  if (!session) {
284
- process.stderr.write("Not logged in. Run `acc-runner login`.\n");
332
+ process.stderr.write(tokenProvider.mode === "env"
333
+ ? "No session in env. Set ACC_RUNNER_ACCESS_TOKEN (token mode: env).\n"
334
+ : "Not logged in. Run `acc-runner login`.\n");
285
335
  process.exit(1);
286
336
  }
287
337
  const v = await checkVersion(cfg.publicUrl);
@@ -297,10 +347,13 @@ export async function watchCommand(options = {}) {
297
347
  }
298
348
  // Startup refresh: if the access token expires in less than an hour,
299
349
  // grab a fresh one before opening the realtime connection.
300
- if (expiresSoon(session)) {
350
+ // v0.12.0 (T-52-7): skipped when the provider has no refresh token —
351
+ // an env-token runner just runs out its 8h access token (the idle TTL
352
+ // normally exits it long before then).
353
+ if (tokenProvider.canRefresh() && expiresSoon(session)) {
301
354
  try {
302
355
  session = await refreshAccessToken(cfg, session);
303
- await saveSession(session);
356
+ await tokenProvider.save(session);
304
357
  }
305
358
  catch (err) {
306
359
  if (err instanceof RefreshReuseDetectedError)
@@ -309,19 +362,61 @@ export async function watchCommand(options = {}) {
309
362
  process.exit(1);
310
363
  }
311
364
  }
365
+ // v0.12.0 (T-52-7): env-token (ephemeral) runners had no `login` flow, so
366
+ // nothing registered an acc.runners row yet. Register on boot under the
367
+ // acc-eph-<random> identity. register_runner may resolve to an existing
368
+ // row (same bound user + machine, e.g. a restarted container with a
369
+ // pinned hostname) — adopt whatever id it returns so the realtime
370
+ // channel, heartbeats, and polling all agree.
371
+ if (tokenProvider.mode === "env") {
372
+ const boot = createRunnerClient(cfg, session.access_token);
373
+ const models = await detectInstalledModels();
374
+ const { data: resolvedId, error: regErr } = await boot.rpc("register_runner", {
375
+ p_id: session.runner_id,
376
+ p_name: session.runner_id,
377
+ p_owner: session.email ?? session.user_id,
378
+ p_machine: machineIdentity(),
379
+ p_models: models,
380
+ p_caps: RUNNER_CAPS,
381
+ p_version: PACKAGE_VERSION,
382
+ });
383
+ if (regErr) {
384
+ process.stderr.write(`[acc-runner] ephemeral register_runner failed: ${regErr.message}\n`);
385
+ process.exit(1);
386
+ }
387
+ if (typeof resolvedId === "string" && resolvedId && resolvedId !== session.runner_id) {
388
+ session = { ...session, runner_id: resolvedId };
389
+ }
390
+ await tokenProvider.save(session);
391
+ console.log(chalk.green(`✓ Registered ephemeral runner: ${session.runner_id}`));
392
+ }
312
393
  const taskRunnerFactory = options.taskRunnerFactory ?? runTask;
313
394
  const heartbeatMs = options.heartbeatMs ?? 4_000;
314
395
  const pollMs = options.pollMs ?? POLL_INTERVAL_MS;
315
396
  const reviewerFactory = options.reviewerFactory ?? ((assignment, deps) => runReview(assignment, deps));
397
+ // v0.48: check for an existing quarantine file before opening the
398
+ // realtime connection. If quarantined, emit a loud warning — the
399
+ // runner will not claim tasks until `acc-runner quarantine clear` runs.
400
+ const startupQuarantine = await getQuarantine().catch(() => null);
401
+ if (startupQuarantine) {
402
+ process.stderr.write(chalk.red.bold(`[acc-runner] QUARANTINED (${startupQuarantine.cause}): ${startupQuarantine.detail}\n`));
403
+ process.stderr.write(chalk.red(`[acc-runner] Runner will NOT claim tasks. Resolve the issue then run ` +
404
+ `\`acc-runner quarantine clear\`.\n`));
405
+ }
406
+ // v0.12.0 (T-52-7): idle TTL — option override wins (tests), then env.
407
+ const idleTtlMs = options.idleTtlMs !== undefined ? options.idleTtlMs : resolveIdleTtlMs();
316
408
  const state = {
317
409
  cfg,
318
410
  session,
411
+ tokenProvider,
319
412
  supabase: createRunnerClient(cfg, session.access_token),
320
413
  channel: undefined,
321
414
  heartbeatTimer: undefined,
322
415
  refreshTimer: undefined,
323
416
  pollTimer: undefined,
324
- current: null,
417
+ // v0.10 T-49-2: concurrent running Map replaces v0.9 single `current`.
418
+ running: new Map(),
419
+ concurrencyLimit: options.concurrencyLimit ?? 2,
325
420
  queue: [],
326
421
  seen: new Set(),
327
422
  pumping: false,
@@ -334,6 +429,12 @@ export async function watchCommand(options = {}) {
334
429
  seenReviews: new Set(),
335
430
  reviewPumping: false,
336
431
  runReview: reviewerFactory,
432
+ quarantined: startupQuarantine !== null,
433
+ authRetriedTasks: new Set(),
434
+ idleTtlMs,
435
+ lastActivityMs: Date.now(),
436
+ idleTimer: null,
437
+ idleExiting: false,
337
438
  };
338
439
  subscribeChannel(state, taskRunnerFactory);
339
440
  // v0.34-B: one-shot startup claim scan. The 5-minute pollSince watermark
@@ -376,8 +477,10 @@ export async function watchCommand(options = {}) {
376
477
  // attempts. setInterval would keep firing every heartbeatMs even
377
478
  // while the link is down.
378
479
  scheduleHeartbeat(state, taskRunnerFactory, heartbeatMs);
480
+ // v0.12.0 (T-52-7): no refresh timer when the provider can't rotate —
481
+ // doRefresh would just emit the same "unavailable" line every 30 min.
379
482
  state.refreshTimer = setInterval(() => {
380
- if (expiresSoon(state.session)) {
483
+ if (state.tokenProvider.canRefresh() && expiresSoon(state.session)) {
381
484
  void doRefresh(state, taskRunnerFactory);
382
485
  }
383
486
  }, REFRESH_CHECK_MS);
@@ -416,8 +519,12 @@ export async function watchCommand(options = {}) {
416
519
  clearTimeout(state.heartbeatTimer);
417
520
  clearInterval(state.refreshTimer);
418
521
  clearInterval(state.pollTimer);
419
- if (state.current)
420
- state.current.cancel();
522
+ if (state.idleTimer)
523
+ clearInterval(state.idleTimer);
524
+ // v0.10 T-49-2: cancel every in-flight task (replaces v0.9 single cancel).
525
+ for (const ctrl of state.running.values()) {
526
+ ctrl.cancel();
527
+ }
421
528
  try {
422
529
  await state.supabase.removeChannel(state.channel);
423
530
  }
@@ -444,6 +551,19 @@ export async function watchCommand(options = {}) {
444
551
  }
445
552
  catch { /* network may already be gone */ }
446
553
  };
554
+ // v0.12.0 (T-52-7): arm the idle-TTL timer. Desktop runners (no TTL env,
555
+ // no option) never reach this branch — zero behaviour change. Armed after
556
+ // `stop` exists so the exit sequence can reuse the one shutdown path.
557
+ if (state.idleTtlMs !== null && state.idleTtlMs > 0) {
558
+ const exitFn = options.exit ?? ((code) => process.exit(code));
559
+ const checkMs = options.idleCheckMs ?? Math.min(IDLE_CHECK_MS, state.idleTtlMs);
560
+ state.idleTimer = setInterval(() => {
561
+ void maybeIdleExit(state, stop, exitFn);
562
+ }, checkMs);
563
+ // Don't hold the event loop open for the idle check alone.
564
+ state.idleTimer.unref?.();
565
+ console.log(chalk.gray(`Idle TTL armed: exiting after ${Math.round(state.idleTtlMs / 60_000)} min without work.`));
566
+ }
447
567
  if (!options.taskRunnerFactory) {
448
568
  // v0.33-C: best-effort terminal status flush on runner shutdown. If
449
569
  // launchd (or another OS-driven kill) sends SIGTERM while a task is
@@ -455,16 +575,19 @@ export async function watchCommand(options = {}) {
455
575
  // safety net; this just shrinks the window when the kill is catchable.
456
576
  const onSignal = (signal) => {
457
577
  void (async () => {
458
- if (signal === "SIGTERM" && state.current) {
459
- try {
460
- await state.supabase.rpc("transition_task", {
461
- p_task_id: state.current.taskId,
462
- p_new_status: "failed",
463
- });
464
- }
465
- catch {
466
- // best-effort — the sweep covers us if this RPC can't get out
467
- }
578
+ // v0.10 T-49-2: on SIGTERM, transition ALL in-flight tasks to
579
+ // failed (replaces v0.9 single-task transition). SIGINT (Ctrl-C)
580
+ // still skips the flush — the 5-min sweep handles that path.
581
+ if (signal === "SIGTERM" && state.running.size > 0) {
582
+ await Promise.allSettled([...state.running.keys()].map(async (taskId) => {
583
+ try {
584
+ await state.supabase.rpc("transition_task", {
585
+ p_task_id: taskId,
586
+ p_new_status: "failed",
587
+ });
588
+ }
589
+ catch { /* best-effort — sweep covers us if RPC can't get out */ }
590
+ }));
468
591
  }
469
592
  await stop();
470
593
  process.exit(0);
@@ -473,7 +596,11 @@ export async function watchCommand(options = {}) {
473
596
  process.once("SIGINT", () => onSignal("SIGINT"));
474
597
  process.once("SIGTERM", () => onSignal("SIGTERM"));
475
598
  }
476
- return { stop, pollOnce: () => pollOnce(state, taskRunnerFactory) };
599
+ return {
600
+ stop,
601
+ pollOnce: () => pollOnce(state, taskRunnerFactory),
602
+ runningCount: () => state.running.size,
603
+ };
477
604
  }
478
605
  function scheduleHeartbeat(state, taskRunnerFactory, baseMs) {
479
606
  if (state.stopped)
@@ -513,6 +640,57 @@ function scheduleHeartbeat(state, taskRunnerFactory, baseMs) {
513
640
  })();
514
641
  }, delay);
515
642
  }
643
+ /**
644
+ * v0.12.0 (T-52-7): idle-TTL exit check. Fires from the idle timer; exits
645
+ * the process cleanly when the runner has had no queued or in-flight work
646
+ * (tasks OR reviews) for idleTtlMs. The exit sequence:
647
+ *
648
+ * 1. log_activity runner.idle_ttl_exit — the audit breadcrumb the
649
+ * autoscale sweep and operators read ("why did acc-eph-x vanish?").
650
+ * 2. final heartbeat_runner — so the runners panel shows a fresh
651
+ * timestamp right up to the clean exit (vs. a stale-heartbeat crash).
652
+ * 3. stop() — shutdown_intent + set_runner_status('offline'), the same
653
+ * deregistration path `acc-runner logout` and SIGTERM use.
654
+ * 4. exit(0).
655
+ *
656
+ * In-flight work always wins: any running task, queued task, or queued
657
+ * review resets the decision to "not idle" — the TTL never kills a runner
658
+ * mid-task.
659
+ */
660
+ async function maybeIdleExit(state, stop, exitFn) {
661
+ if (state.stopped || state.idleExiting || state.idleTtlMs === null)
662
+ return;
663
+ if (state.running.size > 0 ||
664
+ state.queue.length > 0 ||
665
+ state.reviewQueue.length > 0 ||
666
+ state.reviewPumping) {
667
+ return;
668
+ }
669
+ const idleMs = Date.now() - state.lastActivityMs;
670
+ if (idleMs < state.idleTtlMs)
671
+ return;
672
+ state.idleExiting = true;
673
+ const idleMinutes = Math.round(idleMs / 60_000);
674
+ console.log(chalk.gray(`[acc-runner] idle TTL reached (${idleMinutes} min without work); exiting cleanly.`));
675
+ try {
676
+ await state.supabase.rpc("log_activity", {
677
+ p_verb: "runner.idle_ttl_exit",
678
+ p_target_id: state.session.runner_id,
679
+ p_payload: { idle_minutes: idleMinutes, version: PACKAGE_VERSION },
680
+ p_target_type: "runner",
681
+ });
682
+ }
683
+ catch { /* best-effort */ }
684
+ try {
685
+ await state.supabase.rpc("heartbeat_runner", {
686
+ p_id: state.session.runner_id,
687
+ p_version: PACKAGE_VERSION,
688
+ });
689
+ }
690
+ catch { /* best-effort */ }
691
+ await stop();
692
+ exitFn(0);
693
+ }
516
694
  async function pumpReviews(state) {
517
695
  if (state.reviewPumping)
518
696
  return;
@@ -530,22 +708,103 @@ async function pumpReviews(state) {
530
708
  catch (err) {
531
709
  process.stderr.write(`[acc-runner] review ${next.review_id} crashed: ${err.message}\n`);
532
710
  }
711
+ finally {
712
+ touchActivity(state); // v0.12.0 (T-52-7): idle clock restarts at review end
713
+ }
533
714
  }
534
715
  }
535
716
  finally {
536
717
  state.reviewPumping = false;
537
718
  }
538
719
  }
720
+ /**
721
+ * v0.48 / v0.49 T-49-6: enter quarantine after a machine-level failure.
722
+ *
723
+ * Extracted from pump()'s completion handler so both the immediate
724
+ * quarantine path and the auth_expired "refresh failed → quarantine"
725
+ * path share one implementation. Idempotent: a no-op once the runner is
726
+ * already quarantined, so concurrent machine-level failures don't fire
727
+ * duplicate audit events. Sets the in-memory flag synchronously (pump()
728
+ * reads it without an async hop) and best-effort persists + announces.
729
+ */
730
+ function enterQuarantine(state, taskId, cause, detail) {
731
+ if (state.quarantined)
732
+ return;
733
+ state.quarantined = true;
734
+ const qState = {
735
+ cause,
736
+ classifiedAt: new Date().toISOString(),
737
+ taskId,
738
+ detail,
739
+ };
740
+ void setQuarantine(qState).catch((err) => {
741
+ process.stderr.write(`[acc-runner] setQuarantine failed: ${err.message}\n`);
742
+ });
743
+ process.stderr.write(chalk.red.bold(`[acc-runner] QUARANTINE: runner ${state.session.runner_id} quarantined ` +
744
+ `(${cause}) after task ${taskId} failed.\n`));
745
+ void postRunnerStateMessage(state.supabase, {
746
+ task_id: taskId,
747
+ sender_id: state.session.runner_id,
748
+ state: "blocked",
749
+ protocol: "error_context",
750
+ payload: {
751
+ capacity_alert: true,
752
+ quarantine_cause: cause,
753
+ runner_id: state.session.runner_id,
754
+ detail,
755
+ },
756
+ kind: "blocked",
757
+ }).catch((err) => {
758
+ process.stderr.write(`[acc-runner] capacity alert post failed: ${err.message}\n`);
759
+ });
760
+ void (async () => {
761
+ try {
762
+ await state.supabase.rpc("log_activity", {
763
+ p_verb: "runner.quarantine_enter",
764
+ p_target_id: state.session.runner_id,
765
+ p_payload: { cause, task_id: taskId, detail },
766
+ p_target_type: "runner",
767
+ });
768
+ }
769
+ catch { /* best-effort */ }
770
+ })();
771
+ }
772
+ /**
773
+ * v0.10 T-49-2: concurrent task pump.
774
+ *
775
+ * Launches up to state.concurrencyLimit tasks in parallel. Each task
776
+ * gets its own isolated git worktree (prepareTaskWorktree in task-runner)
777
+ * so there is zero shared-file risk between concurrent runs. Database-side
778
+ * `claim_task_with_locks` provides a second guard: it rejects a claim when
779
+ * the task's file scope overlaps another in-flight task.
780
+ *
781
+ * Design: rather than `await`ing each task inside a while loop (v0.9
782
+ * serial behaviour), tasks are launched fire-and-forget. Each task's
783
+ * `.then()` handler removes it from `state.running` and re-fires pump()
784
+ * so newly freed capacity immediately picks up the next queued item.
785
+ * The `pumping` mutex prevents two concurrent pump() invocations from
786
+ * both launching into the same concurrencyLimit slot.
787
+ */
539
788
  async function pump(state, factory) {
540
789
  if (state.pumping)
541
790
  return;
542
791
  state.pumping = true;
543
792
  try {
544
- while (!state.stopped && state.queue.length > 0) {
793
+ // v0.48: check quarantine before claiming any task.
794
+ if (state.quarantined) {
795
+ process.stderr.write(`[acc-runner] QUARANTINED: runner ${state.session.runner_id} is not claiming tasks.\n` +
796
+ `[acc-runner] Fix the underlying issue then run \`acc-runner quarantine clear\`.\n`);
797
+ return;
798
+ }
799
+ // Fill available concurrency slots from the queue.
800
+ while (!state.stopped &&
801
+ !state.quarantined &&
802
+ state.queue.length > 0 &&
803
+ state.running.size < state.concurrencyLimit) {
545
804
  const next = state.queue.shift();
546
805
  if (!next)
547
806
  continue;
548
- state.current = factory(next, {
807
+ const ctrl = factory(next, {
549
808
  supabase: state.supabase,
550
809
  cfg: state.cfg,
551
810
  session: {
@@ -554,24 +813,78 @@ async function pump(state, factory) {
554
813
  },
555
814
  publicUrl: state.cfg.publicUrl,
556
815
  });
557
- try {
558
- const outcome = await state.current.promise;
559
- // v0.6.0 REG-294: surface a one-liner when runTask resolves
560
- // with status:'failed' (not thrown). Before, an operator had
561
- // no signal that a multi-minute task ended in a 'failed' RPC
562
- // call — only thrown exceptions ever hit stderr.
816
+ state.running.set(next, ctrl);
817
+ // Detached completion handler. Runs AFTER pump() returns so the
818
+ // running Map slot is occupied by the time the while condition
819
+ // re-checks running.size on the next iteration.
820
+ void ctrl.promise
821
+ .then((outcome) => {
822
+ state.running.delete(next);
823
+ touchActivity(state); // v0.12.0 (T-52-7): idle clock restarts at task end
563
824
  if (outcome.status === "failed") {
564
825
  const reason = outcome.error?.trim() ||
565
826
  (outcome.exitCode != null ? `exit ${outcome.exitCode}` : "unknown");
566
827
  process.stderr.write(`[acc-runner] task ${next} phase=${outcome.phase ?? "unknown"} failed: ${reason}\n`);
828
+ // v0.48: machine-level failure → quarantine. Guard with
829
+ // !state.quarantined so concurrent tasks don't fire duplicate
830
+ // quarantine events when two machine-level failures land
831
+ // simultaneously (both already in-flight; only the first to
832
+ // resolve sets the flag and writes the audit trail).
833
+ if (outcome.quarantine_cause && !state.quarantined) {
834
+ const cause = outcome.quarantine_cause;
835
+ // v0.49 T-49-6: a transient auth_expired (the access token
836
+ // lapsed while the task ran) gets ONE JWT refresh before we
837
+ // quarantine. If the refresh recovers, the runner stays
838
+ // online and resumes claiming queued work; if it fails — or
839
+ // this task already burned its one retry — we quarantine.
840
+ // usage_limit / env_broken skip this path: a token refresh
841
+ // can't fix a spent quota or a broken host.
842
+ if (cause === "auth_expired" && !state.authRetriedTasks.has(next)) {
843
+ state.authRetriedTasks.add(next);
844
+ process.stderr.write(chalk.yellow(`[acc-runner] auth_expired after task ${next}; attempting one ` +
845
+ `token refresh before quarantine.\n`));
846
+ void doRefresh(state, factory).then((ok) => {
847
+ if (ok) {
848
+ process.stderr.write(chalk.green(`[acc-runner] token refresh recovered auth after task ${next}; ` +
849
+ `staying online.\n`));
850
+ void (async () => {
851
+ try {
852
+ await state.supabase.rpc("log_activity", {
853
+ p_verb: "runner.auth_retry_recovered",
854
+ p_target_id: state.session.runner_id,
855
+ p_payload: { task_id: next, cause },
856
+ p_target_type: "runner",
857
+ });
858
+ }
859
+ catch { /* best-effort */ }
860
+ })();
861
+ // Resume claiming: the freed slot can pick up queued work
862
+ // now that the JWT is fresh.
863
+ void pump(state, factory);
864
+ }
865
+ else {
866
+ enterQuarantine(state, next, cause, reason);
867
+ }
868
+ });
869
+ return;
870
+ }
871
+ enterQuarantine(state, next, cause, reason);
872
+ // Quarantined — do not re-fire pump for new tasks.
873
+ // Other in-flight tasks (still in state.running) complete
874
+ // normally; we just stop accepting new work.
875
+ return;
876
+ }
567
877
  }
568
- }
569
- catch (err) {
878
+ // Task done (ok, failed without quarantine, or cancelled).
879
+ // Re-fire pump so the next queued task can claim the freed slot.
880
+ void pump(state, factory);
881
+ })
882
+ .catch((err) => {
883
+ state.running.delete(next);
884
+ touchActivity(state); // v0.12.0 (T-52-7): idle clock restarts at task end
570
885
  process.stderr.write(`[acc-runner] task ${next} crashed: ${err.message}\n`);
571
- }
572
- finally {
573
- state.current = null;
574
- }
886
+ void pump(state, factory);
887
+ });
575
888
  }
576
889
  }
577
890
  finally {