@webpresso/agent-kit 3.2.0 → 3.3.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/bin/_run.js +4 -1
- package/dist/esm/audit/blueprint-trust.js +14 -10
- package/dist/esm/audit/changeset-evidence.d.ts +23 -0
- package/dist/esm/audit/changeset-evidence.js +30 -0
- package/dist/esm/audit/changeset-required.d.ts +10 -0
- package/dist/esm/audit/changeset-required.js +73 -0
- package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
- package/dist/esm/audit/ci-guardrails-detection.js +68 -0
- package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
- package/dist/esm/audit/registry.d.ts +4 -4
- package/dist/esm/audit/registry.js +6 -1
- package/dist/esm/blueprint/core/parser.js +61 -4
- package/dist/esm/blueprint/core/schema.d.ts +1 -0
- package/dist/esm/blueprint/core/schema.js +26 -0
- package/dist/esm/blueprint/lifecycle/audit.js +22 -10
- package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
- package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
- package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
- package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
- package/dist/esm/blueprint/sync/client.d.ts +2 -1
- package/dist/esm/blueprint/sync/client.js +1 -1
- package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
- package/dist/esm/blueprint/trust/dossier.js +40 -6
- package/dist/esm/blueprint/trust/promotion.js +12 -0
- package/dist/esm/build/cli-mcp-parity.js +5 -0
- package/dist/esm/cli/cli.d.ts +1 -1
- package/dist/esm/cli/cli.js +9 -0
- package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
- package/dist/esm/cli/commands/agent-launch.js +31 -20
- package/dist/esm/cli/commands/audit.js +4 -0
- package/dist/esm/cli/commands/ci-preflight.js +24 -3
- package/dist/esm/cli/commands/init/convergence-apply.d.ts +64 -0
- package/dist/esm/cli/commands/init/convergence-apply.js +127 -23
- package/dist/esm/cli/commands/init/index.d.ts +2 -0
- package/dist/esm/cli/commands/init/index.js +2 -2
- package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
- package/dist/esm/cli/commands/init/package-root.js +10 -2
- package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
- package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
- package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
- package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
- package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
- package/dist/esm/cli/commands/opencode-probe.d.ts +107 -0
- package/dist/esm/cli/commands/opencode-probe.js +323 -0
- package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
- package/dist/esm/cli/commands/opencode-rotate.js +23 -11
- package/dist/esm/cli/commands/quality-runner.js +3 -3
- package/dist/esm/cli/commands/review.js +27 -5
- package/dist/esm/cli/commands/rust-check.d.ts +24 -0
- package/dist/esm/cli/commands/rust-check.js +110 -0
- package/dist/esm/cli/commands/typecheck.d.ts +1 -0
- package/dist/esm/cli/commands/typecheck.js +30 -1
- package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +6 -0
- package/dist/esm/cli/commands/worktree/router-dispatch.js +113 -1
- package/dist/esm/cli/commands/worktree/router.js +10 -0
- package/dist/esm/hooks/doctor.d.ts +2 -0
- package/dist/esm/hooks/doctor.js +37 -1
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
- package/dist/esm/hooks/shared/types.js +27 -1
- package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
- package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
- package/dist/esm/mcp/tools/audit.d.ts +2 -1
- package/dist/esm/mcp/tools/audit.js +12 -0
- package/dist/esm/mcp/tools/audits.d.ts +1 -0
- package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
- package/dist/esm/package.json +2 -0
- package/dist/esm/platform/client.d.ts +7 -1
- package/dist/esm/platform/github-contract-source.d.ts +2 -1
- package/dist/esm/pr-description/contract.js +24 -4
- package/dist/esm/review/authority.js +14 -9
- package/dist/esm/review/availability.d.ts +11 -0
- package/dist/esm/review/availability.js +18 -3
- package/dist/esm/review/execution/adapters.d.ts +11 -1
- package/dist/esm/review/execution/adapters.js +10 -1
- package/dist/esm/review/execution/artifacts.d.ts +1 -1
- package/dist/esm/review/execution/review-checkout.js +35 -16
- package/dist/esm/review/execution/supervisor.js +31 -4
- package/dist/esm/review/execution/types.d.ts +1 -0
- package/dist/esm/review/subject.d.ts +13 -0
- package/dist/esm/review/subject.js +42 -4
- package/dist/esm/rust/affected-manifests.d.ts +38 -0
- package/dist/esm/rust/affected-manifests.js +69 -0
- package/dist/esm/session-memory/fetch-index.d.ts +1 -1
- package/dist/esm/status/snapshot.js +2 -6
- package/dist/esm/test-helpers/global-setup.d.ts +0 -1
- package/dist/esm/test-helpers/global-setup.js +18 -15
- package/dist/esm/typecheck/planner.d.ts +9 -1
- package/dist/esm/typecheck/planner.js +31 -0
- package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
- package/dist/esm/worktrees/orphan-scan.js +211 -0
- package/package.json +14 -12
|
@@ -1,35 +1,207 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { existsSync, lstatSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
|
|
2
3
|
import { dirname } from "node:path";
|
|
3
|
-
import
|
|
4
|
+
import { isProcessAlive } from "#cli/auto-update/install-lock.js";
|
|
4
5
|
import { writeFileAtomicStrict } from "#shared-utils/write-json-file.js";
|
|
5
6
|
const LOCK_TIMEOUT_MS = 5_000;
|
|
6
7
|
const LOCK_POLL_MS = 25;
|
|
7
8
|
const LOCK_STALE_MS = 30_000;
|
|
9
|
+
/**
|
|
10
|
+
* Hard-age backstop: past this, a stale lock is reclaimed regardless of owner
|
|
11
|
+
* liveness. Prevents a recycled pid — or a `kill(pid,0)` that fails safe to
|
|
12
|
+
* "alive" (EPERM / unknown / cross-PID-namespace) — from wedging the lock
|
|
13
|
+
* forever. Mirrors `isInstallLockActive`, which likewise pairs liveness with an
|
|
14
|
+
* age bound rather than trusting liveness alone. Also serves as the reaper's own
|
|
15
|
+
* stale bound. Not a new deadline: it replaces the prior reaper-stale constant.
|
|
16
|
+
*/
|
|
17
|
+
const LOCK_STALE_HARD_MS = 5 * 60_000;
|
|
18
|
+
const LOCK_OWNER_FILE = "owner";
|
|
8
19
|
function sleepSync(ms) {
|
|
9
20
|
Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
|
|
10
21
|
}
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
22
|
+
function isErrno(error, code) {
|
|
23
|
+
return error instanceof Error && "code" in error && error.code === code;
|
|
24
|
+
}
|
|
25
|
+
/** Directory age in ms, or `undefined` when the directory is gone (ENOENT). */
|
|
26
|
+
function directoryAgeMs(path, now) {
|
|
27
|
+
try {
|
|
28
|
+
return now - statSync(path).mtime.getTime();
|
|
29
|
+
}
|
|
30
|
+
catch (error) {
|
|
31
|
+
if (isErrno(error, "ENOENT"))
|
|
32
|
+
return undefined;
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* Read the lock's owner record. The token is the full `"<pid>:<uuid>"` string
|
|
38
|
+
* (identical to what release compares); `pid` is its leading integer. Returns
|
|
39
|
+
* `undefined` for a missing/empty/unparseable owner — i.e. a crash that never
|
|
40
|
+
* finished writing the record, which is reclaimable once age-stale.
|
|
41
|
+
*/
|
|
42
|
+
function readLockOwner(lockPath) {
|
|
43
|
+
let token;
|
|
44
|
+
try {
|
|
45
|
+
token = readFileSync(`${lockPath}/${LOCK_OWNER_FILE}`, "utf8");
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
const separator = token.indexOf(":");
|
|
51
|
+
if (separator <= 0)
|
|
52
|
+
return undefined;
|
|
53
|
+
const pid = Number(token.slice(0, separator));
|
|
54
|
+
if (!Number.isInteger(pid) || pid <= 0)
|
|
55
|
+
return undefined;
|
|
56
|
+
return { pid, token };
|
|
57
|
+
}
|
|
58
|
+
function tryAcquireDirectory(path) {
|
|
59
|
+
try {
|
|
60
|
+
mkdirSync(path);
|
|
61
|
+
return true;
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (isErrno(error, "EEXIST"))
|
|
65
|
+
return false;
|
|
66
|
+
throw error;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
/**
|
|
70
|
+
* Serialize stale reclaim across contenders. A crashed reaper holder is itself
|
|
71
|
+
* reclaimed race-free: exactly one waiter's atomic rename of the reaper carcass
|
|
72
|
+
* succeeds; losers observe ENOENT and retry the mkdir. This is what stops two
|
|
73
|
+
* reclaimers from deleting each other's fresh lock — the original lost-update.
|
|
74
|
+
*
|
|
75
|
+
* Release is owner-token-checked, exactly like the main lock: if a paused reaper
|
|
76
|
+
* holder is stolen and replaced while it sleeps, its release sees a mismatched
|
|
77
|
+
* owner token and no-ops, so it cannot delete the successor's reaper and let a
|
|
78
|
+
* third reclaimer in behind it.
|
|
79
|
+
*/
|
|
80
|
+
function acquireReaperLock(path, deadline, now) {
|
|
81
|
+
const ownerPath = `${path}/${LOCK_OWNER_FILE}`;
|
|
82
|
+
const ownerToken = `${process.pid}:${randomUUID()}`;
|
|
83
|
+
while (!tryAcquireDirectory(path)) {
|
|
84
|
+
const ageMs = directoryAgeMs(path, now());
|
|
85
|
+
if (ageMs !== undefined && ageMs >= LOCK_STALE_HARD_MS) {
|
|
86
|
+
const carcass = `${path}.stale-${process.pid}-${randomUUID()}`;
|
|
87
|
+
try {
|
|
88
|
+
renameSync(path, carcass);
|
|
89
|
+
rmSync(carcass, { recursive: true, force: true });
|
|
90
|
+
}
|
|
91
|
+
catch (error) {
|
|
92
|
+
// A concurrent waiter won the atomic rename; retry the mkdir.
|
|
93
|
+
if (!isErrno(error, "ENOENT"))
|
|
94
|
+
throw error;
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
if (now() >= deadline) {
|
|
99
|
+
throw new Error(`Timed out waiting for stale host config lock reaper: ${path}`);
|
|
100
|
+
}
|
|
101
|
+
sleepSync(LOCK_POLL_MS);
|
|
102
|
+
}
|
|
103
|
+
try {
|
|
104
|
+
writeFileSync(ownerPath, ownerToken, "utf8");
|
|
105
|
+
}
|
|
106
|
+
catch (error) {
|
|
107
|
+
// Mirror the main lock: don't leave a reaper others must wait out on an
|
|
108
|
+
// owner-write failure.
|
|
109
|
+
rmSync(path, { recursive: true, force: true });
|
|
110
|
+
throw error;
|
|
111
|
+
}
|
|
112
|
+
return () => {
|
|
17
113
|
try {
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
update: LOCK_STALE_MS / 2,
|
|
22
|
-
});
|
|
114
|
+
if (readFileSync(ownerPath, "utf8") !== ownerToken)
|
|
115
|
+
return;
|
|
116
|
+
rmSync(path, { recursive: true, force: true });
|
|
23
117
|
}
|
|
24
118
|
catch (error) {
|
|
25
|
-
if (error
|
|
119
|
+
if (!isErrno(error, "ENOENT"))
|
|
120
|
+
throw error;
|
|
121
|
+
}
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Remove a stale lock while holding the reaper, so at most one reclaimer acts at
|
|
126
|
+
* a time. Re-checks under the reaper: only a lock that is still age-stale AND
|
|
127
|
+
* whose owner is absent, dead, or past the hard-age bound is deleted. A live
|
|
128
|
+
* owner within the hard-age window is never reclaimed, so a slow-but-alive
|
|
129
|
+
* writer cannot lose its lock to a successor.
|
|
130
|
+
*/
|
|
131
|
+
function reclaimStaleLock(lockPath, reaperPath, deadline, now, isAlive, seams) {
|
|
132
|
+
const releaseReaper = acquireReaperLock(reaperPath, deadline, now);
|
|
133
|
+
try {
|
|
134
|
+
const ageMs = directoryAgeMs(lockPath, now());
|
|
135
|
+
if (ageMs === undefined || ageMs < LOCK_STALE_MS)
|
|
136
|
+
return;
|
|
137
|
+
const owner = readLockOwner(lockPath);
|
|
138
|
+
// Reclaim an absent owner record (a crash before the owner write) or a dead
|
|
139
|
+
// owner at the soft-stale age — recovering an abandoned lock fast is the whole
|
|
140
|
+
// point. A live owner waits for the hard-age backstop. The residual: a writer
|
|
141
|
+
// OS-suspended for 30s inside the two-statement mkdir/owner-write gap could be
|
|
142
|
+
// reclaimed and overlap; that window is a strict subset of the documented
|
|
143
|
+
// advisory-lock boundary (a suspended holder past the reclaim age), so it is
|
|
144
|
+
// accepted, not silently ignored.
|
|
145
|
+
const reclaimable = owner === undefined || !isAlive(owner.pid) || ageMs >= LOCK_STALE_HARD_MS;
|
|
146
|
+
if (!reclaimable)
|
|
147
|
+
return;
|
|
148
|
+
seams?.onBeforeReclaimDelete?.();
|
|
149
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
releaseReaper();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
function acquireScaffolderFileLock(path, seams) {
|
|
156
|
+
const lockPath = `${path}.lock`;
|
|
157
|
+
const reaperPath = `${lockPath}.reaper`;
|
|
158
|
+
const ownerToken = `${process.pid}:${randomUUID()}`;
|
|
159
|
+
const ownerPath = `${lockPath}/${LOCK_OWNER_FILE}`;
|
|
160
|
+
const now = seams?.now ?? Date.now;
|
|
161
|
+
const isAlive = seams?.isAlive ?? isProcessAlive;
|
|
162
|
+
const deadline = now() + LOCK_TIMEOUT_MS;
|
|
163
|
+
for (;;) {
|
|
164
|
+
if (tryAcquireDirectory(lockPath)) {
|
|
165
|
+
try {
|
|
166
|
+
writeFileSync(ownerPath, ownerToken, "utf8");
|
|
167
|
+
}
|
|
168
|
+
catch (error) {
|
|
169
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
26
170
|
throw error;
|
|
27
|
-
if (Date.now() - startedAt >= LOCK_TIMEOUT_MS) {
|
|
28
|
-
throw new Error(`Timed out waiting for host config lock: ${path}`, { cause: error });
|
|
29
171
|
}
|
|
30
|
-
|
|
172
|
+
return () => {
|
|
173
|
+
try {
|
|
174
|
+
if (readFileSync(ownerPath, "utf8") !== ownerToken)
|
|
175
|
+
return;
|
|
176
|
+
rmSync(lockPath, { recursive: true, force: true });
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
if (!isErrno(error, "ENOENT"))
|
|
180
|
+
throw error;
|
|
181
|
+
}
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
const ageMs = directoryAgeMs(lockPath, now());
|
|
185
|
+
if (ageMs !== undefined && ageMs >= LOCK_STALE_MS) {
|
|
186
|
+
// Reclaim may be a no-op (owner still alive within the hard-age window);
|
|
187
|
+
// fall through to the deadline check so a live holder can't spin us
|
|
188
|
+
// forever, then retry the mkdir on the next iteration.
|
|
189
|
+
reclaimStaleLock(lockPath, reaperPath, deadline, now, isAlive, seams);
|
|
31
190
|
}
|
|
191
|
+
if (now() >= deadline) {
|
|
192
|
+
throw new Error(`Timed out waiting for host config lock: ${path}`);
|
|
193
|
+
}
|
|
194
|
+
sleepSync(LOCK_POLL_MS);
|
|
32
195
|
}
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Serialize a complete read/modify/write transaction for a host config file.
|
|
199
|
+
* These are local single-machine home-config locks (`~/.codex`, `~/.grok`), so
|
|
200
|
+
* owner-pid liveness is meaningful; this is not a shared-filesystem lock.
|
|
201
|
+
*/
|
|
202
|
+
export function withScaffolderFileLock(path, action, seams) {
|
|
203
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
204
|
+
const release = acquireScaffolderFileLock(path, seams);
|
|
33
205
|
try {
|
|
34
206
|
return action();
|
|
35
207
|
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { type OpencodeAccountAvailabilityEvent } from "#review/availability.js";
|
|
2
|
+
import type { ReviewLimitWindow } from "#review/execution/types.js";
|
|
3
|
+
import { type OpencodeAccount, type OpencodeAccountSelection } from "#review/opencode-account.js";
|
|
4
|
+
/** Set to `1`/`true` to launch without probing account health at all. */
|
|
5
|
+
export declare const OPENCODE_SKIP_PROBE_ENV = "WP_OPENCODE_SKIP_PROBE";
|
|
6
|
+
/**
|
|
7
|
+
* Wall-clock bound for a single account probe.
|
|
8
|
+
*
|
|
9
|
+
* Measured live on 2026-07-24 against three real OpenCode Go accounts
|
|
10
|
+
* (`opencode` 1.18.4, model `opencode-go/deepseek-v4-flash`, trivial prompt):
|
|
11
|
+
*
|
|
12
|
+
* | run | capped `a` | capped `o` | healthy `z` |
|
|
13
|
+
* | --- | --------------- | --------------- | -------------------------- |
|
|
14
|
+
* | repo cwd, default tools | 6.0s to first limit line | ~10s | 50.9s to completion |
|
|
15
|
+
* | temp cwd, default tools | 11.6s | 20.7s | 28.2s to first assistant text |
|
|
16
|
+
* | temp cwd, no-tools agent | 13.9s | 11.8s | 18.6s to first assistant text |
|
|
17
|
+
*
|
|
18
|
+
* `opencode --version` alone costs ~5.7s on the same machine, so roughly a third
|
|
19
|
+
* of the floor is binary startup and cannot be optimised away here. The probe
|
|
20
|
+
* therefore runs in a fresh temp cwd (a repo cwd made opencode snapshot the
|
|
21
|
+
* worktree, which alone cost ~23s) with a no-tools agent (which shrinks the
|
|
22
|
+
* system prompt and saved a further ~10s).
|
|
23
|
+
*
|
|
24
|
+
* 45s is ~2.4x the slowest observed healthy completion (18.6s) and ~2.2x the
|
|
25
|
+
* slowest observed limit detection (20.7s). The headroom covers a cold provider
|
|
26
|
+
* catalog fetch on a slow network; it is not a workaround for a slow probe.
|
|
27
|
+
* Hitting it is not a failure to be silenced by raising the bound — it degrades
|
|
28
|
+
* to `inconclusive` and the launch proceeds on the already-selected account.
|
|
29
|
+
*
|
|
30
|
+
* A capped account is detected by *streaming* the output rather than waiting for
|
|
31
|
+
* exit: opencode retries a usage-limit stream error and does not exit on it, so
|
|
32
|
+
* a blocking `spawnSync` sat at the full 60s bound in every measured run even
|
|
33
|
+
* though the limit was already on stderr after ~6s.
|
|
34
|
+
*/
|
|
35
|
+
export declare const OPENCODE_PROBE_TIMEOUT_MS = 45000;
|
|
36
|
+
/**
|
|
37
|
+
* How long a successful probe is trusted before the account is probed again.
|
|
38
|
+
*
|
|
39
|
+
* A probe costs 12-28s (see `OPENCODE_PROBE_TIMEOUT_MS`), so probing a healthy
|
|
40
|
+
* account on every launch would make `wp opencode` permanently ~19s slower. A
|
|
41
|
+
* *capped* account never needs a re-probe — its cooldown record already
|
|
42
|
+
* suppresses it for the real reset window — so only the healthy verdict is
|
|
43
|
+
* memoised.
|
|
44
|
+
*
|
|
45
|
+
* Measured end to end on 2026-07-24 with three real accounts (two capped):
|
|
46
|
+
* a cold launch that rotated twice took 40.6s; the immediately following launch
|
|
47
|
+
* took 476ms, because the two capped accounts were suppressed by their recorded
|
|
48
|
+
* windows and the healthy one was covered by this marker.
|
|
49
|
+
*/
|
|
50
|
+
export declare const OPENCODE_PROBE_HEALTHY_TTL_MS: number;
|
|
51
|
+
export type OpencodeProbeResult =
|
|
52
|
+
/** The account answered, so it has budget. */
|
|
53
|
+
{
|
|
54
|
+
readonly kind: "healthy";
|
|
55
|
+
}
|
|
56
|
+
/** The provider reported a usage limit; `retryAfterMs` is already defaulted. */
|
|
57
|
+
| {
|
|
58
|
+
readonly kind: "limited";
|
|
59
|
+
readonly limitWindow: ReviewLimitWindow;
|
|
60
|
+
readonly retryAfterMs: number;
|
|
61
|
+
readonly windowParsed: boolean;
|
|
62
|
+
}
|
|
63
|
+
/** Nothing could be concluded; the caller must launch on this account anyway. */
|
|
64
|
+
| {
|
|
65
|
+
readonly kind: "inconclusive";
|
|
66
|
+
readonly reason: string;
|
|
67
|
+
};
|
|
68
|
+
export interface OpencodeProbeRunnerInput {
|
|
69
|
+
readonly dataDir: string;
|
|
70
|
+
readonly timeoutMs: number;
|
|
71
|
+
}
|
|
72
|
+
export type OpencodeProbeRunner = (input: OpencodeProbeRunnerInput) => Promise<OpencodeProbeResult>;
|
|
73
|
+
export declare function isOpencodeProbeEnabled(env?: NodeJS.ProcessEnv): boolean;
|
|
74
|
+
export interface ProbeOpencodeAccountDeps {
|
|
75
|
+
readonly run?: OpencodeProbeRunner;
|
|
76
|
+
readonly materialize?: (key: string, dataDir: string) => void;
|
|
77
|
+
readonly now?: () => number;
|
|
78
|
+
readonly timeoutMs?: number;
|
|
79
|
+
readonly healthyTtlMs?: number;
|
|
80
|
+
}
|
|
81
|
+
/**
|
|
82
|
+
* Probe one account's OpenCode Go credential, memoising a healthy verdict.
|
|
83
|
+
*
|
|
84
|
+
* Never throws: every failure path resolves to `inconclusive` so a credential
|
|
85
|
+
* check can never stop the TUI from opening.
|
|
86
|
+
*/
|
|
87
|
+
export declare function probeOpencodeAccount(account: OpencodeAccount, deps?: ProbeOpencodeAccountDeps): Promise<OpencodeProbeResult>;
|
|
88
|
+
export interface OpencodeProbeRotationDeps {
|
|
89
|
+
readonly probe?: (account: OpencodeAccount) => Promise<OpencodeProbeResult>;
|
|
90
|
+
readonly recordCooldown?: (canonicalId: string, event: OpencodeAccountAvailabilityEvent) => Promise<unknown>;
|
|
91
|
+
readonly selectFrom?: (accounts: readonly OpencodeAccount[], excludedIds: ReadonlySet<string>) => OpencodeAccountSelection | undefined;
|
|
92
|
+
readonly env?: NodeJS.ProcessEnv;
|
|
93
|
+
}
|
|
94
|
+
export interface OpencodeProbeRotation {
|
|
95
|
+
readonly selection: OpencodeAccountSelection;
|
|
96
|
+
/** Human-readable lines describing what the probe did, in print order. */
|
|
97
|
+
readonly lines: readonly string[];
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Probe the selected account and rotate past capped ones, bounded by the number
|
|
101
|
+
* of configured accounts.
|
|
102
|
+
*
|
|
103
|
+
* Returns the account to launch on plus the lines to print. Never throws and
|
|
104
|
+
* never returns nothing: an inconclusive probe keeps the incoming selection, so
|
|
105
|
+
* the worst case is exactly today's behaviour plus one log line.
|
|
106
|
+
*/
|
|
107
|
+
export declare function resolveHealthyOpencodeAccount(accounts: readonly OpencodeAccount[], initial: OpencodeAccountSelection, deps?: OpencodeProbeRotationDeps): Promise<OpencodeProbeRotation>;
|
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { formatOpencodeResetWait, OPENCODE_UNKNOWN_LIMIT_COOLDOWN_MS, } from "#cli/commands/opencode-rotate.js";
|
|
6
|
+
import { loadOpencodeAccountAvailability, pruneExpiredOpencodeAccountSuppressions, recordOpencodeAccountAvailabilityWithLock, } from "#review/availability.js";
|
|
7
|
+
import { opencodeUsageLimit } from "#review/execution/adapters.js";
|
|
8
|
+
import { opencodeAccountCanonicalId, opencodeAccountDataDir, selectActiveOpencodeAccount, } from "#review/opencode-account.js";
|
|
9
|
+
import { materializeOpencodeGoAuth } from "#review/opencode-auth.js";
|
|
10
|
+
/** Set to `1`/`true` to launch without probing account health at all. */
|
|
11
|
+
export const OPENCODE_SKIP_PROBE_ENV = "WP_OPENCODE_SKIP_PROBE";
|
|
12
|
+
/**
|
|
13
|
+
* Wall-clock bound for a single account probe.
|
|
14
|
+
*
|
|
15
|
+
* Measured live on 2026-07-24 against three real OpenCode Go accounts
|
|
16
|
+
* (`opencode` 1.18.4, model `opencode-go/deepseek-v4-flash`, trivial prompt):
|
|
17
|
+
*
|
|
18
|
+
* | run | capped `a` | capped `o` | healthy `z` |
|
|
19
|
+
* | --- | --------------- | --------------- | -------------------------- |
|
|
20
|
+
* | repo cwd, default tools | 6.0s to first limit line | ~10s | 50.9s to completion |
|
|
21
|
+
* | temp cwd, default tools | 11.6s | 20.7s | 28.2s to first assistant text |
|
|
22
|
+
* | temp cwd, no-tools agent | 13.9s | 11.8s | 18.6s to first assistant text |
|
|
23
|
+
*
|
|
24
|
+
* `opencode --version` alone costs ~5.7s on the same machine, so roughly a third
|
|
25
|
+
* of the floor is binary startup and cannot be optimised away here. The probe
|
|
26
|
+
* therefore runs in a fresh temp cwd (a repo cwd made opencode snapshot the
|
|
27
|
+
* worktree, which alone cost ~23s) with a no-tools agent (which shrinks the
|
|
28
|
+
* system prompt and saved a further ~10s).
|
|
29
|
+
*
|
|
30
|
+
* 45s is ~2.4x the slowest observed healthy completion (18.6s) and ~2.2x the
|
|
31
|
+
* slowest observed limit detection (20.7s). The headroom covers a cold provider
|
|
32
|
+
* catalog fetch on a slow network; it is not a workaround for a slow probe.
|
|
33
|
+
* Hitting it is not a failure to be silenced by raising the bound — it degrades
|
|
34
|
+
* to `inconclusive` and the launch proceeds on the already-selected account.
|
|
35
|
+
*
|
|
36
|
+
* A capped account is detected by *streaming* the output rather than waiting for
|
|
37
|
+
* exit: opencode retries a usage-limit stream error and does not exit on it, so
|
|
38
|
+
* a blocking `spawnSync` sat at the full 60s bound in every measured run even
|
|
39
|
+
* though the limit was already on stderr after ~6s.
|
|
40
|
+
*/
|
|
41
|
+
export const OPENCODE_PROBE_TIMEOUT_MS = 45_000;
|
|
42
|
+
/**
|
|
43
|
+
* How long a successful probe is trusted before the account is probed again.
|
|
44
|
+
*
|
|
45
|
+
* A probe costs 12-28s (see `OPENCODE_PROBE_TIMEOUT_MS`), so probing a healthy
|
|
46
|
+
* account on every launch would make `wp opencode` permanently ~19s slower. A
|
|
47
|
+
* *capped* account never needs a re-probe — its cooldown record already
|
|
48
|
+
* suppresses it for the real reset window — so only the healthy verdict is
|
|
49
|
+
* memoised.
|
|
50
|
+
*
|
|
51
|
+
* Measured end to end on 2026-07-24 with three real accounts (two capped):
|
|
52
|
+
* a cold launch that rotated twice took 40.6s; the immediately following launch
|
|
53
|
+
* took 476ms, because the two capped accounts were suppressed by their recorded
|
|
54
|
+
* windows and the healthy one was covered by this marker.
|
|
55
|
+
*/
|
|
56
|
+
export const OPENCODE_PROBE_HEALTHY_TTL_MS = 30 * 60 * 1_000;
|
|
57
|
+
/**
|
|
58
|
+
* Cheapest OpenCode Go model at the time of writing. The gateway caps by
|
|
59
|
+
* account, not by model, so any model reveals the cap; this one is picked purely
|
|
60
|
+
* to minimise probe cost. If the id ever disappears from the catalog the probe
|
|
61
|
+
* fails as `inconclusive` and the launch proceeds unchanged, which is the same
|
|
62
|
+
* safe degradation as a spawn failure.
|
|
63
|
+
*/
|
|
64
|
+
const OPENCODE_PROBE_MODEL = "opencode-go/deepseek-v4-flash";
|
|
65
|
+
const OPENCODE_PROBE_AGENT = "wp-health-probe";
|
|
66
|
+
const OPENCODE_PROBE_PROMPT = "hi";
|
|
67
|
+
const OPENCODE_PROBE_PERMISSIONS = { "*": "deny" };
|
|
68
|
+
const OPENCODE_PROBE_CONFIG = JSON.stringify({
|
|
69
|
+
$schema: "https://opencode.ai/config.json",
|
|
70
|
+
share: "disabled",
|
|
71
|
+
permission: OPENCODE_PROBE_PERMISSIONS,
|
|
72
|
+
agent: {
|
|
73
|
+
[OPENCODE_PROBE_AGENT]: {
|
|
74
|
+
description: "Minimal OpenCode Go credential health probe.",
|
|
75
|
+
mode: "primary",
|
|
76
|
+
permission: OPENCODE_PROBE_PERMISSIONS,
|
|
77
|
+
tools: { "*": false },
|
|
78
|
+
},
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
/** Rolling capture bound: the limit text always arrives in the newest output. */
|
|
82
|
+
const MAX_CAPTURE_BYTES = 64 * 1_024;
|
|
83
|
+
/** A rendered assistant token or a finished step proves the account answered. */
|
|
84
|
+
const HEALTHY_EVENT_PATTERN = /"type"\s*:\s*"(?:text|step_finish)"/u;
|
|
85
|
+
const HEALTH_MARKER_FILE = "wp-probe-health.json";
|
|
86
|
+
export function isOpencodeProbeEnabled(env = process.env) {
|
|
87
|
+
const raw = env[OPENCODE_SKIP_PROBE_ENV]?.trim().toLowerCase();
|
|
88
|
+
return raw !== "1" && raw !== "true";
|
|
89
|
+
}
|
|
90
|
+
function healthMarkerPath(dataDir) {
|
|
91
|
+
return join(dataDir, HEALTH_MARKER_FILE);
|
|
92
|
+
}
|
|
93
|
+
function readHealthMarker(dataDir) {
|
|
94
|
+
let parsed;
|
|
95
|
+
try {
|
|
96
|
+
parsed = JSON.parse(readFileSync(healthMarkerPath(dataDir), "utf8"));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return undefined;
|
|
100
|
+
}
|
|
101
|
+
if (parsed === null || typeof parsed !== "object")
|
|
102
|
+
return undefined;
|
|
103
|
+
const okAt = parsed["okAt"];
|
|
104
|
+
return typeof okAt === "number" && Number.isFinite(okAt) ? okAt : undefined;
|
|
105
|
+
}
|
|
106
|
+
function writeHealthMarker(dataDir, atMs) {
|
|
107
|
+
try {
|
|
108
|
+
mkdirSync(dataDir, { recursive: true });
|
|
109
|
+
writeFileSync(healthMarkerPath(dataDir), JSON.stringify({ okAt: atMs }), {
|
|
110
|
+
encoding: "utf8",
|
|
111
|
+
mode: 0o600,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
// Best-effort memoisation: a missing marker only costs another probe.
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
function limitedFromEvent(message) {
|
|
119
|
+
const event = opencodeUsageLimit(message);
|
|
120
|
+
if (event === null || event.kind !== "failure")
|
|
121
|
+
return null;
|
|
122
|
+
const parsed = event.retryAfterMs;
|
|
123
|
+
return {
|
|
124
|
+
kind: "limited",
|
|
125
|
+
limitWindow: event.limitWindow ?? "unknown",
|
|
126
|
+
retryAfterMs: parsed ?? OPENCODE_UNKNOWN_LIMIT_COOLDOWN_MS,
|
|
127
|
+
windowParsed: parsed !== undefined,
|
|
128
|
+
};
|
|
129
|
+
}
|
|
130
|
+
function probeArgs() {
|
|
131
|
+
return [
|
|
132
|
+
"run",
|
|
133
|
+
"--pure",
|
|
134
|
+
"--print-logs",
|
|
135
|
+
"--log-level",
|
|
136
|
+
"ERROR",
|
|
137
|
+
"--format",
|
|
138
|
+
"json",
|
|
139
|
+
"--model",
|
|
140
|
+
OPENCODE_PROBE_MODEL,
|
|
141
|
+
"--agent",
|
|
142
|
+
OPENCODE_PROBE_AGENT,
|
|
143
|
+
OPENCODE_PROBE_PROMPT,
|
|
144
|
+
];
|
|
145
|
+
}
|
|
146
|
+
function classifyChunk(captured) {
|
|
147
|
+
return (limitedFromEvent(captured) ??
|
|
148
|
+
(HEALTHY_EVENT_PATTERN.test(captured) ? { kind: "healthy" } : null));
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* Spawn a bounded `opencode run` against one account's isolated credential
|
|
152
|
+
* store and classify the first conclusive thing it says.
|
|
153
|
+
*
|
|
154
|
+
* There is no OpenCode Go HTTP endpoint anywhere in this codebase — the review
|
|
155
|
+
* adapter also reaches the provider only by spawning this binary — so a
|
|
156
|
+
* subprocess is the only available probe vehicle. Per-account isolation is the
|
|
157
|
+
* same `XDG_DATA_HOME` trick the review adapter uses, so a probe can never race
|
|
158
|
+
* a live interactive TUI's global `auth.json`.
|
|
159
|
+
*/
|
|
160
|
+
function spawnOpencodeProbe(input) {
|
|
161
|
+
return new Promise((resolve) => {
|
|
162
|
+
let probeCwd;
|
|
163
|
+
try {
|
|
164
|
+
probeCwd = mkdtempSync(join(tmpdir(), "wp-opencode-probe-"));
|
|
165
|
+
}
|
|
166
|
+
catch (error) {
|
|
167
|
+
resolve({ kind: "inconclusive", reason: describeError(error) });
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
runProbeChild(probeCwd, input, resolve);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
function runProbeChild(probeCwd, input, resolve) {
|
|
174
|
+
const child = spawn("opencode", [...probeArgs()], {
|
|
175
|
+
cwd: probeCwd,
|
|
176
|
+
env: {
|
|
177
|
+
...process.env,
|
|
178
|
+
XDG_DATA_HOME: input.dataDir,
|
|
179
|
+
OPENCODE_CONFIG_CONTENT: OPENCODE_PROBE_CONFIG,
|
|
180
|
+
OPENCODE_DISABLE_AUTOUPDATE: "true",
|
|
181
|
+
OPENCODE_DISABLE_PRUNE: "true",
|
|
182
|
+
},
|
|
183
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
184
|
+
});
|
|
185
|
+
let settled = false;
|
|
186
|
+
let timer;
|
|
187
|
+
const finish = (result) => {
|
|
188
|
+
if (settled)
|
|
189
|
+
return;
|
|
190
|
+
settled = true;
|
|
191
|
+
if (timer)
|
|
192
|
+
clearTimeout(timer);
|
|
193
|
+
child.kill("SIGKILL");
|
|
194
|
+
try {
|
|
195
|
+
rmSync(probeCwd, { recursive: true, force: true });
|
|
196
|
+
}
|
|
197
|
+
catch {
|
|
198
|
+
// Best-effort temp cleanup; the OS reclaims it either way.
|
|
199
|
+
}
|
|
200
|
+
resolve(result);
|
|
201
|
+
};
|
|
202
|
+
timer = setTimeout(() => finish({ kind: "inconclusive", reason: `probe timed out after ${input.timeoutMs}ms` }), input.timeoutMs);
|
|
203
|
+
let captured = "";
|
|
204
|
+
const onChunk = (chunk) => {
|
|
205
|
+
captured = (captured + chunk.toString("utf8")).slice(-MAX_CAPTURE_BYTES);
|
|
206
|
+
const result = classifyChunk(captured);
|
|
207
|
+
if (result)
|
|
208
|
+
finish(result);
|
|
209
|
+
};
|
|
210
|
+
child.stdout.on("data", onChunk);
|
|
211
|
+
child.stderr.on("data", onChunk);
|
|
212
|
+
child.on("error", (error) => finish({ kind: "inconclusive", reason: error.message }));
|
|
213
|
+
child.on("close", (code) => finish(code === 0
|
|
214
|
+
? { kind: "healthy" }
|
|
215
|
+
: { kind: "inconclusive", reason: `opencode exited with code ${code ?? "signal"}` }));
|
|
216
|
+
}
|
|
217
|
+
function describeError(error) {
|
|
218
|
+
return error instanceof Error ? error.message : String(error);
|
|
219
|
+
}
|
|
220
|
+
/**
|
|
221
|
+
* Probe one account's OpenCode Go credential, memoising a healthy verdict.
|
|
222
|
+
*
|
|
223
|
+
* Never throws: every failure path resolves to `inconclusive` so a credential
|
|
224
|
+
* check can never stop the TUI from opening.
|
|
225
|
+
*/
|
|
226
|
+
export async function probeOpencodeAccount(account, deps = {}) {
|
|
227
|
+
const now = (deps.now ?? Date.now)();
|
|
228
|
+
const dataDir = opencodeAccountDataDir(account.id);
|
|
229
|
+
const ttl = deps.healthyTtlMs ?? OPENCODE_PROBE_HEALTHY_TTL_MS;
|
|
230
|
+
const okAt = readHealthMarker(dataDir);
|
|
231
|
+
if (okAt !== undefined && now - okAt >= 0 && now - okAt < ttl)
|
|
232
|
+
return { kind: "healthy" };
|
|
233
|
+
try {
|
|
234
|
+
(deps.materialize ?? defaultMaterialize)(account.key, dataDir);
|
|
235
|
+
}
|
|
236
|
+
catch (error) {
|
|
237
|
+
return { kind: "inconclusive", reason: describeError(error) };
|
|
238
|
+
}
|
|
239
|
+
const result = await (deps.run ?? spawnOpencodeProbe)({
|
|
240
|
+
dataDir,
|
|
241
|
+
timeoutMs: deps.timeoutMs ?? OPENCODE_PROBE_TIMEOUT_MS,
|
|
242
|
+
});
|
|
243
|
+
if (result.kind === "healthy")
|
|
244
|
+
writeHealthMarker(dataDir, now);
|
|
245
|
+
return result;
|
|
246
|
+
}
|
|
247
|
+
function defaultMaterialize(key, dataDir) {
|
|
248
|
+
materializeOpencodeGoAuth(key, { dataDir });
|
|
249
|
+
}
|
|
250
|
+
/**
|
|
251
|
+
* Select the account to launch on, excluding `excludedIds`.
|
|
252
|
+
*
|
|
253
|
+
* Excluded ids are the accounts this run just cooled. They are filtered here
|
|
254
|
+
* rather than relied upon coming back suppressed from the store — the same
|
|
255
|
+
* read-after-write independence `selectNextAccount` in `opencode-rotate.ts`
|
|
256
|
+
* keeps, so rotation stays correct even when the cooldown write is contended.
|
|
257
|
+
*/
|
|
258
|
+
function defaultSelectFrom(accounts, excludedIds) {
|
|
259
|
+
const pool = accounts.filter((account) => !excludedIds.has(account.id));
|
|
260
|
+
if (pool.length === 0)
|
|
261
|
+
return undefined;
|
|
262
|
+
const pruned = pruneExpiredOpencodeAccountSuppressions(loadOpencodeAccountAvailability());
|
|
263
|
+
return selectActiveOpencodeAccount(pool, pruned.state);
|
|
264
|
+
}
|
|
265
|
+
function coolLimitedAccount(accountId, result, deps) {
|
|
266
|
+
const canonicalId = opencodeAccountCanonicalId(accountId);
|
|
267
|
+
// Same event shape `--wp-rotate` and the review gate record, so all three
|
|
268
|
+
// writers produce identical, mutually-readable cooldown records. Unlike a
|
|
269
|
+
// manual rotation this one carries the provider's REAL reset window.
|
|
270
|
+
const event = {
|
|
271
|
+
failureCode: "opencode-go-usage-limit",
|
|
272
|
+
terminalClassification: "provider-limit-reached",
|
|
273
|
+
limitWindow: result.limitWindow,
|
|
274
|
+
retryAfterMs: result.retryAfterMs,
|
|
275
|
+
};
|
|
276
|
+
return (deps.recordCooldown ?? recordOpencodeAccountAvailabilityWithLock)(canonicalId, event);
|
|
277
|
+
}
|
|
278
|
+
function allCappedLines(selection) {
|
|
279
|
+
return [
|
|
280
|
+
`wp opencode: every OpenCode Go account probed as capped; launching on the soonest-expiring one (${selection.account.id})${formatOpencodeResetWait(selection.waitMs)}. Add another OPENCODE_GO_KEY_<ID> to widen the pool.`,
|
|
281
|
+
];
|
|
282
|
+
}
|
|
283
|
+
/**
|
|
284
|
+
* Probe the selected account and rotate past capped ones, bounded by the number
|
|
285
|
+
* of configured accounts.
|
|
286
|
+
*
|
|
287
|
+
* Returns the account to launch on plus the lines to print. Never throws and
|
|
288
|
+
* never returns nothing: an inconclusive probe keeps the incoming selection, so
|
|
289
|
+
* the worst case is exactly today's behaviour plus one log line.
|
|
290
|
+
*/
|
|
291
|
+
export async function resolveHealthyOpencodeAccount(accounts, initial, deps = {}) {
|
|
292
|
+
if (!isOpencodeProbeEnabled(deps.env ?? process.env))
|
|
293
|
+
return { selection: initial, lines: [] };
|
|
294
|
+
// `cooled` means every account is already known-capped: probing cannot find a
|
|
295
|
+
// better one and would cost a full timeout, so keep the existing behaviour.
|
|
296
|
+
if (initial.cooled)
|
|
297
|
+
return { selection: initial, lines: [] };
|
|
298
|
+
const probe = deps.probe ?? ((account) => probeOpencodeAccount(account));
|
|
299
|
+
const selectFrom = deps.selectFrom ?? defaultSelectFrom;
|
|
300
|
+
const excluded = new Set();
|
|
301
|
+
const lines = [];
|
|
302
|
+
let current = initial;
|
|
303
|
+
for (let attempt = 0; attempt < accounts.length; attempt += 1) {
|
|
304
|
+
const result = await probe(current.account);
|
|
305
|
+
if (result.kind === "healthy")
|
|
306
|
+
return { selection: current, lines };
|
|
307
|
+
if (result.kind === "inconclusive") {
|
|
308
|
+
lines.push(`wp opencode: health probe for opencode-go:${current.account.id} was inconclusive (${result.reason}); launching on it anyway.`);
|
|
309
|
+
return { selection: current, lines };
|
|
310
|
+
}
|
|
311
|
+
await coolLimitedAccount(current.account.id, result, deps);
|
|
312
|
+
excluded.add(current.account.id);
|
|
313
|
+
const next = selectFrom(accounts, excluded);
|
|
314
|
+
if (!next) {
|
|
315
|
+
const fallback = selectFrom(accounts, new Set()) ?? current;
|
|
316
|
+
lines.push(...allCappedLines(fallback));
|
|
317
|
+
return { selection: fallback, lines };
|
|
318
|
+
}
|
|
319
|
+
lines.push(`wp opencode: probe found opencode-go:${current.account.id} capped${formatOpencodeResetWait(result.retryAfterMs)}, rotated to opencode-go:${next.account.id}`);
|
|
320
|
+
current = next;
|
|
321
|
+
}
|
|
322
|
+
return { selection: current, lines };
|
|
323
|
+
}
|