sequant 2.9.0 → 2.11.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.
Files changed (209) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +20 -5
  4. package/dist/bin/cli.js +143 -18
  5. package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
  6. package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
  7. package/dist/marketplace/external_plugins/sequant/README.md +56 -2
  8. package/dist/marketplace/external_plugins/sequant/hooks/parallel-marker.sh +50 -0
  9. package/dist/marketplace/external_plugins/sequant/hooks/post-tool.sh +20 -7
  10. package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +75 -10
  11. package/dist/marketplace/external_plugins/sequant/skills/_shared/references/trust-model.md +18 -0
  12. package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +215 -390
  13. package/dist/marketplace/external_plugins/sequant/skills/assess/references/predicted-collision-detection.md +1 -1
  14. package/dist/marketplace/external_plugins/sequant/skills/docs/SKILL.md +4 -4
  15. package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +26 -6
  16. package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +3 -1
  17. package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +38 -2
  18. package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +2 -2
  19. package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +337 -57
  20. package/dist/marketplace/external_plugins/sequant/skills/qa/references/anti-pattern-detection.md +6 -6
  21. package/dist/marketplace/external_plugins/sequant/skills/qa/references/call-site-review.md +2 -2
  22. package/dist/marketplace/external_plugins/sequant/skills/qa/references/code-review-checklist.md +2 -2
  23. package/dist/marketplace/external_plugins/sequant/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  24. package/dist/marketplace/external_plugins/sequant/skills/qa/references/quality-gates.md +21 -7
  25. package/dist/marketplace/external_plugins/sequant/skills/qa/references/testing-requirements.md +1 -1
  26. package/dist/marketplace/external_plugins/sequant/skills/qa/scripts/quality-checks.sh +11 -11
  27. package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +1 -1
  28. package/dist/marketplace/external_plugins/sequant/skills/setup/SKILL.md +5 -5
  29. package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +2 -0
  30. package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
  31. package/dist/src/commands/assess-render.d.ts +23 -0
  32. package/dist/src/commands/assess-render.js +60 -0
  33. package/dist/src/commands/doctor.js +8 -13
  34. package/dist/src/commands/init.js +13 -1
  35. package/dist/src/commands/locks.d.ts +20 -1
  36. package/dist/src/commands/locks.js +208 -4
  37. package/dist/src/commands/logs.js +14 -5
  38. package/dist/src/commands/merge.js +154 -2
  39. package/dist/src/commands/ready-tui-adapter.js +6 -1
  40. package/dist/src/commands/ready.d.ts +31 -3
  41. package/dist/src/commands/ready.js +53 -13
  42. package/dist/src/commands/resume.d.ts +113 -0
  43. package/dist/src/commands/resume.js +193 -0
  44. package/dist/src/commands/run-display.js +38 -4
  45. package/dist/src/commands/run-flags.d.ts +42 -1
  46. package/dist/src/commands/run-flags.js +53 -1
  47. package/dist/src/commands/run-progress.js +26 -1
  48. package/dist/src/commands/run.js +11 -10
  49. package/dist/src/commands/state.js +7 -0
  50. package/dist/src/commands/status.d.ts +9 -0
  51. package/dist/src/commands/status.js +24 -1
  52. package/dist/src/commands/sync.js +90 -19
  53. package/dist/src/commands/update.js +28 -5
  54. package/dist/src/commands/worktree.d.ts +31 -0
  55. package/dist/src/commands/worktree.js +95 -0
  56. package/dist/src/lib/ac-parser.d.ts +14 -0
  57. package/dist/src/lib/ac-parser.js +99 -5
  58. package/dist/src/lib/assess/index.d.ts +10 -0
  59. package/dist/src/lib/assess/index.js +9 -0
  60. package/dist/src/lib/assess/renderer.d.ts +64 -0
  61. package/dist/src/lib/assess/renderer.js +481 -0
  62. package/dist/src/lib/assess/types.d.ts +224 -0
  63. package/dist/src/lib/assess/types.js +241 -0
  64. package/dist/src/lib/ci/outputs.d.ts +15 -2
  65. package/dist/src/lib/ci/outputs.js +17 -5
  66. package/dist/src/lib/cli-flags.d.ts +56 -0
  67. package/dist/src/lib/cli-flags.js +89 -0
  68. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +23 -7
  69. package/dist/src/lib/cli-ui/run-renderer.d.ts +10 -0
  70. package/dist/src/lib/cli-ui/run-renderer.js +69 -1
  71. package/dist/src/lib/errors.d.ts +78 -6
  72. package/dist/src/lib/errors.js +133 -15
  73. package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
  74. package/dist/src/lib/locks/checkout-lock.js +389 -0
  75. package/dist/src/lib/locks/index.d.ts +7 -4
  76. package/dist/src/lib/locks/index.js +5 -3
  77. package/dist/src/lib/locks/lock-manager.d.ts +101 -3
  78. package/dist/src/lib/locks/lock-manager.js +277 -8
  79. package/dist/src/lib/locks/types.d.ts +106 -3
  80. package/dist/src/lib/locks/types.js +44 -0
  81. package/dist/src/lib/mcp-config.d.ts +45 -0
  82. package/dist/src/lib/mcp-config.js +77 -1
  83. package/dist/src/lib/merge-check/combined-branch-test.d.ts +21 -2
  84. package/dist/src/lib/merge-check/combined-branch-test.js +233 -110
  85. package/dist/src/lib/merge-check/command-result.d.ts +36 -0
  86. package/dist/src/lib/merge-check/command-result.js +56 -0
  87. package/dist/src/lib/merge-check/index.d.ts +4 -0
  88. package/dist/src/lib/merge-check/index.js +1 -1
  89. package/dist/src/lib/merge-check/report.js +5 -1
  90. package/dist/src/lib/merge-check/types.d.ts +9 -0
  91. package/dist/src/lib/merge-check/watch.d.ts +121 -0
  92. package/dist/src/lib/merge-check/watch.js +215 -0
  93. package/dist/src/lib/qa/infra-blocked-ci.d.ts +90 -0
  94. package/dist/src/lib/qa/infra-blocked-ci.js +80 -0
  95. package/dist/src/lib/settings.d.ts +87 -0
  96. package/dist/src/lib/settings.js +106 -8
  97. package/dist/src/lib/shutdown.d.ts +42 -2
  98. package/dist/src/lib/shutdown.js +75 -5
  99. package/dist/src/lib/skills-check.d.ts +26 -0
  100. package/dist/src/lib/skills-check.js +31 -0
  101. package/dist/src/lib/stacks.d.ts +110 -1
  102. package/dist/src/lib/stacks.js +244 -5
  103. package/dist/src/lib/templates.d.ts +74 -0
  104. package/dist/src/lib/templates.js +171 -16
  105. package/dist/src/lib/test-tautology-detector.d.ts +10 -2
  106. package/dist/src/lib/test-tautology-detector.js +213 -12
  107. package/dist/src/lib/version-check.d.ts +31 -0
  108. package/dist/src/lib/version-check.js +45 -2
  109. package/dist/src/lib/workflow/batch-executor.d.ts +125 -6
  110. package/dist/src/lib/workflow/batch-executor.js +552 -60
  111. package/dist/src/lib/workflow/chain-resume.d.ts +13 -11
  112. package/dist/src/lib/workflow/chain-resume.js +24 -20
  113. package/dist/src/lib/workflow/completed-status.d.ts +52 -0
  114. package/dist/src/lib/workflow/completed-status.js +57 -0
  115. package/dist/src/lib/workflow/config-resolver.d.ts +51 -0
  116. package/dist/src/lib/workflow/config-resolver.js +143 -2
  117. package/dist/src/lib/workflow/drivers/agent-driver.d.ts +22 -0
  118. package/dist/src/lib/workflow/drivers/aider.d.ts +2 -0
  119. package/dist/src/lib/workflow/drivers/aider.js +2 -0
  120. package/dist/src/lib/workflow/drivers/claude-code.d.ts +14 -0
  121. package/dist/src/lib/workflow/drivers/claude-code.js +25 -2
  122. package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
  123. package/dist/src/lib/workflow/effort-escalation.js +82 -0
  124. package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
  125. package/dist/src/lib/workflow/error-classifier.js +4 -0
  126. package/dist/src/lib/workflow/git-diff-utils.d.ts +35 -3
  127. package/dist/src/lib/workflow/git-diff-utils.js +77 -4
  128. package/dist/src/lib/workflow/heartbeat.d.ts +38 -0
  129. package/dist/src/lib/workflow/heartbeat.js +90 -0
  130. package/dist/src/lib/workflow/log-writer.d.ts +54 -2
  131. package/dist/src/lib/workflow/log-writer.js +95 -6
  132. package/dist/src/lib/workflow/metrics-schema.d.ts +52 -9
  133. package/dist/src/lib/workflow/metrics-schema.js +33 -0
  134. package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
  135. package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
  136. package/dist/src/lib/workflow/phase-detection.js +5 -1
  137. package/dist/src/lib/workflow/phase-executor.d.ts +203 -14
  138. package/dist/src/lib/workflow/phase-executor.js +467 -52
  139. package/dist/src/lib/workflow/phase-registry.js +2 -1
  140. package/dist/src/lib/workflow/platforms/github.d.ts +64 -0
  141. package/dist/src/lib/workflow/platforms/github.js +152 -0
  142. package/dist/src/lib/workflow/qa-cache.d.ts +6 -1
  143. package/dist/src/lib/workflow/qa-cache.js +22 -9
  144. package/dist/src/lib/workflow/ready-gate.d.ts +45 -4
  145. package/dist/src/lib/workflow/ready-gate.js +49 -12
  146. package/dist/src/lib/workflow/run-log-schema.d.ts +69 -0
  147. package/dist/src/lib/workflow/run-log-schema.js +78 -2
  148. package/dist/src/lib/workflow/run-orchestrator.d.ts +19 -0
  149. package/dist/src/lib/workflow/run-orchestrator.js +168 -20
  150. package/dist/src/lib/workflow/skills-preflight.d.ts +89 -0
  151. package/dist/src/lib/workflow/skills-preflight.js +112 -0
  152. package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
  153. package/dist/src/lib/workflow/spec-recommendation.js +142 -0
  154. package/dist/src/lib/workflow/state-cleanup.js +9 -2
  155. package/dist/src/lib/workflow/state-manager.d.ts +33 -0
  156. package/dist/src/lib/workflow/state-manager.js +83 -0
  157. package/dist/src/lib/workflow/state-schema.d.ts +26 -0
  158. package/dist/src/lib/workflow/state-schema.js +47 -0
  159. package/dist/src/lib/workflow/types.d.ts +190 -4
  160. package/dist/src/lib/workflow/types.js +1 -0
  161. package/dist/src/lib/workflow/worktree-manager.d.ts +36 -3
  162. package/dist/src/lib/workflow/worktree-manager.js +111 -24
  163. package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
  164. package/dist/src/lib/workflow/worktree-resolver.js +126 -0
  165. package/dist/src/mcp/tools/run.d.ts +9 -1
  166. package/dist/src/mcp/tools/run.js +20 -1
  167. package/dist/src/mcp/tools/status.js +6 -0
  168. package/dist/src/ui/tui/ElapsedTimer.d.ts +12 -3
  169. package/dist/src/ui/tui/ElapsedTimer.js +12 -10
  170. package/dist/src/ui/tui/IssueBox.js +10 -2
  171. package/dist/src/ui/tui/row-cap.js +2 -1
  172. package/dist/src/ui/tui/theme.d.ts +7 -0
  173. package/dist/src/ui/tui/theme.js +9 -0
  174. package/package.json +10 -8
  175. package/templates/hooks/parallel-marker.sh +50 -0
  176. package/templates/hooks/post-tool.sh +20 -7
  177. package/templates/hooks/pre-tool.sh +303 -10
  178. package/templates/scripts/cleanup-worktree.sh +217 -33
  179. package/templates/scripts/new-feature.sh +289 -35
  180. package/templates/skills/_shared/references/subagent-types.md +7 -18
  181. package/templates/skills/_shared/references/trust-model.md +18 -0
  182. package/templates/skills/assess/SKILL.md +220 -391
  183. package/templates/skills/assess/references/predicted-collision-detection.md +1 -1
  184. package/templates/skills/docs/SKILL.md +4 -4
  185. package/templates/skills/exec/SKILL.md +87 -13
  186. package/templates/skills/fullsolve/SKILL.md +130 -22
  187. package/templates/skills/loop/SKILL.md +94 -13
  188. package/templates/skills/merger/SKILL.md +100 -12
  189. package/templates/skills/qa/SKILL.md +396 -63
  190. package/templates/skills/qa/references/anti-pattern-detection.md +6 -6
  191. package/templates/skills/qa/references/call-site-review.md +2 -2
  192. package/templates/skills/qa/references/code-review-checklist.md +2 -2
  193. package/templates/skills/qa/references/fixtures/injection-issue-body.md +36 -0
  194. package/templates/skills/qa/references/quality-gates.md +21 -7
  195. package/templates/skills/qa/references/testing-requirements.md +1 -1
  196. package/templates/skills/qa/scripts/quality-checks.sh +11 -11
  197. package/templates/skills/release/SKILL.md +80 -1
  198. package/templates/skills/setup/SKILL.md +5 -5
  199. package/templates/skills/spec/SKILL.md +33 -15
  200. package/templates/skills/spec/references/recommended-workflow.md +14 -1
  201. package/templates/skills/test/SKILL.md +1 -1
  202. package/templates/skills/testgen/SKILL.md +23 -6
  203. package/dist/src/lib/phase-spinner.d.ts +0 -146
  204. package/dist/src/lib/phase-spinner.js +0 -255
  205. package/dist/src/lib/workflow/pr-operations.d.ts +0 -86
  206. package/dist/src/lib/workflow/pr-operations.js +0 -326
  207. package/dist/src/lib/workflow/run-summary.d.ts +0 -36
  208. package/dist/src/lib/workflow/run-summary.js +0 -142
  209. package/templates/agents/sequant-explorer.md +0 -24
@@ -7,6 +7,8 @@
7
7
  * `state.json`) keeps acquisition atomic — no read-modify-write race.
8
8
  *
9
9
  * Stale detection (in order):
10
+ * 0. Absolute ceiling (any host, any PID state): `startedAt > maxLockAgeMs
11
+ * ago` → cleared. Guards against recycled PIDs and SIGKILL leaks (#856).
10
12
  * 1. `hostname === os.hostname()`: check `process.kill(pid, 0)`.
11
13
  * Not alive → cleared.
12
14
  * 2. Cross-host: PID check is meaningless. Use age only.
@@ -16,7 +18,8 @@
16
18
  * method is a no-op (no fs touches, no warnings). Mirrors the
17
19
  * `OrchestratorRenderer` pattern at `src/lib/cli-ui/run-renderer.ts:244`.
18
20
  */
19
- import { type AcquireResult, type LockFile, type LockListing, type SignalOtherResult } from "./types.js";
21
+ import { renameSync, linkSync } from "fs";
22
+ import { type AcquireResult, type LockFile, type LockListing, type SignalOtherResult, type StaleReason } from "./types.js";
20
23
  export interface LockManagerOptions {
21
24
  /** Directory holding `<issue>.lock` files (default: `.sequant/locks`). */
22
25
  locksDir?: string;
@@ -31,6 +34,12 @@ export interface LockManagerOptions {
31
34
  * the lock has to bridge long /fullsolve runs with multi-iteration QA loops.
32
35
  */
33
36
  skillLockTtlMs?: number;
37
+ /**
38
+ * Absolute age ceiling (ms). A lock older than this is stale regardless of
39
+ * host, PID liveness, or `skipPidCheck`. Default 24h. See
40
+ * `DEFAULT_MAX_LOCK_AGE_MS` (#856).
41
+ */
42
+ maxLockAgeMs?: number;
34
43
  /** Override for orchestrator detection (test seam). */
35
44
  orchestratorMode?: boolean;
36
45
  /** Override for `os.hostname()` (test seam). */
@@ -44,7 +53,11 @@ export interface LockManagerOptions {
44
53
  }
45
54
  /** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
46
55
  export declare function isOrchestratorMode(): boolean;
47
- /** Resolve the locks directory honoring `SEQUANT_LOCKS_DIR` for test isolation. */
56
+ /**
57
+ * Resolve the locks directory. Priority: explicit option > `SEQUANT_LOCKS_DIR`
58
+ * > the shared git checkout root (#909) > plain cwd-relative (non-git
59
+ * fallback, preserves pre-#909 behavior).
60
+ */
48
61
  export declare function resolveLocksDir(explicit?: string): string;
49
62
  /**
50
63
  * Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
@@ -52,6 +65,13 @@ export declare function resolveLocksDir(explicit?: string): string;
52
65
  * caller can fall back to the constructor option / default.
53
66
  */
54
67
  export declare function resolveSkillLockTtlMs(): number | null;
68
+ /**
69
+ * Resolve `SEQUANT_MAX_LOCK_AGE_MS` (milliseconds) — env override for the
70
+ * absolute lock-age ceiling (#856). Returns `null` when unset or unparseable
71
+ * so the caller can fall back to the constructor option / default. Mirrors
72
+ * `resolveSkillLockTtlMs`.
73
+ */
74
+ export declare function resolveMaxLockAgeMs(): number | null;
55
75
  /** Default same-host PID check. `process.kill(pid, 0)` throws if not alive. */
56
76
  export declare function defaultIsPidAlive(pid: number): boolean;
57
77
  /** Build the canonical "issue is in use" error message (AC: error format). */
@@ -67,12 +87,90 @@ export declare function classifyStaleness(args: {
67
87
  staleAgeMs: number;
68
88
  /** TTL for skill-shell (skipPidCheck) locks; falls back to staleAgeMs. */
69
89
  skillLockTtlMs?: number;
90
+ /** Absolute ceiling; falls back to `DEFAULT_MAX_LOCK_AGE_MS`. */
91
+ maxLockAgeMs?: number;
70
92
  isPidAlive: (pid: number) => boolean;
71
- }): "pid-dead" | "age-exceeded" | null;
93
+ }): StaleReason | null;
94
+ /** The fields that uniquely identify a lock's holder (any lock class). */
95
+ export interface StaleLockIdentity {
96
+ pid: number;
97
+ hostname: string;
98
+ startedAt: string;
99
+ }
100
+ /**
101
+ * Atomically steal a stale lock (#908) — the compare-and-swap replacement for
102
+ * the old unlink-then-create, shared by both lock classes so they cannot drift
103
+ * (AC-1, AC-2).
104
+ *
105
+ * `unlink(lockPath)` removes whatever inode is at the path — including a fresh
106
+ * lock a winner created microseconds earlier — so two sessions that both
107
+ * classified the same lock stale could each destroy the other's fresh lock and
108
+ * both "win" (two holders, the exact failure the lock exists to prevent). A
109
+ * bare `rename(lockPath, …)` has the identical flaw: rename moves whatever is
110
+ * at the path, fresh or not.
111
+ *
112
+ * So take possession atomically, THEN check identity:
113
+ *
114
+ * 1. `rename(lockPath → tmp)` — atomic on POSIX. Of two racing stealers,
115
+ * exactly one moves the current inode; the loser gets `ENOENT`.
116
+ * 2. Read what we moved. If it is the stale holder we `classified`, discard
117
+ * it (`unlink tmp`) — the steal is legitimate and `lockPath` is now free
118
+ * for the caller's terminal `openSync(…, "wx")` to claim.
119
+ * 3. If it is NOT that holder, a fresh lock appeared between our staleness
120
+ * read and our rename. We must not destroy it: `link` it back into place
121
+ * (never overwrites) and report the steal lost. A third session that
122
+ * claimed `lockPath` in the gap is left intact and our copy becomes a
123
+ * harmless `*.steal.*` orphan, which `list()` ignores (it matches only
124
+ * `.lock`).
125
+ *
126
+ * Returns `true` only when this caller legitimately removed the stale lock it
127
+ * classified. `false` means "lost" — the caller falls through to `writeAtomic`,
128
+ * whose `O_CREAT|O_EXCL` arbitrates the real holder (accepting the current
129
+ * occupant on `EEXIST`).
130
+ *
131
+ * NOTE ON DEVIATION FROM THE #908 SPEC: the plan prescribed a *plain*
132
+ * rename-away ("rename, unlink tmp, ENOENT = lost", then fall through). That is
133
+ * behaviorally identical to the `unlink` it replaces — verified by a
134
+ * hand-driven interleave: in the issue's own documented ordering
135
+ * (`A.steal → A.create → B.steal → B.create`) B's rename succeeds on A's fresh
136
+ * lock and destroys it, two holders, same as today. The identity check in
137
+ * step 2/3 is what actually makes AC-1 ("loser cannot remove the winner's fresh
138
+ * lock") hold and makes AC-4's mutation test possible.
139
+ *
140
+ * RESIDUAL: the sub-millisecond window at step 3 where a third session's
141
+ * O_EXCL create races our `link`-back is not fully closed — plain lock files
142
+ * admit no atomic compare-and-swap on content. It is far narrower than the
143
+ * original (which failed on a *single* race, every time a stealer's removal
144
+ * landed on a fresh lock) and never destroys a live lock. Fully closing it is a
145
+ * larger protocol change (claim file / lease), flagged for follow-up.
146
+ *
147
+ * NEVER THROWS. A steal is an opportunistic optimization on the acquire path;
148
+ * no filesystem error here is worth crashing `acquire` over. Errors degrade to
149
+ * "lost" (`false`) and the caller's terminal create surfaces any real
150
+ * environment problem (EACCES etc.) with the same errno the pre-#908 path did.
151
+ * The one active recovery: if the `link`-back restore fails because the
152
+ * filesystem refuses hard links (EPERM/ENOTSUP), fall back to renaming `tmp`
153
+ * back into place — leaving a fresh lock renamed-away IS the two-holder bug,
154
+ * so restoring it outweighs `link`'s no-overwrite guarantee on such a
155
+ * filesystem.
156
+ *
157
+ * `ops` is a test seam for the link/rename syscalls — production callers omit
158
+ * it. Injecting a failing `link` is the only way to drive the fallback branch
159
+ * deterministically (capability errors like ENOTSUP cannot be provoked on a
160
+ * normal tmpdir).
161
+ */
162
+ export declare function stealStaleLock(lockPath: string, classified: StaleLockIdentity, self: {
163
+ pid: number;
164
+ now: number;
165
+ }, ops?: {
166
+ link?: typeof linkSync;
167
+ rename?: typeof renameSync;
168
+ }): boolean;
72
169
  export declare class LockManager {
73
170
  private readonly locksDir;
74
171
  private readonly staleAgeMs;
75
172
  private readonly skillLockTtlMs;
173
+ private readonly maxLockAgeMs;
76
174
  private readonly orchestratorMode;
77
175
  private readonly hostname;
78
176
  private readonly pid;
@@ -7,6 +7,8 @@
7
7
  * `state.json`) keeps acquisition atomic — no read-modify-write race.
8
8
  *
9
9
  * Stale detection (in order):
10
+ * 0. Absolute ceiling (any host, any PID state): `startedAt > maxLockAgeMs
11
+ * ago` → cleared. Guards against recycled PIDs and SIGKILL leaks (#856).
10
12
  * 1. `hostname === os.hostname()`: check `process.kill(pid, 0)`.
11
13
  * Not alive → cleared.
12
14
  * 2. Cross-host: PID check is meaningless. Use age only.
@@ -16,18 +18,57 @@
16
18
  * method is a no-op (no fs touches, no warnings). Mirrors the
17
19
  * `OrchestratorRenderer` pattern at `src/lib/cli-ui/run-renderer.ts:244`.
18
20
  */
19
- import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, mkdirSync, readdirSync, statSync, } from "fs";
20
- import { join, resolve } from "path";
21
+ import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, renameSync, linkSync, mkdirSync, readdirSync, statSync, } from "fs";
22
+ import { basename, dirname, join, resolve } from "path";
23
+ import { execFileSync } from "child_process";
21
24
  import * as os from "os";
22
- import { DEFAULT_LOCKS_DIR, DEFAULT_SKILL_LOCK_TTL_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
25
+ import { DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_SKILL_LOCK_TTL_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
23
26
  /** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
24
27
  export function isOrchestratorMode() {
25
28
  return Boolean(process.env.SEQUANT_ORCHESTRATOR);
26
29
  }
27
- /** Resolve the locks directory honoring `SEQUANT_LOCKS_DIR` for test isolation. */
30
+ /**
31
+ * Resolve the checkout root shared by the main worktree and every linked
32
+ * worktree of the same repository (#909). `--git-common-dir` (unlike
33
+ * `--show-toplevel`) points at the same physical `.git` for all of them, so
34
+ * a linked worktree lands on the main checkout's `.sequant/locks` instead of
35
+ * growing its own — matching what `pre-tool.sh`'s checkout guard reads.
36
+ * Returns `null` outside a git repository (or if `git` is unavailable).
37
+ *
38
+ * Deliberately omits `--path-format=absolute` (Git >=2.31, 2021): that flag
39
+ * failing on an older git would fall into the same `catch` as "not a repo"
40
+ * and silently re-open the bug this function exists to fix, with no signal
41
+ * that the cause was an old toolchain rather than a bare directory. Plain
42
+ * `--git-common-dir` (Git >=2.5, 2015) is resolved by hand instead —
43
+ * `resolve(cwd, out)` is a no-op when git already returned an absolute path
44
+ * (the common linked-worktree case) and anchors a relative one (the common
45
+ * main-checkout case) to `cwd`, so it is correct either way.
46
+ */
47
+ function resolveGitCheckoutRoot(cwd) {
48
+ try {
49
+ const commonDir = execFileSync("git", ["rev-parse", "--git-common-dir"], {
50
+ cwd,
51
+ encoding: "utf8",
52
+ stdio: ["ignore", "pipe", "ignore"],
53
+ }).trim();
54
+ return commonDir ? dirname(resolve(cwd, commonDir)) : null;
55
+ }
56
+ catch {
57
+ return null;
58
+ }
59
+ }
60
+ /**
61
+ * Resolve the locks directory. Priority: explicit option > `SEQUANT_LOCKS_DIR`
62
+ * > the shared git checkout root (#909) > plain cwd-relative (non-git
63
+ * fallback, preserves pre-#909 behavior).
64
+ */
28
65
  export function resolveLocksDir(explicit) {
29
66
  const fromEnv = process.env.SEQUANT_LOCKS_DIR;
30
- return resolve(explicit ?? fromEnv ?? DEFAULT_LOCKS_DIR);
67
+ if (explicit !== undefined || fromEnv !== undefined) {
68
+ return resolve(explicit ?? fromEnv ?? DEFAULT_LOCKS_DIR);
69
+ }
70
+ const gitRoot = resolveGitCheckoutRoot(process.cwd());
71
+ return resolve(gitRoot ?? process.cwd(), DEFAULT_LOCKS_DIR);
31
72
  }
32
73
  /**
33
74
  * Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
@@ -43,6 +84,21 @@ export function resolveSkillLockTtlMs() {
43
84
  return null;
44
85
  return ms;
45
86
  }
87
+ /**
88
+ * Resolve `SEQUANT_MAX_LOCK_AGE_MS` (milliseconds) — env override for the
89
+ * absolute lock-age ceiling (#856). Returns `null` when unset or unparseable
90
+ * so the caller can fall back to the constructor option / default. Mirrors
91
+ * `resolveSkillLockTtlMs`.
92
+ */
93
+ export function resolveMaxLockAgeMs() {
94
+ const raw = process.env.SEQUANT_MAX_LOCK_AGE_MS;
95
+ if (raw === undefined || raw === "")
96
+ return null;
97
+ const ms = Number.parseInt(raw, 10);
98
+ if (!Number.isFinite(ms) || ms <= 0)
99
+ return null;
100
+ return ms;
101
+ }
46
102
  /** Default same-host PID check. `process.kill(pid, 0)` throws if not alive. */
47
103
  export function defaultIsPidAlive(pid) {
48
104
  if (!Number.isInteger(pid) || pid <= 0)
@@ -72,6 +128,19 @@ export function formatLockedMessage(issue, holder) {
72
128
  export function classifyStaleness(args) {
73
129
  const { holder, myHostname, now, staleAgeMs, isPidAlive } = args;
74
130
  const skillTtl = args.skillLockTtlMs ?? staleAgeMs;
131
+ const maxAge = args.maxLockAgeMs ?? DEFAULT_MAX_LOCK_AGE_MS;
132
+ const ageMs = now - Date.parse(holder.startedAt);
133
+ const ageKnown = Number.isFinite(ageMs);
134
+ // 0. Absolute ceiling, checked FIRST and unconditionally (#856). The
135
+ // same-host branch below treats a live PID as proof of freshness, but a
136
+ // PID is only a stable identity while its process lives — once the OS
137
+ // recycles it, an abandoned lock points at an unrelated process and
138
+ // reads as fresh forever. Nothing legitimate holds a lock this long
139
+ // (24h vs a 30-minute phase timeout), so age wins over PID liveness
140
+ // past the ceiling. Also the sole recovery path for locks leaked by a
141
+ // SIGKILLed run, whose release handlers never got to run.
142
+ if (ageKnown && ageMs > maxAge)
143
+ return "max-age-exceeded";
75
144
  // 1. Same-host PID check is authoritative — except when the holder asked
76
145
  // us to skip it (skill shells exit before the lock is released; their
77
146
  // PID is dead but the skill is still running in Claude Code).
@@ -85,17 +154,195 @@ export function classifyStaleness(args) {
85
154
  // with multi-iteration QA loops don't lose their own lock; cross-host
86
155
  // uses the stricter staleAgeMs (default 2h).
87
156
  const ttl = holder.skipPidCheck ? skillTtl : staleAgeMs;
88
- const ageMs = now - Date.parse(holder.startedAt);
89
- if (!Number.isFinite(ageMs))
157
+ if (!ageKnown)
90
158
  return null;
91
159
  if (ageMs > ttl)
92
160
  return "age-exceeded";
93
161
  return null;
94
162
  }
163
+ /** Read just the identity fields of a lock file. Null if missing/unparseable. */
164
+ function readLockIdentity(lockPath) {
165
+ if (!existsSync(lockPath))
166
+ return null;
167
+ try {
168
+ const parsed = JSON.parse(readFileSync(lockPath, "utf-8"));
169
+ if (parsed &&
170
+ typeof parsed.pid === "number" &&
171
+ typeof parsed.hostname === "string" &&
172
+ typeof parsed.startedAt === "string") {
173
+ return {
174
+ pid: parsed.pid,
175
+ hostname: parsed.hostname,
176
+ startedAt: parsed.startedAt,
177
+ };
178
+ }
179
+ return null;
180
+ }
181
+ catch {
182
+ return null;
183
+ }
184
+ }
185
+ /**
186
+ * Best-effort unlink for a steal's private `tmp` file. Failure to remove it is
187
+ * never worth crashing `acquire` over — the orphan sweep below reclaims it.
188
+ */
189
+ function unlinkBestEffort(path) {
190
+ try {
191
+ unlinkSync(path);
192
+ }
193
+ catch {
194
+ // Best-effort: swept later by sweepStealOrphans.
195
+ }
196
+ }
197
+ /**
198
+ * Age past which a `<lock>.steal.<pid>.<ts>` file cannot be an in-flight steal
199
+ * (the live window is microseconds) and is reclaimed as an orphan.
200
+ */
201
+ const STEAL_ORPHAN_TTL_MS = 10 * 60 * 1000;
202
+ /**
203
+ * Reap aged `*.steal.*` orphans left behind by lost restore races (the
204
+ * documented EEXIST branch below). Best-effort throughout: steals are the only
205
+ * producer and the only consumer, `list()` never sees these files, and a
206
+ * failure here must not affect the steal itself.
207
+ */
208
+ function sweepStealOrphans(lockPath, now) {
209
+ const prefix = `${basename(lockPath)}.steal.`;
210
+ try {
211
+ const dir = dirname(lockPath);
212
+ for (const name of readdirSync(dir)) {
213
+ if (!name.startsWith(prefix))
214
+ continue;
215
+ const orphan = join(dir, name);
216
+ try {
217
+ if (now - statSync(orphan).mtimeMs > STEAL_ORPHAN_TTL_MS) {
218
+ unlinkSync(orphan);
219
+ }
220
+ }
221
+ catch {
222
+ // Raced away or unreadable — skip it.
223
+ }
224
+ }
225
+ }
226
+ catch {
227
+ // Locks dir unreadable — nothing to sweep.
228
+ }
229
+ }
230
+ /**
231
+ * Atomically steal a stale lock (#908) — the compare-and-swap replacement for
232
+ * the old unlink-then-create, shared by both lock classes so they cannot drift
233
+ * (AC-1, AC-2).
234
+ *
235
+ * `unlink(lockPath)` removes whatever inode is at the path — including a fresh
236
+ * lock a winner created microseconds earlier — so two sessions that both
237
+ * classified the same lock stale could each destroy the other's fresh lock and
238
+ * both "win" (two holders, the exact failure the lock exists to prevent). A
239
+ * bare `rename(lockPath, …)` has the identical flaw: rename moves whatever is
240
+ * at the path, fresh or not.
241
+ *
242
+ * So take possession atomically, THEN check identity:
243
+ *
244
+ * 1. `rename(lockPath → tmp)` — atomic on POSIX. Of two racing stealers,
245
+ * exactly one moves the current inode; the loser gets `ENOENT`.
246
+ * 2. Read what we moved. If it is the stale holder we `classified`, discard
247
+ * it (`unlink tmp`) — the steal is legitimate and `lockPath` is now free
248
+ * for the caller's terminal `openSync(…, "wx")` to claim.
249
+ * 3. If it is NOT that holder, a fresh lock appeared between our staleness
250
+ * read and our rename. We must not destroy it: `link` it back into place
251
+ * (never overwrites) and report the steal lost. A third session that
252
+ * claimed `lockPath` in the gap is left intact and our copy becomes a
253
+ * harmless `*.steal.*` orphan, which `list()` ignores (it matches only
254
+ * `.lock`).
255
+ *
256
+ * Returns `true` only when this caller legitimately removed the stale lock it
257
+ * classified. `false` means "lost" — the caller falls through to `writeAtomic`,
258
+ * whose `O_CREAT|O_EXCL` arbitrates the real holder (accepting the current
259
+ * occupant on `EEXIST`).
260
+ *
261
+ * NOTE ON DEVIATION FROM THE #908 SPEC: the plan prescribed a *plain*
262
+ * rename-away ("rename, unlink tmp, ENOENT = lost", then fall through). That is
263
+ * behaviorally identical to the `unlink` it replaces — verified by a
264
+ * hand-driven interleave: in the issue's own documented ordering
265
+ * (`A.steal → A.create → B.steal → B.create`) B's rename succeeds on A's fresh
266
+ * lock and destroys it, two holders, same as today. The identity check in
267
+ * step 2/3 is what actually makes AC-1 ("loser cannot remove the winner's fresh
268
+ * lock") hold and makes AC-4's mutation test possible.
269
+ *
270
+ * RESIDUAL: the sub-millisecond window at step 3 where a third session's
271
+ * O_EXCL create races our `link`-back is not fully closed — plain lock files
272
+ * admit no atomic compare-and-swap on content. It is far narrower than the
273
+ * original (which failed on a *single* race, every time a stealer's removal
274
+ * landed on a fresh lock) and never destroys a live lock. Fully closing it is a
275
+ * larger protocol change (claim file / lease), flagged for follow-up.
276
+ *
277
+ * NEVER THROWS. A steal is an opportunistic optimization on the acquire path;
278
+ * no filesystem error here is worth crashing `acquire` over. Errors degrade to
279
+ * "lost" (`false`) and the caller's terminal create surfaces any real
280
+ * environment problem (EACCES etc.) with the same errno the pre-#908 path did.
281
+ * The one active recovery: if the `link`-back restore fails because the
282
+ * filesystem refuses hard links (EPERM/ENOTSUP), fall back to renaming `tmp`
283
+ * back into place — leaving a fresh lock renamed-away IS the two-holder bug,
284
+ * so restoring it outweighs `link`'s no-overwrite guarantee on such a
285
+ * filesystem.
286
+ *
287
+ * `ops` is a test seam for the link/rename syscalls — production callers omit
288
+ * it. Injecting a failing `link` is the only way to drive the fallback branch
289
+ * deterministically (capability errors like ENOTSUP cannot be provoked on a
290
+ * normal tmpdir).
291
+ */
292
+ export function stealStaleLock(lockPath, classified, self, ops = {}) {
293
+ const rename = ops.rename ?? renameSync;
294
+ const link = ops.link ?? linkSync;
295
+ const tmp = `${lockPath}.steal.${self.pid}.${self.now}`;
296
+ sweepStealOrphans(lockPath, self.now);
297
+ try {
298
+ rename(lockPath, tmp);
299
+ }
300
+ catch {
301
+ // ENOENT: another stealer moved it first — cleanly lost. Anything else
302
+ // (EACCES, EROFS, …): nothing was moved, so there is nothing to restore;
303
+ // report lost and let the terminal create surface the environment problem.
304
+ return false;
305
+ }
306
+ const moved = readLockIdentity(tmp);
307
+ if (moved &&
308
+ moved.pid === classified.pid &&
309
+ moved.hostname === classified.hostname &&
310
+ moved.startedAt === classified.startedAt) {
311
+ unlinkBestEffort(tmp);
312
+ return true;
313
+ }
314
+ // A fresh (or corrupt) holder slipped in between our read and our rename.
315
+ // Put back exactly what we took, without overwriting a newer claim, then
316
+ // lose the steal.
317
+ try {
318
+ link(tmp, lockPath);
319
+ unlinkBestEffort(tmp);
320
+ }
321
+ catch (err) {
322
+ const code = err.code;
323
+ if (code !== "EEXIST" && code !== "ENOENT") {
324
+ // Hard links refused (EPERM/ENOTSUP/…) — restore by rename instead.
325
+ // The overwrite risk this reintroduces needs a third session's create
326
+ // to land in this same sub-ms window ON a no-hardlink filesystem;
327
+ // not restoring at all destroys the fresh lock every time.
328
+ try {
329
+ rename(tmp, lockPath);
330
+ }
331
+ catch {
332
+ // Out of options — degrades to pre-#908 behavior on this filesystem.
333
+ }
334
+ }
335
+ // EEXIST: `lockPath` was re-claimed in the gap → leave `tmp` as an orphan
336
+ // (swept by sweepStealOrphans) rather than clobber the new holder.
337
+ // ENOENT: `tmp` already gone.
338
+ }
339
+ return false;
340
+ }
95
341
  export class LockManager {
96
342
  locksDir;
97
343
  staleAgeMs;
98
344
  skillLockTtlMs;
345
+ maxLockAgeMs;
99
346
  orchestratorMode;
100
347
  hostname;
101
348
  pid;
@@ -110,6 +357,8 @@ export class LockManager {
110
357
  options.skillLockTtlMs ??
111
358
  resolveSkillLockTtlMs() ??
112
359
  DEFAULT_SKILL_LOCK_TTL_MS;
360
+ this.maxLockAgeMs =
361
+ options.maxLockAgeMs ?? resolveMaxLockAgeMs() ?? DEFAULT_MAX_LOCK_AGE_MS;
113
362
  this.orchestratorMode = options.orchestratorMode ?? isOrchestratorMode();
114
363
  this.hostname = options.hostname ?? os.hostname();
115
364
  this.pid = options.pid ?? process.pid;
@@ -156,10 +405,19 @@ export class LockManager {
156
405
  now: this.now(),
157
406
  staleAgeMs: this.staleAgeMs,
158
407
  skillLockTtlMs: this.skillLockTtlMs,
408
+ maxLockAgeMs: this.maxLockAgeMs,
159
409
  isPidAlive: this.isPidAlive,
160
410
  });
161
411
  if (staleReason) {
162
- this.unlinkSafe(lockPath);
412
+ // Compare-and-swap steal, not a blind unlink (#908): only remove the
413
+ // stale inode we classified, never a fresh lock a racing winner may
414
+ // have created at this path in the meantime. Win or lose, fall through
415
+ // to `writeAtomic` — its `O_CREAT|O_EXCL` arbitrates the real holder.
416
+ stealStaleLock(lockPath, {
417
+ pid: existing.pid,
418
+ hostname: existing.hostname,
419
+ startedAt: existing.startedAt,
420
+ }, { pid: this.pid, now: this.now() });
163
421
  }
164
422
  else {
165
423
  return {
@@ -211,6 +469,15 @@ export class LockManager {
211
469
  if (holder.pid === this.pid || holder.pid === process.ppid) {
212
470
  return { sent: false, reason: "self-or-parent" };
213
471
  }
472
+ // #856: past the absolute ceiling, the PID is no longer trustworthy
473
+ // identity — the OS has almost certainly recycled it onto an unrelated
474
+ // process. `acquire` already treats such a lock as abandoned; signalling
475
+ // it would kill a stranger's program on behalf of a lock nobody holds.
476
+ // The liveness probe below cannot catch this: a recycled PID *is* alive.
477
+ const ageMs = this.now() - Date.parse(holder.startedAt);
478
+ if (Number.isFinite(ageMs) && ageMs > this.maxLockAgeMs) {
479
+ return { sent: false, reason: "stale-pid-untrusted" };
480
+ }
214
481
  if (!this.isPidAlive(holder.pid))
215
482
  return { sent: false, reason: "pid-dead" };
216
483
  try {
@@ -304,6 +571,7 @@ export class LockManager {
304
571
  now,
305
572
  staleAgeMs: this.staleAgeMs,
306
573
  skillLockTtlMs: this.skillLockTtlMs,
574
+ maxLockAgeMs: this.maxLockAgeMs,
307
575
  isPidAlive: this.isPidAlive,
308
576
  });
309
577
  out.push({
@@ -338,6 +606,7 @@ export class LockManager {
338
606
  now: this.now(),
339
607
  staleAgeMs: this.staleAgeMs,
340
608
  skillLockTtlMs: this.skillLockTtlMs,
609
+ maxLockAgeMs: this.maxLockAgeMs,
341
610
  isPidAlive: this.isPidAlive,
342
611
  });
343
612
  if (!staleReason) {
@@ -14,8 +14,32 @@ export declare const DEFAULT_STALE_AGE_MS: number;
14
14
  * Override per-process via `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds).
15
15
  */
16
16
  export declare const DEFAULT_SKILL_LOCK_TTL_MS: number;
17
+ /**
18
+ * Absolute age ceiling (ms) beyond which a lock is stale no matter what
19
+ * (#856). Unlike the two TTLs above this one is NOT conditional on host or
20
+ * PID liveness: the same-host branch of `classifyStaleness` treats a live PID
21
+ * as authoritative proof of freshness, so before this ceiling existed a lock
22
+ * whose PID had been recycled by the OS read as fresh forever and blocked its
23
+ * issue permanently (observed: `505.lock` from 2026-05-14, `708.lock`,
24
+ * `803.lock`). It is also the recovery path for locks leaked by a SIGKILLed
25
+ * run, where no in-process release handler can ever fire.
26
+ *
27
+ * 24h is ~48x the 30-minute default phase timeout and 4x
28
+ * `DEFAULT_SKILL_LOCK_TTL_MS`, so no real run can reach it.
29
+ *
30
+ * Override per-process via `SEQUANT_MAX_LOCK_AGE_MS` (milliseconds).
31
+ */
32
+ export declare const DEFAULT_MAX_LOCK_AGE_MS: number;
17
33
  /** Default lock directory relative to the project root. */
18
34
  export declare const DEFAULT_LOCKS_DIR = ".sequant/locks";
35
+ /**
36
+ * Why a lock is considered stale, or `null` when it is fresh.
37
+ *
38
+ * - `pid-dead` — same-host holder PID is gone.
39
+ * - `age-exceeded` — cross-host / skill-shell lock past its TTL.
40
+ * - `max-age-exceeded` — past the absolute ceiling; applies unconditionally.
41
+ */
42
+ export type StaleReason = "pid-dead" | "age-exceeded" | "max-age-exceeded";
19
43
  /** On-disk lock payload. */
20
44
  export declare const LockFileSchema: z.ZodObject<{
21
45
  pid: z.ZodNumber;
@@ -25,6 +49,78 @@ export declare const LockFileSchema: z.ZodObject<{
25
49
  skipPidCheck: z.ZodOptional<z.ZodBoolean>;
26
50
  }, z.core.$strip>;
27
51
  export type LockFile = z.infer<typeof LockFileSchema>;
52
+ /**
53
+ * Filename of the checkout-scoped lock (#901), stored alongside the numeric
54
+ * `<issue>.lock` files. Deliberately non-numeric so `LockManager.list()` —
55
+ * which parses each filename as an issue number and skips anything else —
56
+ * ignores it rather than surfacing a bogus `NaN` issue.
57
+ */
58
+ export declare const CHECKOUT_LOCK_FILENAME = "checkout.lock";
59
+ /**
60
+ * On-disk payload for the checkout-scoped lock (#901).
61
+ *
62
+ * The per-issue lock from #625 is keyed on issue number, so two sessions
63
+ * working *different* issues take different lock files and never contend —
64
+ * yet `git checkout`, `reset`, `rebase` and `merge` are global to a working
65
+ * tree. This lock represents the tree itself.
66
+ *
67
+ * Extends `LockFileSchema` with:
68
+ * - `issue` — which issue the holder is working on, so a refusal can name it
69
+ * (AC-2) and point the loser at the right worktree (AC-3).
70
+ * - `sessionId` — Claude Code's per-session id, read from the PreToolUse
71
+ * stdin envelope. Optional: it is the *preferred* holder identity because a
72
+ * skill shell's PID dies immediately after acquire, but the hook falls back
73
+ * to `pid`+`hostname` when the envelope does not carry one, so nothing
74
+ * depends on it being present.
75
+ */
76
+ export declare const CheckoutLockFileSchema: z.ZodObject<{
77
+ pid: z.ZodNumber;
78
+ hostname: z.ZodString;
79
+ startedAt: z.ZodString;
80
+ command: z.ZodString;
81
+ skipPidCheck: z.ZodOptional<z.ZodBoolean>;
82
+ issue: z.ZodNumber;
83
+ sessionId: z.ZodOptional<z.ZodString>;
84
+ }, z.core.$strip>;
85
+ export type CheckoutLockFile = z.infer<typeof CheckoutLockFileSchema>;
86
+ /** Identity of the session asking for the checkout lock. */
87
+ export interface CheckoutHolderIdentity {
88
+ /** Claude Code session id, when the caller knows it. */
89
+ sessionId?: string;
90
+ /** Falls back to these when `sessionId` is absent on either side. */
91
+ pid: number;
92
+ hostname: string;
93
+ /**
94
+ * The issue this session is working on (#906).
95
+ *
96
+ * Last-resort ownership proxy for `skipPidCheck` locks, whose PID is dead
97
+ * by the time anything checks it. Optional because a live process releasing
98
+ * its own lock identifies itself by PID and needs none of this — but a
99
+ * skill shell that omits it can no longer release, which is deliberate.
100
+ */
101
+ issue?: number;
102
+ }
103
+ /** Outcome of `CheckoutLock.acquire()`. */
104
+ export type CheckoutAcquireResult = {
105
+ acquired: true;
106
+ lockPath: string;
107
+ /** True when the caller already held it — re-acquire is idempotent. */
108
+ reentrant: boolean;
109
+ } | {
110
+ acquired: false;
111
+ holder: CheckoutLockFile;
112
+ lockPath: string;
113
+ stale: false;
114
+ staleReason: null;
115
+ };
116
+ /** Listing entry for the checkout lock, mirroring `LockListing`. */
117
+ export interface CheckoutLockListing {
118
+ holder: CheckoutLockFile;
119
+ ageMs: number;
120
+ stale: boolean;
121
+ staleReason: StaleReason | null;
122
+ lockPath: string;
123
+ }
28
124
  /** Outcome of `LockManager.acquire()`. */
29
125
  export type AcquireResult = {
30
126
  acquired: true;
@@ -35,7 +131,7 @@ export type AcquireResult = {
35
131
  lockPath: string;
36
132
  /** True when the holder appears stale and could be cleared with `--force`. */
37
133
  stale: boolean;
38
- staleReason?: "pid-dead" | "age-exceeded" | null;
134
+ staleReason?: StaleReason | null;
39
135
  };
40
136
  /** Listing entry from `LockManager.list()`. */
41
137
  export interface LockListing {
@@ -43,7 +139,7 @@ export interface LockListing {
43
139
  holder: LockFile;
44
140
  ageMs: number;
45
141
  stale: boolean;
46
- staleReason: "pid-dead" | "age-exceeded" | null;
142
+ staleReason: StaleReason | null;
47
143
  lockPath: string;
48
144
  }
49
145
  /**
@@ -51,7 +147,14 @@ export interface LockListing {
51
147
  * that previously all collapsed to `false`, so callers can produce accurate
52
148
  * log lines (#637).
53
149
  */
54
- export type SignalReason = "sent" | "orchestrator" | "cross-host" | "self-or-parent" | "pid-dead" | "kill-failed";
150
+ export type SignalReason = "sent" | "orchestrator" | "cross-host" | "self-or-parent" | "pid-dead"
151
+ /**
152
+ * Holder is past the absolute age ceiling, so its PID is not trustworthy
153
+ * identity (#856). Signalling it would target whatever process the OS has
154
+ * since recycled that PID onto — an unrelated program, killed by a
155
+ * `--force --signal-other` aimed at a lock abandoned weeks ago.
156
+ */
157
+ | "stale-pid-untrusted" | "kill-failed";
55
158
  /** Outcome of `LockManager.signalOther()`. */
56
159
  export interface SignalOtherResult {
57
160
  sent: boolean;