@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.
- package/README.md +175 -69
- package/dist/cli.js +42 -0
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +22 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +89 -3
- package/dist/config.js.map +1 -1
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +64 -5
- package/dist/doctor.js.map +1 -1
- package/dist/failure-classifier.d.ts +33 -0
- package/dist/failure-classifier.d.ts.map +1 -0
- package/dist/failure-classifier.js +128 -0
- package/dist/failure-classifier.js.map +1 -0
- package/dist/login.d.ts +3 -0
- package/dist/login.d.ts.map +1 -1
- package/dist/login.js +5 -2
- package/dist/login.js.map +1 -1
- package/dist/runtime/conflict-resolver.d.ts +65 -0
- package/dist/runtime/conflict-resolver.d.ts.map +1 -0
- package/dist/runtime/conflict-resolver.js +477 -0
- package/dist/runtime/conflict-resolver.js.map +1 -0
- package/dist/runtime/quarantine.d.ts +16 -0
- package/dist/runtime/quarantine.d.ts.map +1 -0
- package/dist/runtime/quarantine.js +50 -0
- package/dist/runtime/quarantine.js.map +1 -0
- package/dist/runtime/reviewer.d.ts +4 -0
- package/dist/runtime/reviewer.d.ts.map +1 -1
- package/dist/runtime/reviewer.js +102 -30
- package/dist/runtime/reviewer.js.map +1 -1
- package/dist/runtime/rework.d.ts +48 -0
- package/dist/runtime/rework.d.ts.map +1 -0
- package/dist/runtime/rework.js +136 -0
- package/dist/runtime/rework.js.map +1 -0
- package/dist/runtime/worktree.d.ts +11 -0
- package/dist/runtime/worktree.d.ts.map +1 -1
- package/dist/runtime/worktree.js +11 -5
- package/dist/runtime/worktree.js.map +1 -1
- package/dist/task-runner.d.ts +21 -0
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +347 -19
- package/dist/task-runner.js.map +1 -1
- package/dist/token-provider.d.ts +50 -0
- package/dist/token-provider.d.ts.map +1 -0
- package/dist/token-provider.js +177 -0
- package/dist/token-provider.js.map +1 -0
- package/dist/types.d.ts +11 -0
- package/dist/types.d.ts.map +1 -1
- package/dist/watch.d.ts +30 -0
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +358 -45
- package/dist/watch.js.map +1 -1
- 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,
|
|
4
|
-
* access token on startup + every 30 min, and exits
|
|
5
|
-
* consecutive heartbeat failures
|
|
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)
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
200
|
-
|
|
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
|
|
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
|
-
|
|
329
|
+
const tokenProvider = getTokenProvider();
|
|
330
|
+
let session = await tokenProvider.load();
|
|
283
331
|
if (!session) {
|
|
284
|
-
process.stderr.write(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
420
|
-
state.
|
|
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
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
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
|
-
|
|
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
|
-
|
|
573
|
-
state.current = null;
|
|
574
|
-
}
|
|
886
|
+
void pump(state, factory);
|
|
887
|
+
});
|
|
575
888
|
}
|
|
576
889
|
}
|
|
577
890
|
finally {
|