@tokenfactory/acc-runner 0.12.0 → 0.14.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 +25 -0
- package/dist/cli.js +5 -0
- package/dist/cli.js.map +1 -1
- package/dist/doctor.d.ts +19 -0
- package/dist/doctor.d.ts.map +1 -1
- package/dist/doctor.js +126 -3
- package/dist/doctor.js.map +1 -1
- package/dist/failure-classifier.d.ts +13 -0
- package/dist/failure-classifier.d.ts.map +1 -1
- package/dist/failure-classifier.js +4 -0
- package/dist/failure-classifier.js.map +1 -1
- package/dist/git.d.ts +15 -0
- package/dist/git.d.ts.map +1 -1
- package/dist/git.js +19 -0
- package/dist/git.js.map +1 -1
- package/dist/github-client.d.ts +133 -0
- package/dist/github-client.d.ts.map +1 -0
- package/dist/github-client.js +234 -0
- package/dist/github-client.js.map +1 -0
- package/dist/runtime/cache-dir.d.ts +2 -0
- package/dist/runtime/cache-dir.d.ts.map +1 -0
- package/dist/runtime/cache-dir.js +15 -0
- package/dist/runtime/cache-dir.js.map +1 -0
- package/dist/runtime/quarantine.d.ts +10 -0
- package/dist/runtime/quarantine.d.ts.map +1 -1
- package/dist/runtime/quarantine.js.map +1 -1
- package/dist/runtime/singleton.d.ts +47 -0
- package/dist/runtime/singleton.d.ts.map +1 -0
- package/dist/runtime/singleton.js +200 -0
- package/dist/runtime/singleton.js.map +1 -0
- package/dist/runtime/version-drift.d.ts +31 -0
- package/dist/runtime/version-drift.d.ts.map +1 -0
- package/dist/runtime/version-drift.js +114 -0
- package/dist/runtime/version-drift.js.map +1 -0
- package/dist/task-runner.d.ts +10 -1
- package/dist/task-runner.d.ts.map +1 -1
- package/dist/task-runner.js +153 -49
- package/dist/task-runner.js.map +1 -1
- package/dist/watch.d.ts +8 -0
- package/dist/watch.d.ts.map +1 -1
- package/dist/watch.js +60 -4
- package/dist/watch.js.map +1 -1
- package/package.json +1 -1
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.53 T-53-4 — runner-side, budget-aware GitHub REST client.
|
|
3
|
+
*
|
|
4
|
+
* A port of the api/ side's `api/_lib/github-client.ts` (T-52-4) into the
|
|
5
|
+
* runner package. The runner CANNOT import api/ code (separate build, no
|
|
6
|
+
* shared module graph), so the pattern is reproduced here. The three
|
|
7
|
+
* behaviours match the server client 1:1:
|
|
8
|
+
*
|
|
9
|
+
* 1. Quota tracking — x-ratelimit-remaining / x-ratelimit-reset per
|
|
10
|
+
* resource (core, search, graphql, …) from every response, surfaced
|
|
11
|
+
* via getQuotaSnapshot() for `acc-runner doctor`.
|
|
12
|
+
*
|
|
13
|
+
* 2. Secondary-limit backoff — a 429, or a 403 carrying retry-after /
|
|
14
|
+
* x-ratelimit-remaining: 0, is a rate-limit response, not an answer.
|
|
15
|
+
* The client sleeps once (retry-after if given, else an exponential
|
|
16
|
+
* base, both jittered, capped at MAX_BACKOFF_MS) and retries once. A
|
|
17
|
+
* second rate-limit response — or a retry-after beyond the cap —
|
|
18
|
+
* surfaces as a typed RateLimitedError.
|
|
19
|
+
*
|
|
20
|
+
* 3. Per-task-execution soft budget — runTask arms a budget at the top
|
|
21
|
+
* of a task (startTaskGithubBudget) and disarms it in its finally
|
|
22
|
+
* (endTaskGithubBudget). Once the budget is spent, every further
|
|
23
|
+
* githubFetch throws GithubBudgetExceededError WITHOUT hitting the
|
|
24
|
+
* network. The caller treats this as a CLEAN, RETRYABLE infra failure
|
|
25
|
+
* — NOT a code failure: it never reaches classifyClaudeExit, so the
|
|
26
|
+
* runner is never quarantined for exhausting its GitHub budget.
|
|
27
|
+
*
|
|
28
|
+
* ── Coverage note (READ THIS) ──────────────────────────────────────────
|
|
29
|
+
* As of v0.53 the runner makes ALL of its GitHub calls through the `gh`
|
|
30
|
+
* CLI (see gh.ts, runtime/conflict-resolver.ts, runtime/rework.ts,
|
|
31
|
+
* runtime/reviewer.ts). Those `gh`/`gh api` invocations are SEPARATE
|
|
32
|
+
* processes with their own rate-limit handling and are out of scope for
|
|
33
|
+
* this client — they remain UNCOVERED by the budget/backoff here. This
|
|
34
|
+
* module is the sanctioned wrapper for any future *direct* REST call the
|
|
35
|
+
* runner adds (`fetch` against api.github.com); routing such calls through
|
|
36
|
+
* githubFetch is what keeps them budget- and rate-limit-aware.
|
|
37
|
+
*
|
|
38
|
+
* Reference: api/_lib/github-client.ts, planning/ACC_WORKFLOW_GAP_ANALYSIS.md §G13.
|
|
39
|
+
*/
|
|
40
|
+
/** Soft per-task-execution call budget. */
|
|
41
|
+
export const DEFAULT_TASK_GITHUB_BUDGET = 50;
|
|
42
|
+
/**
|
|
43
|
+
* Longest single backoff sleep. A secondary-limit retry-after can be 60s+;
|
|
44
|
+
* sleeping that long inside a task run wastes wall-clock the operator is
|
|
45
|
+
* paying for, so anything above the cap skips the retry and throws
|
|
46
|
+
* RateLimitedError instead.
|
|
47
|
+
*/
|
|
48
|
+
export const MAX_BACKOFF_MS = 15_000;
|
|
49
|
+
/** Base for the exponential backoff when GitHub sends no retry-after. */
|
|
50
|
+
export const BASE_BACKOFF_MS = 2_000;
|
|
51
|
+
/**
|
|
52
|
+
* Typed rate-limit failure: GitHub said "back off" twice in a row (or
|
|
53
|
+
* asked for a longer wait than we can afford). A rate-limit storm is then
|
|
54
|
+
* diagnosable instead of looking like random github_http_403 noise. This
|
|
55
|
+
* is a RETRYABLE INFRA failure — see isRetryableGithubError.
|
|
56
|
+
*/
|
|
57
|
+
export class RateLimitedError extends Error {
|
|
58
|
+
status;
|
|
59
|
+
resource;
|
|
60
|
+
/** ISO timestamp the quota window resets at, when GitHub said. */
|
|
61
|
+
reset_at;
|
|
62
|
+
retry_after_s;
|
|
63
|
+
constructor(opts) {
|
|
64
|
+
super(`github rate limited (${opts.status})` +
|
|
65
|
+
(opts.resource ? ` on ${opts.resource}` : "") +
|
|
66
|
+
(opts.reset_at ? `, resets ${opts.reset_at}` : ""));
|
|
67
|
+
this.name = "RateLimitedError";
|
|
68
|
+
this.status = opts.status;
|
|
69
|
+
this.resource = opts.resource;
|
|
70
|
+
this.reset_at = opts.reset_at;
|
|
71
|
+
this.retry_after_s = opts.retry_after_s;
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* Soft-budget exhaustion. NOT a code failure: the task simply did as much
|
|
76
|
+
* direct GitHub work as one task execution is allowed. The caller should
|
|
77
|
+
* surface it as a clean, RETRYABLE infra failure and NEVER feed it to the
|
|
78
|
+
* claude-exit classifier (which would quarantine the runner).
|
|
79
|
+
*/
|
|
80
|
+
export class GithubBudgetExceededError extends Error {
|
|
81
|
+
used;
|
|
82
|
+
budget;
|
|
83
|
+
constructor(used, budget) {
|
|
84
|
+
super(`github task budget exhausted (${used}/${budget} calls)`);
|
|
85
|
+
this.name = "GithubBudgetExceededError";
|
|
86
|
+
this.used = used;
|
|
87
|
+
this.budget = budget;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
/**
|
|
91
|
+
* v0.53 T-53-4: both client failure modes are retryable infra conditions,
|
|
92
|
+
* not task/code failures. The contract the runner relies on: an error for
|
|
93
|
+
* which this returns true must surface as `phase: "github"` / retryable and
|
|
94
|
+
* MUST NOT set `quarantine_cause` — i.e. it is classifier-neutral and never
|
|
95
|
+
* benches the runner.
|
|
96
|
+
*/
|
|
97
|
+
export function isRetryableGithubError(err) {
|
|
98
|
+
return err instanceof RateLimitedError || err instanceof GithubBudgetExceededError;
|
|
99
|
+
}
|
|
100
|
+
// ── Module state (per process) ───────────────────────────────────────────────
|
|
101
|
+
const quotaByResource = new Map();
|
|
102
|
+
let usedThisTask = 0;
|
|
103
|
+
let taskBudget = null;
|
|
104
|
+
/**
|
|
105
|
+
* Begin a budgeted task execution: resets the per-task counter and arms the
|
|
106
|
+
* soft budget. runTask calls this at the top of a task; the explicit reset
|
|
107
|
+
* also re-baselines a long-lived watch process between tasks.
|
|
108
|
+
*/
|
|
109
|
+
export function startTaskGithubBudget(budget = DEFAULT_TASK_GITHUB_BUDGET) {
|
|
110
|
+
usedThisTask = 0;
|
|
111
|
+
taskBudget = budget;
|
|
112
|
+
}
|
|
113
|
+
/**
|
|
114
|
+
* End a budgeted task execution: disarms the soft budget so any direct REST
|
|
115
|
+
* call made OUTSIDE a task run (e.g. doctor) is unbudgeted. Idempotent.
|
|
116
|
+
*/
|
|
117
|
+
export function endTaskGithubBudget() {
|
|
118
|
+
taskBudget = null;
|
|
119
|
+
}
|
|
120
|
+
export function getQuotaSnapshot() {
|
|
121
|
+
let min = null;
|
|
122
|
+
const resources = {};
|
|
123
|
+
for (const [name, q] of quotaByResource) {
|
|
124
|
+
resources[name] = { ...q };
|
|
125
|
+
if (q.remaining !== null &&
|
|
126
|
+
(min === null || min.remaining === null || q.remaining < min.remaining)) {
|
|
127
|
+
min = q;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
remaining: min?.remaining ?? null,
|
|
132
|
+
reset_at: min?.reset_at ?? null,
|
|
133
|
+
used_this_task: usedThisTask,
|
|
134
|
+
resources,
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
/** Test seam: wipe quota/budget state between unit tests. */
|
|
138
|
+
export function resetGithubClientStateForTests() {
|
|
139
|
+
quotaByResource.clear();
|
|
140
|
+
usedThisTask = 0;
|
|
141
|
+
taskBudget = null;
|
|
142
|
+
}
|
|
143
|
+
function recordRateLimitHeaders(res) {
|
|
144
|
+
const remainingRaw = res.headers.get("x-ratelimit-remaining");
|
|
145
|
+
const resetRaw = res.headers.get("x-ratelimit-reset");
|
|
146
|
+
if (remainingRaw === null && resetRaw === null)
|
|
147
|
+
return;
|
|
148
|
+
const resource = res.headers.get("x-ratelimit-resource") ?? "core";
|
|
149
|
+
const remaining = remainingRaw !== null && remainingRaw !== "" ? Number(remainingRaw) : NaN;
|
|
150
|
+
const resetEpochS = resetRaw !== null && resetRaw !== "" ? Number(resetRaw) : NaN;
|
|
151
|
+
quotaByResource.set(resource, {
|
|
152
|
+
remaining: Number.isFinite(remaining) ? remaining : null,
|
|
153
|
+
reset_at: Number.isFinite(resetEpochS)
|
|
154
|
+
? new Date(resetEpochS * 1000).toISOString()
|
|
155
|
+
: null,
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
/**
|
|
159
|
+
* A 429 is always "back off". A 403 is only a rate limit when it carries
|
|
160
|
+
* retry-after (secondary limit) or x-ratelimit-remaining: 0 (primary limit
|
|
161
|
+
* exhausted) — a plain 403 is a permissions error and passes through.
|
|
162
|
+
*/
|
|
163
|
+
function isRateLimitResponse(res) {
|
|
164
|
+
if (res.status === 429)
|
|
165
|
+
return true;
|
|
166
|
+
if (res.status !== 403)
|
|
167
|
+
return false;
|
|
168
|
+
if (res.headers.get("retry-after") !== null)
|
|
169
|
+
return true;
|
|
170
|
+
return res.headers.get("x-ratelimit-remaining") === "0";
|
|
171
|
+
}
|
|
172
|
+
function toRateLimitedError(res) {
|
|
173
|
+
const retryAfterRaw = res.headers.get("retry-after");
|
|
174
|
+
const retryAfter = retryAfterRaw !== null ? Number(retryAfterRaw) : NaN;
|
|
175
|
+
const resetRaw = res.headers.get("x-ratelimit-reset");
|
|
176
|
+
const resetEpochS = resetRaw !== null ? Number(resetRaw) : NaN;
|
|
177
|
+
return new RateLimitedError({
|
|
178
|
+
status: res.status,
|
|
179
|
+
resource: res.headers.get("x-ratelimit-resource"),
|
|
180
|
+
reset_at: Number.isFinite(resetEpochS)
|
|
181
|
+
? new Date(resetEpochS * 1000).toISOString()
|
|
182
|
+
: null,
|
|
183
|
+
retry_after_s: Number.isFinite(retryAfter) ? retryAfter : null,
|
|
184
|
+
});
|
|
185
|
+
}
|
|
186
|
+
function defaultSleep(ms) {
|
|
187
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
188
|
+
}
|
|
189
|
+
/**
|
|
190
|
+
* Authenticated GitHub REST fetch. The caller supplies whatever token is
|
|
191
|
+
* appropriate (the runner uses the operator's own gh/git credentials for
|
|
192
|
+
* CLI calls; a direct REST consumer would pass a token it already holds).
|
|
193
|
+
* An empty token sends an anonymous request rather than `Bearer <empty>`.
|
|
194
|
+
*/
|
|
195
|
+
export async function githubFetch(url, token, init = {}, deps = {}) {
|
|
196
|
+
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
197
|
+
const sleep = deps.sleep ?? defaultSleep;
|
|
198
|
+
const random = deps.random ?? Math.random;
|
|
199
|
+
const headers = new Headers(init.headers);
|
|
200
|
+
if (token)
|
|
201
|
+
headers.set("Authorization", `Bearer ${token}`);
|
|
202
|
+
if (!headers.has("Accept")) {
|
|
203
|
+
headers.set("Accept", "application/vnd.github+json");
|
|
204
|
+
}
|
|
205
|
+
headers.set("X-GitHub-Api-Version", "2022-11-28");
|
|
206
|
+
headers.set("User-Agent", "acc-runner");
|
|
207
|
+
const doFetch = async () => {
|
|
208
|
+
if (taskBudget !== null && usedThisTask >= taskBudget) {
|
|
209
|
+
throw new GithubBudgetExceededError(usedThisTask, taskBudget);
|
|
210
|
+
}
|
|
211
|
+
usedThisTask++;
|
|
212
|
+
const res = await fetchImpl(url, { ...init, headers });
|
|
213
|
+
recordRateLimitHeaders(res);
|
|
214
|
+
return res;
|
|
215
|
+
};
|
|
216
|
+
const first = await doFetch();
|
|
217
|
+
if (!isRateLimitResponse(first))
|
|
218
|
+
return first;
|
|
219
|
+
// Rate limited: sleep jittered-exponential once, retry once.
|
|
220
|
+
const retryAfterRaw = first.headers.get("retry-after");
|
|
221
|
+
const retryAfterS = retryAfterRaw !== null ? Number(retryAfterRaw) : NaN;
|
|
222
|
+
const baseMs = Number.isFinite(retryAfterS) ? retryAfterS * 1000 : BASE_BACKOFF_MS;
|
|
223
|
+
if (baseMs > MAX_BACKOFF_MS) {
|
|
224
|
+
// GitHub asked for a longer pause than we want to burn mid-task —
|
|
225
|
+
// surface the typed error now instead of sleeping into the run.
|
|
226
|
+
throw toRateLimitedError(first);
|
|
227
|
+
}
|
|
228
|
+
await sleep(Math.round(baseMs * (1 + random() * 0.5)));
|
|
229
|
+
const second = await doFetch();
|
|
230
|
+
if (!isRateLimitResponse(second))
|
|
231
|
+
return second;
|
|
232
|
+
throw toRateLimitedError(second);
|
|
233
|
+
}
|
|
234
|
+
//# sourceMappingURL=github-client.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"github-client.js","sourceRoot":"","sources":["../src/github-client.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache-dir.d.ts","sourceRoot":"","sources":["../../src/runtime/cache-dir.ts"],"names":[],"mappings":"AAWA,wBAAgB,QAAQ,IAAI,MAAM,CAKjC"}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.14.0 (T-54-2): single source of truth for the runner cache root.
|
|
3
|
+
*
|
|
4
|
+
* `quarantine.ts` already honoured ACC_RUNNER_CACHE_DIR inline; the
|
|
5
|
+
* singleton-lock and last-task-env modules added in this release reuse the
|
|
6
|
+
* same resolution so tests can point all cache state at a tmpdir with one
|
|
7
|
+
* env var. Defaults to ~/.cache/acc-runner (unchanged from prior releases).
|
|
8
|
+
*/
|
|
9
|
+
import os from "node:os";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
export function cacheDir() {
|
|
12
|
+
return (process.env.ACC_RUNNER_CACHE_DIR?.trim() ||
|
|
13
|
+
path.join(os.homedir(), ".cache", "acc-runner"));
|
|
14
|
+
}
|
|
15
|
+
//# sourceMappingURL=cache-dir.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"cache-dir.js","sourceRoot":"","sources":["../../src/runtime/cache-dir.ts"],"names":[],"mappings":"AAAA;;;;;;;GAOG;AACH,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAE7B,MAAM,UAAU,QAAQ;IACtB,OAAO,CACL,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,IAAI,EAAE;QACxC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,YAAY,CAAC,CAChD,CAAC;AACJ,CAAC"}
|
|
@@ -4,6 +4,16 @@ export interface QuarantineState {
|
|
|
4
4
|
classifiedAt: string;
|
|
5
5
|
taskId: string;
|
|
6
6
|
detail: string;
|
|
7
|
+
/**
|
|
8
|
+
* v0.53 T-53-4: how many consecutive instant-empty (heuristic) claude
|
|
9
|
+
* exits triggered the quarantine. Cache-schema additive — older readers
|
|
10
|
+
* ignore it; the keychain entry schema and the runner protocol message
|
|
11
|
+
* shapes are untouched. Definitive machine failures (dyld, exit 127,
|
|
12
|
+
* usage/auth patterns) record 1; a heuristic env_broken records 2 (the
|
|
13
|
+
* task-runner only quarantines after the retry also exits instantly).
|
|
14
|
+
* Optional so a state written by a pre-0.53 runner still parses.
|
|
15
|
+
*/
|
|
16
|
+
consecutive?: number;
|
|
7
17
|
}
|
|
8
18
|
/** Path to the quarantine sentinel file. Configurable via ACC_RUNNER_CACHE_DIR for tests. */
|
|
9
19
|
export declare function quarantineFile(): string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"quarantine.d.ts","sourceRoot":"","sources":["../../src/runtime/quarantine.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,eAAe,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;
|
|
1
|
+
{"version":3,"file":"quarantine.d.ts","sourceRoot":"","sources":["../../src/runtime/quarantine.ts"],"names":[],"mappings":"AAgBA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAEhE,MAAM,WAAW,eAAe;IAC9B,KAAK,EAAE,eAAe,CAAC;IACvB,YAAY,EAAE,MAAM,CAAC;IACrB,MAAM,EAAE,MAAM,CAAC;IACf,MAAM,EAAE,MAAM,CAAC;IACf;;;;;;;;OAQG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED,6FAA6F;AAC7F,wBAAgB,cAAc,IAAI,MAAM,CAMvC;AAED,0EAA0E;AAC1E,wBAAsB,aAAa,IAAI,OAAO,CAAC,eAAe,GAAG,IAAI,CAAC,CAQrE;AAED,oEAAoE;AACpE,wBAAsB,aAAa,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,IAAI,CAAC,CAIzE;AAED,yEAAyE;AACzE,wBAAsB,eAAe,IAAI,OAAO,CAAC,IAAI,CAAC,CAMrD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"quarantine.js","sourceRoot":"","sources":["../../src/runtime/quarantine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAClC,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;
|
|
1
|
+
{"version":3,"file":"quarantine.js","sourceRoot":"","sources":["../../src/runtime/quarantine.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;GAYG;AACH,OAAO,EAAE,MAAM,kBAAkB,CAAC;AAClC,OAAO,EAAE,MAAM,SAAS,CAAC;AACzB,OAAO,IAAI,MAAM,WAAW,CAAC;AAoB7B,6FAA6F;AAC7F,MAAM,UAAU,cAAc;IAC5B,OAAO,IAAI,CAAC,IAAI,CACd,OAAO,CAAC,GAAG,CAAC,oBAAoB,EAAE,IAAI,EAAE;QACtC,IAAI,CAAC,IAAI,CAAC,EAAE,CAAC,OAAO,EAAE,EAAE,QAAQ,EAAE,YAAY,CAAC,EACjD,iBAAiB,CAClB,CAAC;AACJ,CAAC;AAED,0EAA0E;AAC1E,MAAM,CAAC,KAAK,UAAU,aAAa;IACjC,IAAI,CAAC;QACH,MAAM,GAAG,GAAG,MAAM,EAAE,CAAC,QAAQ,CAAC,cAAc,EAAE,EAAE,MAAM,CAAC,CAAC;QACxD,OAAO,IAAI,CAAC,KAAK,CAAC,GAAG,CAAoB,CAAC;IAC5C,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,OAAO,IAAI,CAAC;QAClE,MAAM,GAAG,CAAC;IACZ,CAAC;AACH,CAAC;AAED,oEAAoE;AACpE,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,KAAsB;IACxD,MAAM,IAAI,GAAG,cAAc,EAAE,CAAC;IAC9B,MAAM,EAAE,CAAC,KAAK,CAAC,IAAI,CAAC,OAAO,CAAC,IAAI,CAAC,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;IACxD,MAAM,EAAE,CAAC,SAAS,CAAC,IAAI,EAAE,IAAI,CAAC,SAAS,CAAC,KAAK,EAAE,IAAI,EAAE,CAAC,CAAC,EAAE,MAAM,CAAC,CAAC;AACnE,CAAC;AAED,yEAAyE;AACzE,MAAM,CAAC,KAAK,UAAU,eAAe;IACnC,IAAI,CAAC;QACH,MAAM,EAAE,CAAC,MAAM,CAAC,cAAc,EAAE,CAAC,CAAC;IACpC,CAAC;IAAC,OAAO,GAAG,EAAE,CAAC;QACb,IAAK,GAA6B,CAAC,IAAI,KAAK,QAAQ;YAAE,MAAM,GAAG,CAAC;IAClE,CAAC;AACH,CAAC"}
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
export interface SingletonLockInfo {
|
|
2
|
+
pid: number;
|
|
3
|
+
/** Canonical (realpath-resolved where possible) repo path this runner serves. */
|
|
4
|
+
repoPath: string;
|
|
5
|
+
acquiredAt: string;
|
|
6
|
+
version: string;
|
|
7
|
+
}
|
|
8
|
+
export interface SingletonLock {
|
|
9
|
+
lockFile: string;
|
|
10
|
+
repoPath: string;
|
|
11
|
+
/** Remove the lock iff we still own it. Safe to call more than once. */
|
|
12
|
+
release(): Promise<void>;
|
|
13
|
+
}
|
|
14
|
+
export declare class SingletonLockHeldError extends Error {
|
|
15
|
+
readonly holder: SingletonLockInfo;
|
|
16
|
+
readonly lockFile: string;
|
|
17
|
+
constructor(holder: SingletonLockInfo, lockFile: string);
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Resolve a repo path to a stable canonical form so two spellings of the
|
|
21
|
+
* same directory (trailing slash, symlink, `./`) map to one lock file.
|
|
22
|
+
* realpath fails if the path does not exist yet — fall back to resolve().
|
|
23
|
+
*/
|
|
24
|
+
export declare function canonicalRepoPath(repoPath: string): string;
|
|
25
|
+
/** `<cache>/runner.<hash>.lock`, hash derived from the canonical repo path. */
|
|
26
|
+
export declare function singletonLockPath(repoPath: string): string;
|
|
27
|
+
/**
|
|
28
|
+
* Acquire the single-instance lock for `repoPath`. Throws
|
|
29
|
+
* SingletonLockHeldError when a *live* process already holds the lock for
|
|
30
|
+
* the same canonical repo path. A stale lock (dead pid, our own pid, or a
|
|
31
|
+
* corrupt file) is reclaimed with a stderr breadcrumb and then re-acquired.
|
|
32
|
+
*/
|
|
33
|
+
export declare function acquireSingletonLock(repoPath: string, pid?: number): Promise<SingletonLock>;
|
|
34
|
+
/**
|
|
35
|
+
* Inspect the lock file for `repoPath` and return its holder iff a *live*
|
|
36
|
+
* process other than `selfPid` owns it. Used by `acc-runner doctor` to
|
|
37
|
+
* report a duplicate runner without trying to acquire the lock.
|
|
38
|
+
*/
|
|
39
|
+
export declare function findLiveLockHolder(repoPath: string, selfPid?: number): Promise<SingletonLockInfo | null>;
|
|
40
|
+
/**
|
|
41
|
+
* pgrep-style scan for other `acc-runner watch` processes. Best-effort and
|
|
42
|
+
* offline of the lock file — used by doctor to catch a runner that predates
|
|
43
|
+
* the singleton lock (no lock file) or one whose lock was hand-deleted.
|
|
44
|
+
* Returns foreign pids; never throws (returns [] on any failure).
|
|
45
|
+
*/
|
|
46
|
+
export declare function listForeignRunnerPids(selfPid?: number): Promise<number[]>;
|
|
47
|
+
//# sourceMappingURL=singleton.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"singleton.d.ts","sourceRoot":"","sources":["../../src/runtime/singleton.ts"],"names":[],"mappings":"AA4BA,MAAM,WAAW,iBAAiB;IAChC,GAAG,EAAE,MAAM,CAAC;IACZ,iFAAiF;IACjF,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,EAAE,MAAM,CAAC;IACjB,QAAQ,EAAE,MAAM,CAAC;IACjB,wEAAwE;IACxE,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,CAAC;CAC1B;AAED,qBAAa,sBAAuB,SAAQ,KAAK;aAE7B,MAAM,EAAE,iBAAiB;aACzB,QAAQ,EAAE,MAAM;gBADhB,MAAM,EAAE,iBAAiB,EACzB,QAAQ,EAAE,MAAM;CAOnC;AAED;;;;GAIG;AACH,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAO1D;AAED,+EAA+E;AAC/E,wBAAgB,iBAAiB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAO1D;AA0CD;;;;;GAKG;AACH,wBAAsB,oBAAoB,CACxC,QAAQ,EAAE,MAAM,EAChB,GAAG,GAAE,MAAoB,GACxB,OAAO,CAAC,aAAa,CAAC,CA8CxB;AAED;;;;GAIG;AACH,wBAAsB,kBAAkB,CACtC,QAAQ,EAAE,MAAM,EAChB,OAAO,GAAE,MAAoB,GAC5B,OAAO,CAAC,iBAAiB,GAAG,IAAI,CAAC,CAMnC;AAED;;;;;GAKG;AACH,wBAAsB,qBAAqB,CACzC,OAAO,GAAE,MAAoB,GAC5B,OAAO,CAAC,MAAM,EAAE,CAAC,CAqBnB"}
|
|
@@ -0,0 +1,200 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v0.14.0 (T-54-2, GA-3): single-instance runner lock.
|
|
3
|
+
*
|
|
4
|
+
* On 2026-06-05 two `acc-runner watch` processes came up on one machine
|
|
5
|
+
* sharing the same repo path. They collided on per-task worktree locks and
|
|
6
|
+
* raced git refs, double-failing an entire batch in under a second
|
|
7
|
+
* (CURRENT_CYCLE.md v0.53 incident (b)). No startup guard existed.
|
|
8
|
+
*
|
|
9
|
+
* This module gives any task-executing entrypoint (today: `watch`) an
|
|
10
|
+
* exclusive lock keyed by the *canonical repo path* it serves. Acquisition
|
|
11
|
+
* uses O_EXCL (`fs.openSync(file, "wx")`) so the create-or-fail is atomic;
|
|
12
|
+
* an existing lock whose pid is dead is reclaimed with a log line, while a
|
|
13
|
+
* live holder for the SAME repo path is rejected loudly.
|
|
14
|
+
*
|
|
15
|
+
* Different repo paths coexist (future multi-repo): the lock file name is
|
|
16
|
+
* derived from a hash of the canonical repo path, so one machine can run
|
|
17
|
+
* one runner per repo without contention. The conceptual name in the spec
|
|
18
|
+
* is `<cache>/runner.lock`; the per-repo hash suffix is what makes
|
|
19
|
+
* coexistence possible.
|
|
20
|
+
*/
|
|
21
|
+
import fs from "node:fs";
|
|
22
|
+
import fsp from "node:fs/promises";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import crypto from "node:crypto";
|
|
25
|
+
import { cacheDir } from "./cache-dir.js";
|
|
26
|
+
import { isProcessAlive } from "./locks.js";
|
|
27
|
+
import { PACKAGE_VERSION } from "../pkg-version.js";
|
|
28
|
+
export class SingletonLockHeldError extends Error {
|
|
29
|
+
holder;
|
|
30
|
+
lockFile;
|
|
31
|
+
constructor(holder, lockFile) {
|
|
32
|
+
super(`another acc-runner (pid ${holder.pid}) is already serving repo ${holder.repoPath}`);
|
|
33
|
+
this.holder = holder;
|
|
34
|
+
this.lockFile = lockFile;
|
|
35
|
+
this.name = "SingletonLockHeldError";
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Resolve a repo path to a stable canonical form so two spellings of the
|
|
40
|
+
* same directory (trailing slash, symlink, `./`) map to one lock file.
|
|
41
|
+
* realpath fails if the path does not exist yet — fall back to resolve().
|
|
42
|
+
*/
|
|
43
|
+
export function canonicalRepoPath(repoPath) {
|
|
44
|
+
const resolved = path.resolve(repoPath);
|
|
45
|
+
try {
|
|
46
|
+
return fs.realpathSync(resolved);
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return resolved;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
/** `<cache>/runner.<hash>.lock`, hash derived from the canonical repo path. */
|
|
53
|
+
export function singletonLockPath(repoPath) {
|
|
54
|
+
const hash = crypto
|
|
55
|
+
.createHash("sha1")
|
|
56
|
+
.update(canonicalRepoPath(repoPath))
|
|
57
|
+
.digest("hex")
|
|
58
|
+
.slice(0, 12);
|
|
59
|
+
return path.join(cacheDir(), `runner.${hash}.lock`);
|
|
60
|
+
}
|
|
61
|
+
async function readLockInfo(file) {
|
|
62
|
+
let raw;
|
|
63
|
+
try {
|
|
64
|
+
raw = await fsp.readFile(file, "utf8");
|
|
65
|
+
}
|
|
66
|
+
catch (err) {
|
|
67
|
+
if (err.code === "ENOENT")
|
|
68
|
+
return null;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(raw);
|
|
73
|
+
if (typeof parsed.pid !== "number" || !Number.isFinite(parsed.pid))
|
|
74
|
+
return null;
|
|
75
|
+
return {
|
|
76
|
+
pid: parsed.pid,
|
|
77
|
+
repoPath: typeof parsed.repoPath === "string" ? parsed.repoPath : "<unknown>",
|
|
78
|
+
acquiredAt: typeof parsed.acquiredAt === "string" ? parsed.acquiredAt : "<unknown>",
|
|
79
|
+
version: typeof parsed.version === "string" ? parsed.version : "<unknown>",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Corrupt/partial lock file — treat as reclaimable rather than fatal.
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
function makeLock(file, repoPath, pid) {
|
|
88
|
+
return {
|
|
89
|
+
lockFile: file,
|
|
90
|
+
repoPath,
|
|
91
|
+
async release() {
|
|
92
|
+
try {
|
|
93
|
+
const holder = await readLockInfo(file);
|
|
94
|
+
// Only unlink if we still own it — never stomp a lock another
|
|
95
|
+
// process reclaimed after we exited uncleanly.
|
|
96
|
+
if (holder && holder.pid === pid)
|
|
97
|
+
await fsp.unlink(file);
|
|
98
|
+
}
|
|
99
|
+
catch (err) {
|
|
100
|
+
if (err.code !== "ENOENT")
|
|
101
|
+
throw err;
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* Acquire the single-instance lock for `repoPath`. Throws
|
|
108
|
+
* SingletonLockHeldError when a *live* process already holds the lock for
|
|
109
|
+
* the same canonical repo path. A stale lock (dead pid, our own pid, or a
|
|
110
|
+
* corrupt file) is reclaimed with a stderr breadcrumb and then re-acquired.
|
|
111
|
+
*/
|
|
112
|
+
export async function acquireSingletonLock(repoPath, pid = process.pid) {
|
|
113
|
+
const canonical = canonicalRepoPath(repoPath);
|
|
114
|
+
const file = singletonLockPath(repoPath);
|
|
115
|
+
await fsp.mkdir(path.dirname(file), { recursive: true });
|
|
116
|
+
const payload = `${JSON.stringify({
|
|
117
|
+
pid,
|
|
118
|
+
repoPath: canonical,
|
|
119
|
+
acquiredAt: new Date().toISOString(),
|
|
120
|
+
version: PACKAGE_VERSION,
|
|
121
|
+
}, null, 2)}\n`;
|
|
122
|
+
// Two attempts: the second only runs after we reclaim a stale lock, which
|
|
123
|
+
// closes the (tiny) window between the failed create and the unlink.
|
|
124
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
125
|
+
try {
|
|
126
|
+
// "wx" === O_CREAT | O_EXCL | O_WRONLY: atomic create-or-fail.
|
|
127
|
+
const fd = fs.openSync(file, "wx");
|
|
128
|
+
try {
|
|
129
|
+
fs.writeSync(fd, payload);
|
|
130
|
+
}
|
|
131
|
+
finally {
|
|
132
|
+
fs.closeSync(fd);
|
|
133
|
+
}
|
|
134
|
+
return makeLock(file, canonical, pid);
|
|
135
|
+
}
|
|
136
|
+
catch (err) {
|
|
137
|
+
if (err.code !== "EEXIST")
|
|
138
|
+
throw err;
|
|
139
|
+
const holder = await readLockInfo(file);
|
|
140
|
+
if (holder && holder.pid !== pid && isProcessAlive(holder.pid)) {
|
|
141
|
+
throw new SingletonLockHeldError(holder, file);
|
|
142
|
+
}
|
|
143
|
+
// Stale: dead holder, our own leftover pid, or unparseable. Reclaim.
|
|
144
|
+
process.stderr.write(`[acc-runner] reclaiming stale runner lock ${file} ` +
|
|
145
|
+
`(held by pid ${holder?.pid ?? "?"}, not alive)\n`);
|
|
146
|
+
try {
|
|
147
|
+
fs.unlinkSync(file);
|
|
148
|
+
}
|
|
149
|
+
catch (unlinkErr) {
|
|
150
|
+
if (unlinkErr.code !== "ENOENT")
|
|
151
|
+
throw unlinkErr;
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
throw new Error(`could not acquire singleton lock at ${file}`);
|
|
156
|
+
}
|
|
157
|
+
/**
|
|
158
|
+
* Inspect the lock file for `repoPath` and return its holder iff a *live*
|
|
159
|
+
* process other than `selfPid` owns it. Used by `acc-runner doctor` to
|
|
160
|
+
* report a duplicate runner without trying to acquire the lock.
|
|
161
|
+
*/
|
|
162
|
+
export async function findLiveLockHolder(repoPath, selfPid = process.pid) {
|
|
163
|
+
const holder = await readLockInfo(singletonLockPath(repoPath));
|
|
164
|
+
if (holder && holder.pid !== selfPid && isProcessAlive(holder.pid)) {
|
|
165
|
+
return holder;
|
|
166
|
+
}
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
/**
|
|
170
|
+
* pgrep-style scan for other `acc-runner watch` processes. Best-effort and
|
|
171
|
+
* offline of the lock file — used by doctor to catch a runner that predates
|
|
172
|
+
* the singleton lock (no lock file) or one whose lock was hand-deleted.
|
|
173
|
+
* Returns foreign pids; never throws (returns [] on any failure).
|
|
174
|
+
*/
|
|
175
|
+
export async function listForeignRunnerPids(selfPid = process.pid) {
|
|
176
|
+
try {
|
|
177
|
+
const { execa } = await import("execa");
|
|
178
|
+
const { stdout } = await execa("ps", ["-axo", "pid=,args="]);
|
|
179
|
+
const pids = [];
|
|
180
|
+
for (const line of stdout.split(/\r?\n/)) {
|
|
181
|
+
const m = line.trim().match(/^(\d+)\s+(.*)$/);
|
|
182
|
+
if (!m)
|
|
183
|
+
continue;
|
|
184
|
+
const pid = Number.parseInt(m[1], 10);
|
|
185
|
+
const args = m[2];
|
|
186
|
+
if (pid === selfPid)
|
|
187
|
+
continue;
|
|
188
|
+
// Match the runner's watch command without matching this doctor run
|
|
189
|
+
// (which is `acc-runner doctor`) or a grep of the word.
|
|
190
|
+
if (/acc-runner\b.*\bwatch\b/.test(args) || /\bacc-runner-watch\b/.test(args)) {
|
|
191
|
+
pids.push(pid);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return pids;
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
//# sourceMappingURL=singleton.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"singleton.js","sourceRoot":"","sources":["../../src/runtime/singleton.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
export declare const NPM_PACKAGE = "@tokenfactory/acc-runner";
|
|
2
|
+
export interface TaskEnvRecord {
|
|
3
|
+
/** `claude --version` first line at the last successful task, or null. */
|
|
4
|
+
claudeVersion: string | null;
|
|
5
|
+
recordedAt: string;
|
|
6
|
+
}
|
|
7
|
+
export declare function taskEnvFile(): string;
|
|
8
|
+
export interface NpmLatestDeps {
|
|
9
|
+
/** Fetch the latest published version string, or null on failure. */
|
|
10
|
+
fetchLatest?: () => Promise<string | null>;
|
|
11
|
+
/** Now, ms since epoch — injectable for cache-freshness tests. */
|
|
12
|
+
now?: number;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Latest published version, via a cached registry lookup. Returns the
|
|
16
|
+
* cached value while it is fresh; refetches once it goes stale. On a
|
|
17
|
+
* network failure with no usable cache, returns null so the caller skips
|
|
18
|
+
* the drift check silently (offline-safe).
|
|
19
|
+
*/
|
|
20
|
+
export declare function npmLatestVersion(deps?: NpmLatestDeps): Promise<string | null>;
|
|
21
|
+
/**
|
|
22
|
+
* First line of `claude --version`, or null when the binary is missing or
|
|
23
|
+
* errors. Shared by the watch success-recorder and doctor's drift check so
|
|
24
|
+
* both compare the exact same string.
|
|
25
|
+
*/
|
|
26
|
+
export declare function getClaudeVersion(): Promise<string | null>;
|
|
27
|
+
/** Persist the claude CLI version seen at a successful task. Best-effort. */
|
|
28
|
+
export declare function recordTaskClaudeVersion(claudeVersion: string | null): Promise<void>;
|
|
29
|
+
/** Read the last recorded task-env, or null when no task has succeeded yet. */
|
|
30
|
+
export declare function readTaskEnv(): Promise<TaskEnvRecord | null>;
|
|
31
|
+
//# sourceMappingURL=version-drift.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"version-drift.d.ts","sourceRoot":"","sources":["../../src/runtime/version-drift.ts"],"names":[],"mappings":"AAwBA,eAAO,MAAM,WAAW,6BAA6B,CAAC;AAQtD,MAAM,WAAW,aAAa;IAC5B,0EAA0E;IAC1E,aAAa,EAAE,MAAM,GAAG,IAAI,CAAC;IAC7B,UAAU,EAAE,MAAM,CAAC;CACpB;AAMD,wBAAgB,WAAW,IAAI,MAAM,CAEpC;AAgBD,MAAM,WAAW,aAAa;IAC5B,qEAAqE;IACrE,WAAW,CAAC,EAAE,MAAM,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAAC;IAC3C,kEAAkE;IAClE,GAAG,CAAC,EAAE,MAAM,CAAC;CACd;AAiBD;;;;;GAKG;AACH,wBAAsB,gBAAgB,CACpC,IAAI,GAAE,aAAkB,GACvB,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAuBxB;AAED;;;;GAIG;AACH,wBAAsB,gBAAgB,IAAI,OAAO,CAAC,MAAM,GAAG,IAAI,CAAC,CAO/D;AAED,6EAA6E;AAC7E,wBAAsB,uBAAuB,CAC3C,aAAa,EAAE,MAAM,GAAG,IAAI,GAC3B,OAAO,CAAC,IAAI,CAAC,CAKf;AAED,+EAA+E;AAC/E,wBAAsB,WAAW,IAAI,OAAO,CAAC,aAAa,GAAG,IAAI,CAAC,CAEjE"}
|