@gr8ful/spf 0.11.0 → 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.
@@ -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
- steps.scout({ description: "Map the subsystems this spec touches change nothing" }),
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, unlinkSync, writeFileSync } from "node:fs";
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
- function isPidAlive(pid) {
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
- if (existsSync(lockPath)) {
164
- const pid = Number.parseInt(readFileSync(lockPath, "utf-8").trim(), 10);
165
- if (Number.isInteger(pid) && isPidAlive(pid)) {
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
- while (state.inflight.size > 0) {
747
- deps.log(`[spf] watch draining ${state.inflight.size} in-flight issue(s)...`); // deps.log already routes to the dashboard when one is mounted, console.log otherwise
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
@@ -1,21 +1,3 @@
1
- /**
2
- * Bitbucket Cloud REST API v2.0 implementation of `CodeHostProvider` —
3
- * `spf watch`'s PR seam, not its tracker seam (see `provider.ts`'s module
4
- * comment): this class never touches issues/labels, so it's paired with an
5
- * `IssueProvider` (`github_provider.ts` or `jira_provider.ts`) at the CLI
6
- * layer.
7
- *
8
- * Auth is HTTP Basic with an Atlassian account email + API token
9
- * (`BITBUCKET_EMAIL` / `BITBUCKET_API_TOKEN`) — verified directly against
10
- * Atlassian's current docs before writing this, not assumed from training
11
- * data: Bitbucket Cloud app passwords are being fully removed (brownout
12
- * window closes July 28, 2026), so this project only supports the
13
- * replacement — API tokens, same auth shape as `jira_provider.ts`.
14
- *
15
- * `repo` is `"workspace/repo_slug"` (Bitbucket's own two-part identifier),
16
- * the same config field GitHub uses for `"owner/name"` — the shape just
17
- * means something different per `code_host`.
18
- */
19
1
  import type { CodeHostProvider, PrRef, PrStatus } from "./provider.ts";
20
2
  export declare class BitbucketProvider implements CodeHostProvider {
21
3
  private readonly email;
@@ -1,3 +1,22 @@
1
+ /**
2
+ * Bitbucket Cloud REST API v2.0 implementation of `CodeHostProvider` —
3
+ * `spf watch`'s PR seam, not its tracker seam (see `provider.ts`'s module
4
+ * comment): this class never touches issues/labels, so it's paired with an
5
+ * `IssueProvider` (`github_provider.ts` or `jira_provider.ts`) at the CLI
6
+ * layer.
7
+ *
8
+ * Auth is HTTP Basic with an Atlassian account email + API token
9
+ * (`BITBUCKET_EMAIL` / `BITBUCKET_API_TOKEN`) — verified directly against
10
+ * Atlassian's current docs before writing this, not assumed from training
11
+ * data: Bitbucket Cloud app passwords are being fully removed (brownout
12
+ * window closes July 28, 2026), so this project only supports the
13
+ * replacement — API tokens, same auth shape as `jira_provider.ts`.
14
+ *
15
+ * `repo` is `"workspace/repo_slug"` (Bitbucket's own two-part identifier),
16
+ * the same config field GitHub uses for `"owner/name"` — the shape just
17
+ * means something different per `code_host`.
18
+ */
19
+ import { fetchRetryTransient } from "../utils.js";
1
20
  const API = "https://api.bitbucket.org/2.0";
2
21
  export class BitbucketProvider {
3
22
  email;
@@ -15,7 +34,7 @@ export class BitbucketProvider {
15
34
  this.repoSlug = repoSlug;
16
35
  }
17
36
  async bb(path, init) {
18
- const response = await fetch(`${API}${path}`, {
37
+ const response = await fetchRetryTransient(`${API}${path}`, {
19
38
  ...init,
20
39
  headers: {
21
40
  Authorization: `Basic ${Buffer.from(`${this.email}:${this.apiToken}`).toString("base64")}`,
@@ -1,3 +1,4 @@
1
+ import { fetchRetryTransient } from "../utils.js";
1
2
  const STATES = [
2
3
  "ready",
3
4
  "working",
@@ -65,7 +66,7 @@ export class JiraProvider {
65
66
  const debug = Boolean(process.env["SPF_JIRA_DEBUG"]);
66
67
  if (debug)
67
68
  console.error(`[jira debug] ${init?.method ?? "GET"} ${this.baseUrl}${path} body=${init?.body ?? "(none)"}`);
68
- const response = await fetch(`${this.baseUrl}${path}`, {
69
+ const response = await fetchRetryTransient(`${this.baseUrl}${path}`, {
69
70
  ...init,
70
71
  headers: {
71
72
  Authorization: this.authHeader(),
@@ -139,7 +140,7 @@ export class JiraProvider {
139
140
  * never discovers it from the issue body itself.
140
141
  */
141
142
  async getIssue(id) {
142
- const response = await fetch(`${this.baseUrl}/rest/api/3/issue/${id}?fields=summary,description,labels`, {
143
+ const response = await fetchRetryTransient(`${this.baseUrl}/rest/api/3/issue/${id}?fields=summary,description,labels`, {
143
144
  headers: { Authorization: this.authHeader(), Accept: "application/json" },
144
145
  });
145
146
  if (response.status === 404)
@@ -19,6 +19,42 @@ import path from "node:path";
19
19
  */
20
20
  export declare function operatorEnv(): Record<string, string>;
21
21
  export declare function newId(length?: number): string;
22
+ /**
23
+ * `fetch`, but a transport-level blip on the FIRST attempt gets one silent
24
+ * retry before it's allowed to throw. Node's global `fetch` (undici) pools
25
+ * keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
26
+ * Bitbucket) close idle ones from their end, which surfaces here as
27
+ * `ECONNRESET` the next time a long-lived poller (`spf watch`) reuses one —
28
+ * a stale-socket race, not a real problem with the request. Only retries
29
+ * error codes that mean "the transport failed," never an HTTP error status
30
+ * (a 4xx/5xx response is not a thrown error here, and must keep surfacing
31
+ * on the first attempt so callers see it immediately).
32
+ */
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;
22
58
  /** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
23
59
  export declare function nowIso(): string;
24
60
  export declare function ensureDir(dirPath: string): string;
@@ -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.
@@ -31,6 +31,85 @@ export function operatorEnv() {
31
31
  export function newId(length = 8) {
32
32
  return randomBytes(Math.floor(length / 2)).toString("hex");
33
33
  }
34
+ /** Transport-level blips worth one silent retry — see `fetchRetryTransient` below. */
35
+ const TRANSIENT_FETCH_CODES = new Set(["ECONNRESET", "ETIMEDOUT", "EPIPE", "ECONNREFUSED", "EAI_AGAIN"]);
36
+ /**
37
+ * `fetch`, but a transport-level blip on the FIRST attempt gets one silent
38
+ * retry before it's allowed to throw. Node's global `fetch` (undici) pools
39
+ * keep-alive connections across calls; Atlassian's Cloud APIs (Jira,
40
+ * Bitbucket) close idle ones from their end, which surfaces here as
41
+ * `ECONNRESET` the next time a long-lived poller (`spf watch`) reuses one —
42
+ * a stale-socket race, not a real problem with the request. Only retries
43
+ * error codes that mean "the transport failed," never an HTTP error status
44
+ * (a 4xx/5xx response is not a thrown error here, and must keep surfacing
45
+ * on the first attempt so callers see it immediately).
46
+ */
47
+ export async function fetchRetryTransient(input, init) {
48
+ try {
49
+ return await fetch(input, init);
50
+ }
51
+ catch (error) {
52
+ const code = error.cause?.code;
53
+ if (!code || !TRANSIENT_FETCH_CODES.has(code))
54
+ throw error;
55
+ return await fetch(input, init);
56
+ }
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
+ }
34
113
  /** Matches Python's `datetime.now(timezone.utc).isoformat(timespec="milliseconds")`. */
35
114
  export function nowIso() {
36
115
  const iso = new Date().toISOString(); // e.g. 2024-01-01T12:00:00.123Z
@@ -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
@@ -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(() => state.refining.delete(issue.id));
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@gr8ful/spf",
3
- "version": "0.11.0",
3
+ "version": "0.11.2",
4
4
  "description": "Super Portable Factory — a global CLI for repeatable agents-plus-code workflows (ADWs)",
5
5
  "type": "module",
6
6
  "license": "MIT",