agy-crew 0.0.0-stage → 0.9.2
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/LICENSE +22 -0
- package/README.md +315 -2
- package/README.zh-CN.md +314 -0
- package/companion/accounts.mjs +300 -0
- package/companion/agy-companion.mjs +2136 -0
- package/companion/identity.mjs +25 -0
- package/companion/install.mjs +443 -0
- package/companion/observation.mjs +147 -0
- package/companion/pool/env.mjs +14 -0
- package/companion/pool/lease.mjs +89 -0
- package/companion/pool/loans.mjs +46 -0
- package/companion/pool/quota.mjs +142 -0
- package/companion/pool/results.mjs +41 -0
- package/companion/pool/select.mjs +115 -0
- package/companion/pool/state.mjs +104 -0
- package/companion/pool/token.mjs +25 -0
- package/companion/state-lock.mjs +103 -0
- package/companion/stream-worker.mjs +361 -0
- package/docs/INSTALL_FOR_AGENTS.md +213 -0
- package/docs/REFERENCE.md +509 -0
- package/docs/REFERENCE.zh-CN.md +619 -0
- package/opencode-skills/agy-crew-accounts/SKILL.md +85 -0
- package/opencode-skills/agy-crew-ask/SKILL.md +46 -0
- package/opencode-skills/agy-crew-implementer/SKILL.md +56 -0
- package/opencode-skills/agy-crew-jobs/SKILL.md +99 -0
- package/opencode-skills/agy-crew-jobs/references/setup.md +41 -0
- package/opencode-skills/agy-crew-jobs/references/troubleshooting.md +35 -0
- package/opencode-skills/agy-crew-lead/SKILL.md +39 -0
- package/opencode-skills/agy-crew-researcher/SKILL.md +52 -0
- package/opencode-skills/agy-crew-reviewer/SKILL.md +62 -0
- package/opencode-skills/agy-crew-reviewer/references/code-review.md +46 -0
- package/opencode-skills/agy-crew-reviewer/references/general-review.md +22 -0
- package/opencode-skills/agy-crew-staffer/SKILL.md +54 -0
- package/opencode.mjs +14 -0
- package/package.json +53 -4
- package/pi-skills/agy-crew-accounts/SKILL.md +85 -0
- package/pi-skills/agy-crew-ask/SKILL.md +46 -0
- package/pi-skills/agy-crew-implementer/SKILL.md +56 -0
- package/pi-skills/agy-crew-jobs/SKILL.md +99 -0
- package/pi-skills/agy-crew-jobs/references/setup.md +41 -0
- package/pi-skills/agy-crew-jobs/references/troubleshooting.md +35 -0
- package/pi-skills/agy-crew-lead/SKILL.md +39 -0
- package/pi-skills/agy-crew-researcher/SKILL.md +52 -0
- package/pi-skills/agy-crew-reviewer/SKILL.md +62 -0
- package/pi-skills/agy-crew-reviewer/references/code-review.md +46 -0
- package/pi-skills/agy-crew-reviewer/references/general-review.md +22 -0
- package/pi-skills/agy-crew-staffer/SKILL.md +54 -0
- package/scripts/generate-skills.mjs +138 -0
- package/skills/accounts/SKILL.md +78 -0
- package/skills/ask/SKILL.md +40 -0
- package/skills/implementer/SKILL.md +50 -0
- package/skills/jobs/SKILL.md +93 -0
- package/skills/jobs/references/setup.md +39 -0
- package/skills/jobs/references/troubleshooting.md +33 -0
- package/skills/lead/SKILL.md +32 -0
- package/skills/researcher/SKILL.md +46 -0
- package/skills/reviewer/SKILL.md +56 -0
- package/skills/reviewer/references/code-review.md +44 -0
- package/skills/reviewer/references/general-review.md +20 -0
- package/skills/staffer/SKILL.md +48 -0
- package/templates/ask.md +5 -0
- package/templates/harness-compatibility.md +5 -0
- package/templates/implement.md +39 -0
- package/templates/research.md +37 -0
- package/templates/review.md +27 -0
- package/templates/staffer.md +19 -0
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { accountEnv } from './env.mjs';
|
|
3
|
+
import { accountHome, loadPool, poolExists, updatePool } from './state.mjs';
|
|
4
|
+
import { parseResetsIn } from './results.mjs';
|
|
5
|
+
import { chooseAccount, chooseLender, findAccount } from './select.mjs';
|
|
6
|
+
import { beginLoan, endLoan } from './loans.mjs';
|
|
7
|
+
|
|
8
|
+
export const boundAccount = (pool, conversation) =>
|
|
9
|
+
conversation ? pool.conversations[conversation]?.account || 'live' : null;
|
|
10
|
+
|
|
11
|
+
const request = (req, pool) => ({
|
|
12
|
+
requested: req.requested, family: req.family, conversation: req.conversation || null,
|
|
13
|
+
conversationAccount: boundAccount(pool, req.conversation), now: Date.now(),
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
/** Read-only check used before a background job is registered. Returns an error
|
|
17
|
+
* object or null. Never locks or creates anything. */
|
|
18
|
+
export function preflight(req) {
|
|
19
|
+
const pool = loadPool();
|
|
20
|
+
return chooseAccount(pool, request(req, pool)).error || null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const liveHeld = (pool, extra = {}) => ({ account: 'live', env: undefined, pooled: pool.accounts.length > 0, release() {}, ...extra });
|
|
24
|
+
|
|
25
|
+
/** Selection and lease insertion are one locked update, so two runs starting
|
|
26
|
+
* together cannot both see the same account as idle. Returns
|
|
27
|
+
* { held } or { error }. held.release() must run after the agy process ends. */
|
|
28
|
+
export function acquireRun(req) {
|
|
29
|
+
if (!poolExists()) {
|
|
30
|
+
const pool = loadPool();
|
|
31
|
+
const pick = chooseAccount(pool, request(req, pool));
|
|
32
|
+
return pick.error ? { error: pick.error } : { held: liveHeld(pool) };
|
|
33
|
+
}
|
|
34
|
+
const outcome = updatePool((pool) => {
|
|
35
|
+
const pick = chooseAccount(pool, request(req, pool));
|
|
36
|
+
if (pick.error) return { error: pick.error };
|
|
37
|
+
if (pick.account === 'live') return { held: { account: 'live', pooled: pool.accounts.length > 0 } };
|
|
38
|
+
const base = { pid: process.pid, job_id: req.jobId || null, started_at: new Date().toISOString() };
|
|
39
|
+
const account = findAccount(pool, pick.account);
|
|
40
|
+
account.last_used_at = base.started_at;
|
|
41
|
+
if (pick.needsLoan) {
|
|
42
|
+
const lender = chooseLender(pool, { family: req.family, exclude: [pick.account] });
|
|
43
|
+
if (!lender) {
|
|
44
|
+
return { error: { reason: 'quota_exhausted', retry_at: pick.retry_at,
|
|
45
|
+
message: `account ${pick.account} has no ${req.family} quota until ${pick.retry_at} and no other pool account is idle to lend a credential.` } };
|
|
46
|
+
}
|
|
47
|
+
const loan = beginLoan(pool, { borrower: pick.account, lender, jobId: req.jobId });
|
|
48
|
+
return { held: { account: pick.account, lender, loanId: loan.id, pooled: true } };
|
|
49
|
+
}
|
|
50
|
+
const id = randomUUID().slice(0, 8);
|
|
51
|
+
pool.leases.push({ ...base, id, account: pick.account, role: 'run' });
|
|
52
|
+
return { held: { account: pick.account, leaseId: id, auto: !!pick.auto, pooled: true } };
|
|
53
|
+
});
|
|
54
|
+
if (outcome.error) return outcome;
|
|
55
|
+
const held = outcome.held;
|
|
56
|
+
held.env = held.account === 'live' ? undefined : accountEnv(accountHome(held.account));
|
|
57
|
+
held.release = () => {
|
|
58
|
+
if (!held.leaseId && !held.loanId) return;
|
|
59
|
+
const { leaseId, loanId } = held;
|
|
60
|
+
held.leaseId = held.loanId = null;
|
|
61
|
+
updatePool((pool) => {
|
|
62
|
+
if (loanId) endLoan(pool, loanId);
|
|
63
|
+
if (leaseId) pool.leases = pool.leases.filter((l) => l.id !== leaseId);
|
|
64
|
+
});
|
|
65
|
+
};
|
|
66
|
+
return { held };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const DEFAULT_COOLDOWN_MS = 60 * 60_000;
|
|
70
|
+
|
|
71
|
+
/** Record a quota cooldown for the account whose credential made the request.
|
|
72
|
+
* Returns the cooldown (retry_at is when the reported reset passes, or one
|
|
73
|
+
* hour when agy did not say). */
|
|
74
|
+
export function recordCooldown(name, family, text, jobId) {
|
|
75
|
+
const now = Date.now();
|
|
76
|
+
const cooldown = { retry_at: new Date(now + (parseResetsIn(text) ?? DEFAULT_COOLDOWN_MS)).toISOString(),
|
|
77
|
+
reason: 'quota_exhausted', recorded_at: new Date(now).toISOString(), job_id: jobId || null,
|
|
78
|
+
message: String(text).trim().slice(0, 500) };
|
|
79
|
+
updatePool((pool) => { pool.cooldowns[`${name}|${family}`] = cooldown; });
|
|
80
|
+
return cooldown;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** An authentication failure parks the account until `accounts login`. */
|
|
84
|
+
export function markAuthError(name, text) {
|
|
85
|
+
updatePool((pool) => {
|
|
86
|
+
const account = findAccount(pool, name);
|
|
87
|
+
if (account) { account.auth_error_at = new Date().toISOString(); account.auth_error = String(text).trim().slice(0, 300); }
|
|
88
|
+
});
|
|
89
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
import { replaceFile } from '../state-lock.mjs';
|
|
5
|
+
import { PoolError, LOAN_BACKUP_NAME, accountHome, tokenPath, restoreLoanFiles } from './state.mjs';
|
|
6
|
+
|
|
7
|
+
/** Credential loan: the borrower's HOME keeps the conversation, the lender's
|
|
8
|
+
* token makes the request. Called inside updatePool, so the file swap and the
|
|
9
|
+
* loan record are one transaction under the pool lock. */
|
|
10
|
+
export function beginLoan(pool, { borrower, lender, jobId, pid = process.pid }) {
|
|
11
|
+
const tb = tokenPath(accountHome(borrower));
|
|
12
|
+
const tl = tokenPath(accountHome(lender));
|
|
13
|
+
const backup = path.join(path.dirname(tb), LOAN_BACKUP_NAME);
|
|
14
|
+
if (fs.existsSync(backup)) throw new PoolError(`a loan backup already exists for ${borrower}: ${backup}; resolve it before borrowing again`);
|
|
15
|
+
if (!fs.existsSync(tl)) throw new PoolError(`lender ${lender} has no token to lend`);
|
|
16
|
+
const hostHadToken = fs.existsSync(tb);
|
|
17
|
+
const loan = { id: randomUUID().slice(0, 8), borrower, lender, pid, job_id: jobId || null,
|
|
18
|
+
host_had_token: hostHadToken, started_at: new Date().toISOString() };
|
|
19
|
+
try {
|
|
20
|
+
if (hostHadToken) replaceFile(tb, backup);
|
|
21
|
+
const tmp = `${tb}.tmp-${process.pid}`;
|
|
22
|
+
fs.mkdirSync(path.dirname(tb), { recursive: true });
|
|
23
|
+
fs.copyFileSync(tl, tmp);
|
|
24
|
+
replaceFile(tmp, tb);
|
|
25
|
+
} catch (error) {
|
|
26
|
+
// Put the borrower back exactly as it was before surfacing the failure.
|
|
27
|
+
try { fs.rmSync(tb, { force: true }); if (hostHadToken && fs.existsSync(backup)) replaceFile(backup, tb); } catch {}
|
|
28
|
+
throw error;
|
|
29
|
+
}
|
|
30
|
+
pool.loans.push(loan);
|
|
31
|
+
const lease = { pid, job_id: jobId || null, started_at: loan.started_at, loan_id: loan.id };
|
|
32
|
+
pool.leases.push({ ...lease, id: randomUUID().slice(0, 8), account: lender, role: 'lender' });
|
|
33
|
+
pool.leases.push({ ...lease, id: randomUUID().slice(0, 8), account: borrower, role: 'run' });
|
|
34
|
+
return loan;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Restore the tokens and forget the loan and its leases. */
|
|
38
|
+
export function endLoan(pool, loanId) {
|
|
39
|
+
const loan = pool.loans.find((l) => l.id === loanId);
|
|
40
|
+
if (!loan) return;
|
|
41
|
+
restoreLoanFiles(loan);
|
|
42
|
+
pool.loans = pool.loans.filter((l) => l.id !== loanId);
|
|
43
|
+
pool.leases = pool.leases.filter((l) => l.loan_id !== loanId);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export const accountInLoan = (pool, name) => pool.loans.some((l) => l.borrower === name || l.lender === name);
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { spawnSync } from 'node:child_process';
|
|
4
|
+
import { accountEnv } from './env.mjs';
|
|
5
|
+
import { accountHome, updatePool } from './state.mjs';
|
|
6
|
+
import { quotaPool } from './results.mjs';
|
|
7
|
+
import { readToken } from './token.mjs';
|
|
8
|
+
|
|
9
|
+
const baseUrl = () => process.env.AGY_CREW_CLOUDCODE_URL || 'https://cloudcode-pa.googleapis.com';
|
|
10
|
+
const WARM_UP_MS = 120_000;
|
|
11
|
+
const projectCache = (home) => path.join(home, '.gemini', 'antigravity-cli', 'cache', 'default_project_id.txt');
|
|
12
|
+
|
|
13
|
+
/** Run agy once so it refreshes the file-backed token itself. */
|
|
14
|
+
function warmUp(home, agyCommand) {
|
|
15
|
+
const agy = agyCommand(['-p', 'reply with one word: pong', '--output-format', 'json']);
|
|
16
|
+
spawnSync(agy.cmd, agy.args, { cwd: home, env: accountEnv(home), encoding: 'utf8', timeout: 60_000, windowsHide: true });
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
async function post(endpoint, accessToken, body) {
|
|
20
|
+
const response = await fetch(`${baseUrl()}/v1internal:${endpoint}`, {
|
|
21
|
+
method: 'POST',
|
|
22
|
+
headers: { Authorization: `Bearer ${accessToken}`, 'Content-Type': 'application/json', 'User-Agent': 'antigravity' },
|
|
23
|
+
body: JSON.stringify(body),
|
|
24
|
+
signal: AbortSignal.timeout(20_000),
|
|
25
|
+
});
|
|
26
|
+
let json = null;
|
|
27
|
+
try { json = await response.json(); } catch { /* non-JSON error body */ }
|
|
28
|
+
return { status: response.status, json };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const bucketId = (b) => b.id || b.bucketId || b.quotaBucketId || b.name || '';
|
|
32
|
+
|
|
33
|
+
/** Quota pool of one Cloud Code group: bucket id prefixes first, then its labels. */
|
|
34
|
+
function groupPool(group, buckets) {
|
|
35
|
+
for (const b of buckets) {
|
|
36
|
+
const id = String(bucketId(b));
|
|
37
|
+
if (id.startsWith('gemini-')) return 'gemini';
|
|
38
|
+
if (id.startsWith('3p-')) return 'other';
|
|
39
|
+
}
|
|
40
|
+
const label = `${group.displayName || ''} ${group.description || ''}`.toLowerCase();
|
|
41
|
+
if (label.includes('gemini')) return 'gemini';
|
|
42
|
+
if (/claude|gpt/.test(label)) return 'other';
|
|
43
|
+
return null;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function parseQuotaSummary(json) {
|
|
47
|
+
const pools = {};
|
|
48
|
+
for (const group of json?.groups || []) {
|
|
49
|
+
const buckets = group.buckets || group.quotaBuckets || [];
|
|
50
|
+
const name = groupPool(group, buckets);
|
|
51
|
+
if (!name) continue;
|
|
52
|
+
for (const b of buckets) {
|
|
53
|
+
const window = String(b.window || b.windowType || b.period || '').toLowerCase();
|
|
54
|
+
const key = window === '5h' ? 'short' : window === 'weekly' ? 'weekly' : null;
|
|
55
|
+
if (!key) continue;
|
|
56
|
+
// proto3 JSON omits a zero fraction, so a bucket with a reset time but no fraction is empty
|
|
57
|
+
const remaining = typeof b.remainingFraction === 'number' ? b.remainingFraction : b.resetTime ? 0 : null;
|
|
58
|
+
if (remaining === null) continue;
|
|
59
|
+
(pools[name] ||= {})[key] = { remaining, reset_at: b.resetTime ? new Date(b.resetTime).toISOString() : null };
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
return pools;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
async function queryCloudCode(home, accessToken) {
|
|
66
|
+
const meta = { metadata: { ideType: 'ANTIGRAVITY', platform: 'PLATFORM_UNSPECIFIED', pluginType: 'GEMINI' } };
|
|
67
|
+
const load = await post('loadCodeAssist', accessToken, meta);
|
|
68
|
+
if (load.status === 401) return { status: 401 };
|
|
69
|
+
if (load.status >= 400) return { status: load.status, error: `loadCodeAssist HTTP ${load.status}` };
|
|
70
|
+
let project = load.json?.cloudaicompanionProject;
|
|
71
|
+
project = typeof project === 'string' ? project : project?.id;
|
|
72
|
+
if (project) {
|
|
73
|
+
try { fs.mkdirSync(path.dirname(projectCache(home)), { recursive: true }); fs.writeFileSync(projectCache(home), project); } catch {}
|
|
74
|
+
} else {
|
|
75
|
+
try { project = fs.readFileSync(projectCache(home), 'utf8').trim(); } catch {}
|
|
76
|
+
}
|
|
77
|
+
if (!project) return { status: 200, error: 'no Cloud Code project id available' };
|
|
78
|
+
const summary = await post('retrieveUserQuotaSummary', accessToken, { project });
|
|
79
|
+
if (summary.status === 401) return { status: 401 };
|
|
80
|
+
if (summary.status >= 400) return { status: summary.status, error: `retrieveUserQuotaSummary HTTP ${summary.status}` };
|
|
81
|
+
return { status: 200, plan_type: load.json?.planInfo?.planType || null, pools: parseQuotaSummary(summary.json) };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const needsWarmUp = (tok) => !tok?.access_token || (tok.expiry_ms !== null && tok.expiry_ms - Date.now() < WARM_UP_MS);
|
|
85
|
+
|
|
86
|
+
/** Query Cloud Code for one account. Never throws, never prints tokens. */
|
|
87
|
+
export async function fetchSnapshot(name, { agyCommand }) {
|
|
88
|
+
const home = accountHome(name);
|
|
89
|
+
const snapshot = { refreshed_at: new Date().toISOString(), plan_type: null, pools: {}, error: null };
|
|
90
|
+
let authFailed = false;
|
|
91
|
+
try {
|
|
92
|
+
let tok = readToken(home);
|
|
93
|
+
if (needsWarmUp(tok)) { warmUp(home, agyCommand); tok = readToken(home); }
|
|
94
|
+
if (!tok?.access_token) throw new Error('no access token (run `accounts login`)');
|
|
95
|
+
let result = await queryCloudCode(home, tok.access_token);
|
|
96
|
+
if (result.status === 401) {
|
|
97
|
+
warmUp(home, agyCommand);
|
|
98
|
+
tok = readToken(home);
|
|
99
|
+
result = tok?.access_token ? await queryCloudCode(home, tok.access_token) : { status: 401 };
|
|
100
|
+
if (result.status === 401) { authFailed = true; throw new Error('Cloud Code rejected the credential (HTTP 401)'); }
|
|
101
|
+
}
|
|
102
|
+
if (result.error) throw new Error(result.error);
|
|
103
|
+
snapshot.plan_type = result.plan_type;
|
|
104
|
+
snapshot.pools = result.pools;
|
|
105
|
+
} catch (error) {
|
|
106
|
+
snapshot.error = String(error.message || error).slice(0, 300);
|
|
107
|
+
}
|
|
108
|
+
return { snapshot, authFailed };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Fetch snapshots, then store them in one locked update. A pool with quota
|
|
112
|
+
* left clears that account's cooldowns for its families. */
|
|
113
|
+
export async function refreshQuotas(names, deps) {
|
|
114
|
+
const fetched = [];
|
|
115
|
+
for (const name of names) fetched.push([name, await fetchSnapshot(name, deps)]);
|
|
116
|
+
updatePool((pool) => {
|
|
117
|
+
for (const [name, { snapshot, authFailed }] of fetched) {
|
|
118
|
+
const account = pool.accounts.find((a) => a.name === name);
|
|
119
|
+
if (!account) continue;
|
|
120
|
+
pool.quota[name] = snapshot;
|
|
121
|
+
if (authFailed) { account.auth_error_at = snapshot.refreshed_at; account.auth_error = snapshot.error; }
|
|
122
|
+
for (const [group, windows] of Object.entries(snapshot.pools)) {
|
|
123
|
+
const values = Object.values(windows).map((w) => w.remaining);
|
|
124
|
+
if (!values.length || Math.min(...values) <= 0) continue;
|
|
125
|
+
for (const key of Object.keys(pool.cooldowns)) {
|
|
126
|
+
const [owner, family] = key.split('|');
|
|
127
|
+
if (owner === name && quotaPool(family) === group) delete pool.cooldowns[key];
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
});
|
|
132
|
+
return Object.fromEntries(fetched.map(([name, f]) => [name, f.snapshot]));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** One-line quota summary for list/quota output. */
|
|
136
|
+
export function summarizeSnapshot(snapshot) {
|
|
137
|
+
if (!snapshot) return 'quota: not refreshed';
|
|
138
|
+
if (snapshot.error) return `quota: error (${snapshot.error})`;
|
|
139
|
+
const pct = (w) => (w ? `${Math.round(w.remaining * 100)}%` : '?');
|
|
140
|
+
const parts = Object.entries(snapshot.pools).map(([g, w]) => `${g} 5h ${pct(w.short)} weekly ${pct(w.weekly)}`);
|
|
141
|
+
return `quota: ${parts.join(' | ') || 'no data'}${snapshot.plan_type ? ` (${snapshot.plan_type})` : ''}`;
|
|
142
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Classifiers for agy failure text. */
|
|
2
|
+
|
|
3
|
+
const EFFORT_SUFFIX = /-(minimal|low|medium|high|xhigh|max)$/;
|
|
4
|
+
|
|
5
|
+
/** gemini-3.8-flash-low -> gemini-3.8-flash */
|
|
6
|
+
export function modelFamily(model) {
|
|
7
|
+
return String(model || '').replace(EFFORT_SUFFIX, '');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Cloud Code reports two quota groups: gemini and everything else. */
|
|
11
|
+
export function quotaPool(family) {
|
|
12
|
+
return String(family).startsWith('gemini') ? 'gemini' : 'other';
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function isQuotaExhausted(text) {
|
|
16
|
+
const t = String(text || '').toLowerCase();
|
|
17
|
+
return t.includes('resource_exhausted') && (t.includes('individual quota reached') || t.includes('weekly quota reached'));
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function isAuthFailed(text) {
|
|
21
|
+
const t = String(text || '').toLowerCase();
|
|
22
|
+
return ['401', '403', 'unauthenticated'].some((a) => t.includes(a)) &&
|
|
23
|
+
['auth', 'credential', 'login', 'token'].some((b) => t.includes(b));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** 'quota_exhausted' | 'authentication_failed' | null */
|
|
27
|
+
export function classifyFailureText(text) {
|
|
28
|
+
if (isQuotaExhausted(text)) return 'quota_exhausted';
|
|
29
|
+
if (isAuthFailed(text)) return 'authentication_failed';
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** "Resets in 2h3m" / "1d 4h" / "45s" -> milliseconds, or null. */
|
|
34
|
+
export function parseResetsIn(text) {
|
|
35
|
+
const m = /resets in ((?:\d+\s*[dhms]\s*)+)/i.exec(String(text || ''));
|
|
36
|
+
if (!m) return null;
|
|
37
|
+
const unit = { d: 86_400_000, h: 3_600_000, m: 60_000, s: 1000 };
|
|
38
|
+
let total = 0;
|
|
39
|
+
for (const part of m[1].matchAll(/(\d+)\s*([dhms])/gi)) total += Number(part[1]) * unit[part[2].toLowerCase()];
|
|
40
|
+
return total > 0 ? total : null;
|
|
41
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { accountHome, tokenPath } from './state.mjs';
|
|
3
|
+
import { quotaPool } from './results.mjs';
|
|
4
|
+
|
|
5
|
+
export const findAccount = (pool, name) => pool.accounts.find((a) => a.name === name) || null;
|
|
6
|
+
export const leasesOn = (pool, name) => pool.leases.filter((l) => l.account === name);
|
|
7
|
+
const defaultHasToken = (name) => fs.existsSync(tokenPath(accountHome(name)));
|
|
8
|
+
|
|
9
|
+
export function activeCooldown(pool, name, family, now) {
|
|
10
|
+
const c = pool.cooldowns[`${name}|${family}`];
|
|
11
|
+
return c && Date.parse(c.retry_at) > now ? c : null;
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
/** Cloud Code snapshot says a window of this family's quota pool is empty and not yet reset. */
|
|
15
|
+
export function exhaustedSnapshot(pool, name, family, now) {
|
|
16
|
+
const group = pool.quota?.[name]?.pools?.[quotaPool(family)];
|
|
17
|
+
if (!group) return null;
|
|
18
|
+
for (const window of ['short', 'weekly']) {
|
|
19
|
+
const w = group[window];
|
|
20
|
+
if (w && w.remaining === 0 && Date.parse(w.reset_at) > now) return w;
|
|
21
|
+
}
|
|
22
|
+
return null;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** Smallest known remaining fraction across windows, or null when unknown. */
|
|
26
|
+
function knownRemaining(pool, name, family) {
|
|
27
|
+
const group = pool.quota?.[name]?.pools?.[quotaPool(family)];
|
|
28
|
+
const values = ['short', 'weekly'].map((w) => group?.[w]?.remaining).filter((v) => typeof v === 'number');
|
|
29
|
+
return values.length ? Math.min(...values) : null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function usable(pool, a, family, now, hasToken) {
|
|
33
|
+
return !a.disabled && !a.auth_error_at && hasToken(a.name) &&
|
|
34
|
+
!activeCooldown(pool, a.name, family, now) && !exhaustedSnapshot(pool, a.name, family, now);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// fewer leases, then more known quota (unknown after known > 0), then least recently used, then insertion order
|
|
38
|
+
function rank(pool, family, withLeases) {
|
|
39
|
+
return (a, b) => {
|
|
40
|
+
if (withLeases) {
|
|
41
|
+
const d = leasesOn(pool, a.name).length - leasesOn(pool, b.name).length;
|
|
42
|
+
if (d) return d;
|
|
43
|
+
}
|
|
44
|
+
const ra = knownRemaining(pool, a.name, family) ?? -1;
|
|
45
|
+
const rb = knownRemaining(pool, b.name, family) ?? -1;
|
|
46
|
+
if (ra !== rb) return rb - ra;
|
|
47
|
+
const ua = a.last_used_at || '', ub = b.last_used_at || '';
|
|
48
|
+
if (ua !== ub) return ua < ub ? -1 : 1;
|
|
49
|
+
return pool.accounts.indexOf(a) - pool.accounts.indexOf(b);
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function coolingError(pool, accounts, family, now, intro) {
|
|
54
|
+
const rows = accounts.map((a) => {
|
|
55
|
+
const c = activeCooldown(pool, a.name, family, now);
|
|
56
|
+
const s = exhaustedSnapshot(pool, a.name, family, now);
|
|
57
|
+
return { name: a.name, at: c?.retry_at || s?.reset_at || null };
|
|
58
|
+
});
|
|
59
|
+
const times = rows.map((r) => r.at).filter(Boolean).sort();
|
|
60
|
+
const detail = rows.map((r) => ` ${r.name}: ${r.at ? `retry after ${r.at}` : 'unavailable'}`).join('\n');
|
|
61
|
+
return { error: { reason: 'quota_exhausted', retry_at: times[0] || null,
|
|
62
|
+
message: `${intro}\n${detail}\nUse \`--account live\` (your own agy login) or another model family.` } };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Pick the account for a run. Pure: callers add the lease. Returns
|
|
66
|
+
* { account, auto?, needsLoan?, retry_at? } or { error: { message, reason?, retry_at? } }. */
|
|
67
|
+
export function chooseAccount(pool, { requested = 'auto', family, conversation = null, conversationAccount = null, now = Date.now(), hasToken = defaultHasToken }) {
|
|
68
|
+
const explicit = requested && requested !== 'auto';
|
|
69
|
+
if (conversationAccount) {
|
|
70
|
+
if (explicit && requested !== conversationAccount) {
|
|
71
|
+
return { error: { message: `conversation ${conversation} belongs to account ${conversationAccount}; --account ${requested} cannot continue it` } };
|
|
72
|
+
}
|
|
73
|
+
if (conversationAccount === 'live') return { account: 'live' };
|
|
74
|
+
const a = findAccount(pool, conversationAccount);
|
|
75
|
+
if (!a) return { error: { message: `conversation ${conversation} belongs to account ${conversationAccount}, which no longer exists` } };
|
|
76
|
+
if (a.disabled) return { error: { message: `conversation ${conversation} belongs to account ${a.name}, which is disabled (accounts enable ${a.name})` } };
|
|
77
|
+
if (a.auth_error_at || !hasToken(a.name)) return { error: { message: `conversation ${conversation} belongs to account ${a.name}, which needs login (accounts login ${a.name})` } };
|
|
78
|
+
const c = activeCooldown(pool, a.name, family, now);
|
|
79
|
+
if (c) return { account: a.name, needsLoan: true, retry_at: c.retry_at };
|
|
80
|
+
return { account: a.name };
|
|
81
|
+
}
|
|
82
|
+
if (requested === 'live') return { account: 'live' };
|
|
83
|
+
if (explicit) {
|
|
84
|
+
const a = findAccount(pool, requested);
|
|
85
|
+
if (!a) return { error: { message: `unknown account "${requested}" (see \`accounts list\`)` } };
|
|
86
|
+
if (a.disabled) return { error: { message: `account ${a.name} is disabled (accounts enable ${a.name})` } };
|
|
87
|
+
if (a.auth_error_at || !hasToken(a.name)) return { error: { message: `account ${a.name} needs login (accounts login ${a.name})` } };
|
|
88
|
+
const c = activeCooldown(pool, a.name, family, now);
|
|
89
|
+
if (c) {
|
|
90
|
+
return { error: { reason: 'quota_exhausted', retry_at: c.retry_at,
|
|
91
|
+
message: `account ${a.name} has no ${family} quota until ${c.retry_at}. Use \`--account auto\`, another account, or another model family.` } };
|
|
92
|
+
}
|
|
93
|
+
return { account: a.name };
|
|
94
|
+
}
|
|
95
|
+
const enabled = pool.accounts.filter((a) => !a.disabled);
|
|
96
|
+
if (!enabled.length) return { account: 'live' };
|
|
97
|
+
const candidates = enabled.filter((a) => usable(pool, a, family, now, hasToken));
|
|
98
|
+
if (!candidates.length) {
|
|
99
|
+
const cooling = enabled.filter((a) => activeCooldown(pool, a.name, family, now) || exhaustedSnapshot(pool, a.name, family, now));
|
|
100
|
+
if (cooling.length) return coolingError(pool, cooling, family, now, `every pool account is out of ${family} quota (quota_exhausted).`);
|
|
101
|
+
return { error: { message: 'no pool account is ready: each enabled account needs login (see `accounts list`)' } };
|
|
102
|
+
}
|
|
103
|
+
candidates.sort(rank(pool, family, true));
|
|
104
|
+
return { account: candidates[0].name, auto: true };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** An idle account that can lend its credential to a borrower. */
|
|
108
|
+
export function chooseLender(pool, { family, exclude = [], now = Date.now(), hasToken = defaultHasToken }) {
|
|
109
|
+
const inLoan = new Set(pool.loans.flatMap((l) => [l.borrower, l.lender]));
|
|
110
|
+
const candidates = pool.accounts.filter((a) => !exclude.includes(a.name) && !inLoan.has(a.name) &&
|
|
111
|
+
leasesOn(pool, a.name).length === 0 && usable(pool, a, family, now, hasToken));
|
|
112
|
+
candidates.sort(rank(pool, family, false));
|
|
113
|
+
return candidates[0]?.name || null;
|
|
114
|
+
}
|
|
115
|
+
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { PRODUCT, globalHome } from '../identity.mjs';
|
|
4
|
+
import { withStateLock, replaceFile } from '../state-lock.mjs';
|
|
5
|
+
|
|
6
|
+
export const POOL_SCHEMA = 1;
|
|
7
|
+
export const RESERVED_NAMES = ['live', 'auto'];
|
|
8
|
+
const NAME_RE = /^[a-z0-9][a-z0-9._-]{0,31}$/;
|
|
9
|
+
export const LOAN_BACKUP_NAME = '.antigravity-oauth-token.agy-crew.loan-backup';
|
|
10
|
+
|
|
11
|
+
export class PoolError extends Error {}
|
|
12
|
+
|
|
13
|
+
export function validateAccountName(name) {
|
|
14
|
+
if (!NAME_RE.test(String(name || ''))) {
|
|
15
|
+
throw new PoolError(`invalid account name "${name}": use 1-32 chars of a-z 0-9 . _ - starting with a letter or digit`);
|
|
16
|
+
}
|
|
17
|
+
if (RESERVED_NAMES.includes(name)) throw new PoolError(`"${name}" is a reserved account name`);
|
|
18
|
+
return name;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function poolPaths() {
|
|
22
|
+
const root = globalHome();
|
|
23
|
+
return { root, file: path.join(root, 'pool.json'), lock: path.join(root, 'pool.json.lock'),
|
|
24
|
+
accountsDir: path.join(root, 'accounts'), enrollDir: path.join(root, 'enroll') };
|
|
25
|
+
}
|
|
26
|
+
export const accountDir = (name) => path.join(poolPaths().accountsDir, name);
|
|
27
|
+
export const accountHome = (name) => path.join(accountDir(name), 'home');
|
|
28
|
+
export const tokenPath = (home) => path.join(home, '.gemini', 'antigravity-cli', 'antigravity-oauth-token');
|
|
29
|
+
export const accountSettingsPath = (home) => path.join(home, '.gemini', 'antigravity-cli', 'settings.json');
|
|
30
|
+
|
|
31
|
+
export const emptyPool = () => ({ schema: POOL_SCHEMA, accounts: [], cooldowns: {}, leases: [], conversations: {}, loans: [], quota: {} });
|
|
32
|
+
|
|
33
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
34
|
+
|
|
35
|
+
export function poolExists() {
|
|
36
|
+
return fs.existsSync(poolPaths().file);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Missing file = empty pool. A corrupt or newer-schema file is an error and is
|
|
40
|
+
* never reset: the pool holds the only record of conversation bindings. */
|
|
41
|
+
export function loadPool() {
|
|
42
|
+
const { file } = poolPaths();
|
|
43
|
+
let raw;
|
|
44
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
45
|
+
if (error.code === 'ENOENT') return emptyPool();
|
|
46
|
+
throw new PoolError(`cannot read ${file}: ${error.message}`);
|
|
47
|
+
}
|
|
48
|
+
let pool;
|
|
49
|
+
try { pool = JSON.parse(raw); } catch { throw new PoolError(`pool.json is corrupt: ${file} - fix or delete it, then retry`); }
|
|
50
|
+
if (!pool || typeof pool !== 'object') throw new PoolError(`pool.json is corrupt: ${file} - fix or delete it, then retry`);
|
|
51
|
+
if (pool.schema > POOL_SCHEMA) throw new PoolError(`pool.json was written by a newer ${PRODUCT} (schema ${pool.schema}); upgrade before using the account pool: ${file}`);
|
|
52
|
+
return { ...emptyPool(), ...pool };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function savePool(pool) {
|
|
56
|
+
const { file } = poolPaths();
|
|
57
|
+
const tmp = `${file}.tmp-${process.pid}`;
|
|
58
|
+
fs.writeFileSync(tmp, JSON.stringify(pool, null, 2) + '\n');
|
|
59
|
+
replaceFile(tmp, file);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const copyAtomic = (from, to) => {
|
|
63
|
+
const tmp = `${to}.tmp-${process.pid}`;
|
|
64
|
+
fs.copyFileSync(from, tmp);
|
|
65
|
+
replaceFile(tmp, to);
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
/** Undo a credential loan's file swap: the borrower's current token (agy may
|
|
69
|
+
* have refreshed the lender's credential in it) goes back to the lender, the
|
|
70
|
+
* borrower's own token returns from its backup. Idempotent. */
|
|
71
|
+
export function restoreLoanFiles(loan) {
|
|
72
|
+
const tb = tokenPath(accountHome(loan.borrower));
|
|
73
|
+
const tl = tokenPath(accountHome(loan.lender));
|
|
74
|
+
const backup = path.join(path.dirname(tb), LOAN_BACKUP_NAME);
|
|
75
|
+
if (fs.existsSync(tb) && fs.existsSync(path.dirname(tl))) copyAtomic(tb, tl);
|
|
76
|
+
fs.rmSync(tb, { force: true });
|
|
77
|
+
if (loan.host_had_token && fs.existsSync(backup)) replaceFile(backup, tb);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Drop what belongs to dead processes. Runs at the start of every locked
|
|
81
|
+
* update so a crashed run never keeps an account busy or a token swapped. */
|
|
82
|
+
export function reap(pool, now = Date.now()) {
|
|
83
|
+
pool.leases = pool.leases.filter((l) => alive(l.pid));
|
|
84
|
+
const keep = [];
|
|
85
|
+
for (const loan of pool.loans) {
|
|
86
|
+
if (alive(loan.pid)) { keep.push(loan); continue; }
|
|
87
|
+
try { restoreLoanFiles(loan); } catch { keep.push(loan); continue; } // retry on the next update
|
|
88
|
+
pool.leases = pool.leases.filter((l) => l.loan_id !== loan.id);
|
|
89
|
+
}
|
|
90
|
+
pool.loans = keep;
|
|
91
|
+
for (const [key, c] of Object.entries(pool.cooldowns)) if (Date.parse(c.retry_at) <= now) delete pool.cooldowns[key];
|
|
92
|
+
return pool;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function updatePool(change) {
|
|
96
|
+
const { root, lock } = poolPaths();
|
|
97
|
+
fs.mkdirSync(root, { recursive: true });
|
|
98
|
+
return withStateLock(lock, () => {
|
|
99
|
+
const pool = reap(loadPool());
|
|
100
|
+
const value = change(pool);
|
|
101
|
+
savePool(pool);
|
|
102
|
+
return value;
|
|
103
|
+
});
|
|
104
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { tokenPath } from './state.mjs';
|
|
3
|
+
|
|
4
|
+
/** The email claim of an unverified JWT (we only label accounts with it). */
|
|
5
|
+
function jwtEmail(jwt) {
|
|
6
|
+
try {
|
|
7
|
+
const part = String(jwt).split('.')[1];
|
|
8
|
+
const claims = JSON.parse(Buffer.from(part, 'base64url').toString('utf8'));
|
|
9
|
+
return typeof claims.email === 'string' ? claims.email : null;
|
|
10
|
+
} catch { return null; }
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Read a file-backed agy token: access token, expiry (ms) and account email.
|
|
14
|
+
* null when the file is absent or unreadable. */
|
|
15
|
+
export function readToken(home) {
|
|
16
|
+
let json;
|
|
17
|
+
try { json = JSON.parse(fs.readFileSync(tokenPath(home), 'utf8')); } catch { return null; }
|
|
18
|
+
const t = json.token && typeof json.token === 'object' ? json.token : {};
|
|
19
|
+
const expiry = typeof t.expiry === 'number' ? (t.expiry < 1e12 ? t.expiry * 1000 : t.expiry) : Date.parse(t.expiry);
|
|
20
|
+
return {
|
|
21
|
+
access_token: t.access_token || null,
|
|
22
|
+
expiry_ms: Number.isFinite(expiry) ? expiry : null,
|
|
23
|
+
email: jwtEmail(t.id_token) || jwtEmail(json.id_token),
|
|
24
|
+
};
|
|
25
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
const pause = () => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 10);
|
|
6
|
+
|
|
7
|
+
const TRANSIENT = ['EPERM', 'EBUSY', 'EACCES'];
|
|
8
|
+
|
|
9
|
+
/** Atomic replace that survives Windows sharing violations. Replacing a file
|
|
10
|
+
* another process currently has open for reading fails there with EPERM or
|
|
11
|
+
* EBUSY; readers poll state and progress files every few hundred ms, so
|
|
12
|
+
* retry briefly before giving up. POSIX never takes the retry path. */
|
|
13
|
+
export function replaceFile(tmp, file) {
|
|
14
|
+
for (let attempt = 0; ; attempt++) {
|
|
15
|
+
try { fs.renameSync(tmp, file); return; } catch (error) {
|
|
16
|
+
if (!TRANSIENT.includes(error.code) || attempt >= 50) throw error;
|
|
17
|
+
pause();
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** Read a file that a concurrent writer may be replacing right now. ENOENT is
|
|
23
|
+
* returned to the caller; transient Windows sharing errors are retried. */
|
|
24
|
+
export function readTextRetry(file) {
|
|
25
|
+
for (let attempt = 0; ; attempt++) {
|
|
26
|
+
try { return fs.readFileSync(file, 'utf8'); } catch (error) {
|
|
27
|
+
if (!TRANSIENT.includes(error.code) || attempt >= 50) throw error;
|
|
28
|
+
pause();
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch (error) { return error.code === 'EPERM'; } };
|
|
33
|
+
|
|
34
|
+
// Publish a nonempty directory atomically. Reapers remove only the unique
|
|
35
|
+
// marker they inspected, then rmdir (never recursively delete a successor).
|
|
36
|
+
export function withStateLock(lock, change) {
|
|
37
|
+
const owner = `owner-${process.pid}-${randomUUID()}`;
|
|
38
|
+
const candidate = `${lock}.${owner}`;
|
|
39
|
+
fs.mkdirSync(candidate);
|
|
40
|
+
fs.writeFileSync(path.join(candidate, owner), '');
|
|
41
|
+
const start = Date.now();
|
|
42
|
+
let acquired = false;
|
|
43
|
+
let lastError = null;
|
|
44
|
+
try {
|
|
45
|
+
for (;;) {
|
|
46
|
+
try { fs.renameSync(candidate, lock); acquired = true; break; } catch (error) {
|
|
47
|
+
// Windows reports EPERM (not EEXIST/ENOTEMPTY) when renaming a
|
|
48
|
+
// non-empty directory onto an existing one, and EBUSY while a
|
|
49
|
+
// scanner holds it. Both are transient here: the holder releases
|
|
50
|
+
// the lock momentarily, so retry instead of failing the job.
|
|
51
|
+
if (!['EEXIST', 'ENOTEMPTY', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error;
|
|
52
|
+
lastError = error;
|
|
53
|
+
}
|
|
54
|
+
let entries = [];
|
|
55
|
+
// Windows also reports EPERM/EBUSY/EACCES while another process is renaming
|
|
56
|
+
// or removing the lock directory; treat those like a busy lock and retry.
|
|
57
|
+
try { entries = fs.readdirSync(lock); } catch (error) {
|
|
58
|
+
if (!['ENOENT', 'ENOTDIR', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error;
|
|
59
|
+
if (error.code !== 'ENOENT') lastError = error;
|
|
60
|
+
}
|
|
61
|
+
if (entries.length === 1) {
|
|
62
|
+
const match = /^owner-(\d+)-[a-f0-9-]+$/.exec(entries[0]);
|
|
63
|
+
if (match && Number(match[1]) > 1 && !alive(Number(match[1]))) {
|
|
64
|
+
try {
|
|
65
|
+
fs.unlinkSync(path.join(lock, entries[0]));
|
|
66
|
+
fs.rmdirSync(lock);
|
|
67
|
+
} catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error; }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
if (Date.now() - start > 10000) {
|
|
71
|
+
const detail = lastError?.code ? ` (last error: ${lastError.code})` : '';
|
|
72
|
+
throw new Error(`Timed out acquiring job state lock${detail}: ${lock}. If no companion process is running, inspect and remove the stale lock.`);
|
|
73
|
+
}
|
|
74
|
+
pause();
|
|
75
|
+
}
|
|
76
|
+
return change();
|
|
77
|
+
} finally {
|
|
78
|
+
if (acquired) {
|
|
79
|
+
const marker = path.join(lock, owner);
|
|
80
|
+
// A concurrent reaper may already have removed our marker (it judges the
|
|
81
|
+
// holder dead once the dispatch process exits, resulting in ENOENT).
|
|
82
|
+
// On Windows, open handles or scanners can cause transient EPERM/EBUSY/EACCES;
|
|
83
|
+
// retry briefly before giving up, and only ignore if the marker no longer exists.
|
|
84
|
+
for (let attempt = 0; attempt < 5; attempt++) {
|
|
85
|
+
try {
|
|
86
|
+
fs.unlinkSync(marker);
|
|
87
|
+
break;
|
|
88
|
+
} catch (error) {
|
|
89
|
+
if (error.code === 'ENOENT') break;
|
|
90
|
+
if (['EPERM', 'EBUSY', 'EACCES'].includes(error.code)) {
|
|
91
|
+
if (attempt < 4) {
|
|
92
|
+
pause();
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (!fs.existsSync(marker)) break;
|
|
96
|
+
}
|
|
97
|
+
throw error;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
try { fs.rmdirSync(lock); } catch (error) { if (!['ENOENT', 'ENOTEMPTY', 'EEXIST', 'EPERM', 'EBUSY', 'EACCES'].includes(error.code)) throw error; }
|
|
101
|
+
} else fs.rmSync(candidate, { recursive: true, force: true });
|
|
102
|
+
}
|
|
103
|
+
}
|