@gr8ful/spf 0.11.1 → 0.11.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/dist/chains/index.js +7 -1
- package/dist/cli/commands/watch.js +19 -29
- package/dist/core/utils.d.ts +24 -0
- package/dist/core/utils.js +56 -1
- package/dist/core/watch.d.ts +17 -0
- package/dist/core/watch.js +48 -2
- package/package.json +1 -1
package/dist/chains/index.js
CHANGED
|
@@ -80,7 +80,13 @@ export const CHAINS = [
|
|
|
80
80
|
// {{previous_envelope}}. This makes `scout` a required agent for this
|
|
81
81
|
// chain: a roster that pruned it fails agents.validate() by name at
|
|
82
82
|
// `spf watch` startup, same as any other missing required agent.
|
|
83
|
-
|
|
83
|
+
// retries: 2 (3 attempts total) — `gates.artifactsExist` failing here has
|
|
84
|
+
// historically been transient (a declared artifact briefly missing,
|
|
85
|
+
// since fixed at the source by giving `claim()` real per-issue
|
|
86
|
+
// exclusivity — see `core/watch.ts`'s `issueLockPath`), so a same-session
|
|
87
|
+
// correction retry or two is worth it before this blocks the whole spec
|
|
88
|
+
// and pages a human.
|
|
89
|
+
steps.scout({ description: "Map the subsystems this spec touches — change nothing", retries: 2 }),
|
|
84
90
|
steps.refine(),
|
|
85
91
|
steps.publishIssues(),
|
|
86
92
|
]),
|
|
@@ -5,7 +5,7 @@
|
|
|
5
5
|
* actual state machine; this file is just the wiring: config, the GitHub
|
|
6
6
|
* provider, the chain-dispatch callback, the lockfile, and the CLI loop.
|
|
7
7
|
*/
|
|
8
|
-
import { existsSync, mkdirSync, readFileSync, symlinkSync
|
|
8
|
+
import { existsSync, mkdirSync, readFileSync, symlinkSync } from "node:fs";
|
|
9
9
|
import { homedir } from "node:os";
|
|
10
10
|
import path from "node:path";
|
|
11
11
|
import * as v from "valibot";
|
|
@@ -23,7 +23,7 @@ import { withRunScope } from "../../core/sandbox.js";
|
|
|
23
23
|
import { excludeSpfDataFromGit } from "../../core/worktree_data.js";
|
|
24
24
|
import { ReviewOutput } from "../../core/data_types.js";
|
|
25
25
|
import { SfDb } from "../../ui/server/db.js";
|
|
26
|
-
import { parseCli } from "../../core/utils.js";
|
|
26
|
+
import { acquirePidLock, parseCli, releasePidLock } from "../../core/utils.js";
|
|
27
27
|
import { isInteractive } from "../ask.js";
|
|
28
28
|
/** Kept well under Slack's own 2900-char slice on `detail` (see `slack_channel.ts`) — a reviewer can emit a lot of findings, but the PR body/notification only needs enough to tell a human whether to look closer. */
|
|
29
29
|
const MAX_REVIEW_DIGEST_CHARS = 1200;
|
|
@@ -149,34 +149,14 @@ function resolveCodeHostProvider(cfg) {
|
|
|
149
149
|
console.error(`watch.code_host ${JSON.stringify(cfg.watch.code_host)} is not supported`);
|
|
150
150
|
return null;
|
|
151
151
|
}
|
|
152
|
-
|
|
153
|
-
try {
|
|
154
|
-
process.kill(pid, 0);
|
|
155
|
-
return true;
|
|
156
|
-
}
|
|
157
|
-
catch {
|
|
158
|
-
return false;
|
|
159
|
-
}
|
|
160
|
-
}
|
|
161
|
-
/** Stale-steal, like the reference implementation's daemon lock: a dead pid never blocks a restart. */
|
|
152
|
+
/** Stale-steal, like the reference implementation's daemon lock: a dead pid never blocks a restart. Atomic — see `utils.acquirePidLock`. */
|
|
162
153
|
function acquireLock(lockPath) {
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
throw new Error(`another \`spf watch\` is already running (pid ${pid}) — lock at ${lockPath}`);
|
|
167
|
-
}
|
|
168
|
-
}
|
|
169
|
-
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
170
|
-
writeFileSync(lockPath, String(process.pid));
|
|
171
|
-
}
|
|
172
|
-
function releaseLock(lockPath) {
|
|
173
|
-
try {
|
|
174
|
-
unlinkSync(lockPath);
|
|
175
|
-
}
|
|
176
|
-
catch {
|
|
177
|
-
// already gone — fine
|
|
154
|
+
const result = acquirePidLock(lockPath);
|
|
155
|
+
if (!result.ok) {
|
|
156
|
+
throw new Error(`another \`spf watch\` is already running (pid ${result.holderPid}) — lock at ${lockPath}`);
|
|
178
157
|
}
|
|
179
158
|
}
|
|
159
|
+
const releaseLock = releasePidLock;
|
|
180
160
|
/**
|
|
181
161
|
* `spf watch init` — idempotently seed the `<prefix>:*` labels the state
|
|
182
162
|
* machine needs, with sensible colors/descriptions. Doesn't touch git or
|
|
@@ -743,8 +723,18 @@ export async function watchCommand(argv) {
|
|
|
743
723
|
if (stopping)
|
|
744
724
|
break;
|
|
745
725
|
}
|
|
746
|
-
|
|
747
|
-
|
|
726
|
+
// BOTH lanes, not just `inflight` — `state.refining`'s specs are exactly
|
|
727
|
+
// as in-flight (a fire-and-forget `runSpec(...).finally(...)`, same
|
|
728
|
+
// shape as `runIssue`'s), and a graceful stop that only waits on
|
|
729
|
+
// `inflight` would print "stopping after the current tick" and then
|
|
730
|
+
// exit while a refine chain is still writing into its worktree — the
|
|
731
|
+
// next `spf watch` start-up would find `claimSpecs`'s per-issue lock
|
|
732
|
+
// still held by this (still-alive, still-running) process's own pid and
|
|
733
|
+
// correctly back off, but ONLY because that lock exists; before it did,
|
|
734
|
+
// this exact gap is what let a resumed spec race a still-running prior
|
|
735
|
+
// attempt for the same issue.
|
|
736
|
+
while (state.inflight.size > 0 || state.refining.size > 0) {
|
|
737
|
+
deps.log(`[spf] watch draining ${state.inflight.size} in-flight issue(s), ${state.refining.size} refining spec(s)...`); // deps.log already routes to the dashboard when one is mounted, console.log otherwise
|
|
748
738
|
await interruptibleSleep(1000);
|
|
749
739
|
if (stopping && sigints >= 2)
|
|
750
740
|
break; // stop() itself already exits on the 2nd signal; this is belt-and-suspenders
|
package/dist/core/utils.d.ts
CHANGED
|
@@ -31,6 +31,30 @@ export declare function newId(length?: number): string;
|
|
|
31
31
|
* on the first attempt so callers see it immediately).
|
|
32
32
|
*/
|
|
33
33
|
export declare function fetchRetryTransient(input: string, init?: RequestInit): Promise<Response>;
|
|
34
|
+
/** `process.kill(pid, 0)` sends no signal — it throws iff `pid` isn't running (or isn't ours to signal), the standard Node liveness probe. */
|
|
35
|
+
export declare function isPidAlive(pid: number): boolean;
|
|
36
|
+
export type PidLockResult = {
|
|
37
|
+
ok: true;
|
|
38
|
+
} | {
|
|
39
|
+
ok: false;
|
|
40
|
+
holderPid: number;
|
|
41
|
+
};
|
|
42
|
+
/**
|
|
43
|
+
* Atomic PID lockfile at `lockPath`: `{ flag: "wx" }` makes "does anyone
|
|
44
|
+
* hold this" and "claim it" ONE filesystem operation, closing the race a
|
|
45
|
+
* separate `existsSync` check followed by a later `writeFileSync` leaves
|
|
46
|
+
* open — two callers can both pass that existence check before either
|
|
47
|
+
* writes, and both walk away believing they hold the lock. A dead pid never
|
|
48
|
+
* blocks a caller: on `EEXIST`, `isPidAlive` decides whether this is a live
|
|
49
|
+
* holder (returns `ok: false`) or a stale lock from a process that never
|
|
50
|
+
* cleaned up (crash, SIGKILL, a restart that didn't wait for it to exit) —
|
|
51
|
+
* stolen via unlink-then-retry. Another caller can win that same steal
|
|
52
|
+
* race; if so, ITS write is what the retry's `wx` finds, and this caller
|
|
53
|
+
* correctly backs off against a live pid on the next pass instead of
|
|
54
|
+
* clobbering a real holder.
|
|
55
|
+
*/
|
|
56
|
+
export declare function acquirePidLock(lockPath: string): PidLockResult;
|
|
57
|
+
export declare function releasePidLock(lockPath: string): void;
|
|
34
58
|
/** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
|
|
35
59
|
export declare function nowIso(): string;
|
|
36
60
|
export declare function ensureDir(dirPath: string): string;
|
package/dist/core/utils.js
CHANGED
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { randomBytes } from "node:crypto";
|
|
10
10
|
import { spawnSync } from "node:child_process";
|
|
11
|
-
import { existsSync, mkdirSync, readFileSync, statSync } from "node:fs";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
13
|
/**
|
|
14
14
|
* The engineer's own environment, as their shell would hand it over.
|
|
@@ -55,6 +55,61 @@ export async function fetchRetryTransient(input, init) {
|
|
|
55
55
|
return await fetch(input, init);
|
|
56
56
|
}
|
|
57
57
|
}
|
|
58
|
+
/** `process.kill(pid, 0)` sends no signal — it throws iff `pid` isn't running (or isn't ours to signal), the standard Node liveness probe. */
|
|
59
|
+
export function isPidAlive(pid) {
|
|
60
|
+
try {
|
|
61
|
+
process.kill(pid, 0);
|
|
62
|
+
return true;
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/**
|
|
69
|
+
* Atomic PID lockfile at `lockPath`: `{ flag: "wx" }` makes "does anyone
|
|
70
|
+
* hold this" and "claim it" ONE filesystem operation, closing the race a
|
|
71
|
+
* separate `existsSync` check followed by a later `writeFileSync` leaves
|
|
72
|
+
* open — two callers can both pass that existence check before either
|
|
73
|
+
* writes, and both walk away believing they hold the lock. A dead pid never
|
|
74
|
+
* blocks a caller: on `EEXIST`, `isPidAlive` decides whether this is a live
|
|
75
|
+
* holder (returns `ok: false`) or a stale lock from a process that never
|
|
76
|
+
* cleaned up (crash, SIGKILL, a restart that didn't wait for it to exit) —
|
|
77
|
+
* stolen via unlink-then-retry. Another caller can win that same steal
|
|
78
|
+
* race; if so, ITS write is what the retry's `wx` finds, and this caller
|
|
79
|
+
* correctly backs off against a live pid on the next pass instead of
|
|
80
|
+
* clobbering a real holder.
|
|
81
|
+
*/
|
|
82
|
+
export function acquirePidLock(lockPath) {
|
|
83
|
+
mkdirSync(path.dirname(lockPath), { recursive: true });
|
|
84
|
+
for (;;) {
|
|
85
|
+
try {
|
|
86
|
+
writeFileSync(lockPath, String(process.pid), { flag: "wx" });
|
|
87
|
+
return { ok: true };
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
if (error.code !== "EEXIST")
|
|
91
|
+
throw error;
|
|
92
|
+
const pid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
|
|
93
|
+
if (Number.isInteger(pid) && isPidAlive(pid))
|
|
94
|
+
return { ok: false, holderPid: pid };
|
|
95
|
+
try {
|
|
96
|
+
unlinkSync(lockPath);
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
// already gone (another caller's steal beat us to it) — loop back
|
|
100
|
+
// and let the `wx` above settle who actually gets it
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
export function releasePidLock(lockPath) {
|
|
106
|
+
try {
|
|
107
|
+
unlinkSync(lockPath);
|
|
108
|
+
}
|
|
109
|
+
catch {
|
|
110
|
+
// already gone — fine
|
|
111
|
+
}
|
|
112
|
+
}
|
|
58
113
|
/** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
|
|
59
114
|
export function nowIso() {
|
|
60
115
|
const iso = new Date().toISOString(); // e.g. 2024-01-01T12:00:00.123Z
|
package/dist/core/watch.d.ts
CHANGED
|
@@ -222,6 +222,23 @@ export declare function createWatchState(): WatchRunState;
|
|
|
222
222
|
export declare function branchNameFor(issue: Issue): string;
|
|
223
223
|
/** Same idea as `branchNameFor`, for the refine lane's throwaway worktree — a spec never gets a PR, so this branch is only ever fetched-from-and-thrown-away, never pushed. */
|
|
224
224
|
export declare function refineBranchNameFor(issue: Issue): string;
|
|
225
|
+
/**
|
|
226
|
+
* A local, atomic, PID-checked lock per `adwId` (`issue-<id>`/`spec-<id>`) —
|
|
227
|
+
* sibling to `worktreesDir`, so it lives at the same per-repo root
|
|
228
|
+
* (`~/.spf/watch/<repo>/locks/`). `provider.claim()`'s own read-modify-write
|
|
229
|
+
* (see `IssueProvider.claim`'s doc comment) only verifies the END STATE
|
|
230
|
+
* looks claimed, not that no one else raced it — the real exclusivity has
|
|
231
|
+
* to live here, gating `claim()` itself, because a resumed/orphaned spec
|
|
232
|
+
* dispatches straight into the SAME deterministic worktree and
|
|
233
|
+
* `context_handoff_dir` a still-running prior attempt already owns (see
|
|
234
|
+
* `chains/steps.ts`'s `clearStaleRefineOutputFiles` doc comment). A dead
|
|
235
|
+
* holder (the daemon that owned it got killed, not stopped) never blocks a
|
|
236
|
+
* fresh claim — `acquirePidLock`'s stale-steal — but a genuinely live
|
|
237
|
+
* holder (this daemon restarted while the OLD process's chain run, spawned
|
|
238
|
+
* fire-and-forget from `claimNewWork`/`claimSpecs`, was still in flight)
|
|
239
|
+
* does, until that run's own `.finally()` releases it.
|
|
240
|
+
*/
|
|
241
|
+
export declare function issueLockPath(deps: WatchDeps, adwId: string): string;
|
|
225
242
|
/**
|
|
226
243
|
* Any issue labeled `working` that THIS process isn't tracking is an
|
|
227
244
|
* orphan — a daemon restart, or another instance's claim this process
|
package/dist/core/watch.js
CHANGED
|
@@ -59,7 +59,7 @@ import { attemptAdwId, attemptBranch, attemptWorktreePath, runBestOf } from "./f
|
|
|
59
59
|
import { PRIORITY_RANK } from "./data_types.js";
|
|
60
60
|
import { redact } from "./otel.js";
|
|
61
61
|
import { parseRefineMarker } from "./refine.js";
|
|
62
|
-
import { newId } from "./utils.js";
|
|
62
|
+
import { acquirePidLock, newId, releasePidLock } from "./utils.js";
|
|
63
63
|
const MAX_ORPHAN_ATTEMPTS = 2;
|
|
64
64
|
/** GitHub's own documented sub-issue nesting cap (see `github_provider.ts`'s `linkChild` doc comment) — `rollUp`'s own recursion bound, so a malformed/cyclic hierarchy can't spin forever. */
|
|
65
65
|
const MAX_ROLLUP_DEPTH = 8;
|
|
@@ -95,6 +95,25 @@ function worktreePathFor(deps, issue) {
|
|
|
95
95
|
function specWorktreePathFor(deps, issue) {
|
|
96
96
|
return path.join(deps.worktreesDir, `spec-${issue.id}`);
|
|
97
97
|
}
|
|
98
|
+
/**
|
|
99
|
+
* A local, atomic, PID-checked lock per `adwId` (`issue-<id>`/`spec-<id>`) —
|
|
100
|
+
* sibling to `worktreesDir`, so it lives at the same per-repo root
|
|
101
|
+
* (`~/.spf/watch/<repo>/locks/`). `provider.claim()`'s own read-modify-write
|
|
102
|
+
* (see `IssueProvider.claim`'s doc comment) only verifies the END STATE
|
|
103
|
+
* looks claimed, not that no one else raced it — the real exclusivity has
|
|
104
|
+
* to live here, gating `claim()` itself, because a resumed/orphaned spec
|
|
105
|
+
* dispatches straight into the SAME deterministic worktree and
|
|
106
|
+
* `context_handoff_dir` a still-running prior attempt already owns (see
|
|
107
|
+
* `chains/steps.ts`'s `clearStaleRefineOutputFiles` doc comment). A dead
|
|
108
|
+
* holder (the daemon that owned it got killed, not stopped) never blocks a
|
|
109
|
+
* fresh claim — `acquirePidLock`'s stale-steal — but a genuinely live
|
|
110
|
+
* holder (this daemon restarted while the OLD process's chain run, spawned
|
|
111
|
+
* fire-and-forget from `claimNewWork`/`claimSpecs`, was still in flight)
|
|
112
|
+
* does, until that run's own `.finally()` releases it.
|
|
113
|
+
*/
|
|
114
|
+
export function issueLockPath(deps, adwId) {
|
|
115
|
+
return path.join(path.dirname(deps.worktreesDir), "locks", `${adwId}.lock`);
|
|
116
|
+
}
|
|
98
117
|
function cleanupWorktree(deps, marker) {
|
|
99
118
|
if (!marker)
|
|
100
119
|
return;
|
|
@@ -1182,9 +1201,21 @@ export async function claimNewWork(deps, state) {
|
|
|
1182
1201
|
deps.log(`watch: [dry-run] would claim ${issue.id} (${issue.title}) and run chain "${deps.chain}"`);
|
|
1183
1202
|
continue;
|
|
1184
1203
|
}
|
|
1204
|
+
// Gate the tracker-side claim itself behind local exclusivity — see
|
|
1205
|
+
// `issueLockPath`'s doc comment. A held lock means a live process (this
|
|
1206
|
+
// machine, this tick or an earlier one) already owns this issue's
|
|
1207
|
+
// worktree, so skip identically to a lost tracker-side claim race
|
|
1208
|
+
// rather than ever writing the tracker label.
|
|
1209
|
+
const lockPath = issueLockPath(deps, `issue-${issue.id}`);
|
|
1210
|
+
const lock = acquirePidLock(lockPath);
|
|
1211
|
+
if (!lock.ok) {
|
|
1212
|
+
deps.log(`watch: ${issue.id} is locked by another live \`spf watch\` process (pid ${lock.holderPid}) — skipping`);
|
|
1213
|
+
continue;
|
|
1214
|
+
}
|
|
1185
1215
|
const claimed = await deps.provider.claim(issue);
|
|
1186
1216
|
if (!claimed) {
|
|
1187
1217
|
deps.log(`watch: ${issue.id} lost the claim race this tick — skipping`);
|
|
1218
|
+
releasePidLock(lockPath);
|
|
1188
1219
|
continue;
|
|
1189
1220
|
}
|
|
1190
1221
|
deps.log(`watch: claimed ${issue.id}: ${issue.title}`);
|
|
@@ -1200,6 +1231,7 @@ export async function claimNewWork(deps, state) {
|
|
|
1200
1231
|
runIssue(deps, issue).finally(() => {
|
|
1201
1232
|
state.inflight.delete(issue.id);
|
|
1202
1233
|
state.inflightParents.delete(issue.id);
|
|
1234
|
+
releasePidLock(lockPath);
|
|
1203
1235
|
});
|
|
1204
1236
|
}
|
|
1205
1237
|
}
|
|
@@ -1238,9 +1270,20 @@ export async function claimSpecs(deps, state, from = "spec-ready") {
|
|
|
1238
1270
|
deps.log(`watch: [dry-run] would claim spec ${issue.id} (${issue.title}) and run refine chain "${deps.refineChain}"`);
|
|
1239
1271
|
continue;
|
|
1240
1272
|
}
|
|
1273
|
+
// See claimNewWork's identical guard and issueLockPath's doc comment —
|
|
1274
|
+
// same local-exclusivity gate, keyed to match runSpec's own adwId
|
|
1275
|
+
// (`spec-<id>`) so it covers the SAME deterministic worktree a resumed
|
|
1276
|
+
// (`continue-refinement`) claim reruns into.
|
|
1277
|
+
const lockPath = issueLockPath(deps, `spec-${issue.id}`);
|
|
1278
|
+
const lock = acquirePidLock(lockPath);
|
|
1279
|
+
if (!lock.ok) {
|
|
1280
|
+
deps.log(`watch: spec ${issue.id} is locked by another live \`spf watch\` process (pid ${lock.holderPid}) — skipping`);
|
|
1281
|
+
continue;
|
|
1282
|
+
}
|
|
1241
1283
|
const claimed = await deps.provider.claim(issue, { from, to: "refining" });
|
|
1242
1284
|
if (!claimed) {
|
|
1243
1285
|
deps.log(`watch: spec ${issue.id} lost the claim race this tick — skipping`);
|
|
1286
|
+
releasePidLock(lockPath);
|
|
1244
1287
|
continue;
|
|
1245
1288
|
}
|
|
1246
1289
|
deps.log(`watch: claimed spec ${issue.id}: ${issue.title}`);
|
|
@@ -1251,7 +1294,10 @@ export async function claimSpecs(deps, state, from = "spec-ready") {
|
|
|
1251
1294
|
fields: [["issue", issue.id], ["title", issue.title], ["chain", deps.refineChain]],
|
|
1252
1295
|
});
|
|
1253
1296
|
state.refining.add(issue.id);
|
|
1254
|
-
runSpec(deps, issue).finally(() =>
|
|
1297
|
+
runSpec(deps, issue).finally(() => {
|
|
1298
|
+
state.refining.delete(issue.id);
|
|
1299
|
+
releasePidLock(lockPath);
|
|
1300
|
+
});
|
|
1255
1301
|
}
|
|
1256
1302
|
}
|
|
1257
1303
|
function tickErrorHandler(deps, stage) {
|