sequant 2.10.0 → 2.12.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/.claude-plugin/marketplace.json +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +19 -2
- package/dist/bin/cli.js +47 -2
- package/dist/marketplace/external_plugins/sequant/.claude-plugin/plugin.json +1 -1
- package/dist/marketplace/external_plugins/sequant/.mcp.json +1 -1
- package/dist/marketplace/external_plugins/sequant/hooks/pre-tool.sh +331 -12
- package/dist/marketplace/external_plugins/sequant/skills/_shared/references/subagent-types.md +7 -18
- package/dist/marketplace/external_plugins/sequant/skills/assess/SKILL.md +5 -1
- package/dist/marketplace/external_plugins/sequant/skills/exec/SKILL.md +62 -8
- package/dist/marketplace/external_plugins/sequant/skills/fullsolve/SKILL.md +187 -28
- package/dist/marketplace/external_plugins/sequant/skills/loop/SKILL.md +127 -23
- package/dist/marketplace/external_plugins/sequant/skills/merger/SKILL.md +130 -13
- package/dist/marketplace/external_plugins/sequant/skills/qa/SKILL.md +306 -8
- package/dist/marketplace/external_plugins/sequant/skills/release/SKILL.md +79 -0
- package/dist/marketplace/external_plugins/sequant/skills/spec/SKILL.md +40 -20
- package/dist/marketplace/external_plugins/sequant/skills/spec/references/recommended-workflow.md +14 -1
- package/dist/marketplace/external_plugins/sequant/skills/test/SKILL.md +1 -1
- package/dist/marketplace/external_plugins/sequant/skills/testgen/SKILL.md +23 -6
- package/dist/src/commands/doctor.js +20 -18
- package/dist/src/commands/locks.d.ts +20 -1
- package/dist/src/commands/locks.js +206 -4
- package/dist/src/commands/ready.d.ts +6 -0
- package/dist/src/commands/ready.js +19 -1
- package/dist/src/commands/run-display.js +1 -0
- package/dist/src/commands/worktree.d.ts +31 -0
- package/dist/src/commands/worktree.js +95 -0
- package/dist/src/lib/ac-linter.js +26 -0
- package/dist/src/lib/ac-parser.d.ts +40 -0
- package/dist/src/lib/ac-parser.js +202 -16
- package/dist/src/lib/cli-flags.d.ts +23 -0
- package/dist/src/lib/cli-flags.js +43 -0
- package/dist/src/lib/cli-ui/run-renderer-types.d.ts +2 -0
- package/dist/src/lib/cli-ui/run-renderer.js +7 -1
- package/dist/src/lib/locks/checkout-lock.d.ts +193 -0
- package/dist/src/lib/locks/checkout-lock.js +389 -0
- package/dist/src/lib/locks/index.d.ts +6 -3
- package/dist/src/lib/locks/index.js +4 -2
- package/dist/src/lib/locks/lock-manager.d.ts +81 -1
- package/dist/src/lib/locks/lock-manager.js +230 -5
- package/dist/src/lib/locks/types.d.ts +72 -0
- package/dist/src/lib/locks/types.js +28 -0
- package/dist/src/lib/markdown-fence.d.ts +24 -0
- package/dist/src/lib/markdown-fence.js +51 -0
- package/dist/src/lib/mcp-config.d.ts +24 -0
- package/dist/src/lib/mcp-config.js +51 -0
- package/dist/src/lib/scope/analyzer.d.ts +4 -0
- package/dist/src/lib/scope/analyzer.js +7 -1
- package/dist/src/lib/settings.d.ts +111 -1
- package/dist/src/lib/settings.js +59 -0
- package/dist/src/lib/system.d.ts +7 -3
- package/dist/src/lib/system.js +7 -3
- package/dist/src/lib/test-tautology-detector.d.ts +4 -3
- package/dist/src/lib/test-tautology-detector.js +147 -40
- package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
- package/dist/src/lib/workflow/batch-executor.js +154 -23
- package/dist/src/lib/workflow/config-resolver.d.ts +25 -0
- package/dist/src/lib/workflow/config-resolver.js +90 -0
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +22 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +14 -3
- package/dist/src/lib/workflow/effort-escalation.d.ts +73 -0
- package/dist/src/lib/workflow/effort-escalation.js +82 -0
- package/dist/src/lib/workflow/error-classifier.d.ts +4 -1
- package/dist/src/lib/workflow/error-classifier.js +4 -0
- package/dist/src/lib/workflow/log-writer.d.ts +10 -1
- package/dist/src/lib/workflow/log-writer.js +20 -0
- package/dist/src/lib/workflow/metrics-schema.d.ts +49 -6
- package/dist/src/lib/workflow/metrics-schema.js +33 -0
- package/dist/src/lib/workflow/metrics-writer.d.ts +11 -0
- package/dist/src/lib/workflow/mutation-marker.d.ts +86 -0
- package/dist/src/lib/workflow/mutation-marker.js +97 -0
- package/dist/src/lib/workflow/phase-detection.d.ts +12 -0
- package/dist/src/lib/workflow/phase-detection.js +5 -1
- package/dist/src/lib/workflow/phase-executor.d.ts +17 -0
- package/dist/src/lib/workflow/phase-executor.js +60 -4
- package/dist/src/lib/workflow/qa-gaps-marker.d.ts +38 -0
- package/dist/src/lib/workflow/qa-gaps-marker.js +66 -0
- package/dist/src/lib/workflow/ready-gate.d.ts +53 -1
- package/dist/src/lib/workflow/ready-gate.js +105 -14
- package/dist/src/lib/workflow/run-log-schema.d.ts +175 -0
- package/dist/src/lib/workflow/run-log-schema.js +71 -1
- package/dist/src/lib/workflow/run-orchestrator.js +27 -0
- package/dist/src/lib/workflow/spec-recommendation.d.ts +71 -0
- package/dist/src/lib/workflow/spec-recommendation.js +142 -0
- package/dist/src/lib/workflow/state-schema.d.ts +5 -1
- package/dist/src/lib/workflow/state-schema.js +8 -1
- package/dist/src/lib/workflow/types.d.ts +78 -0
- package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
- package/dist/src/lib/workflow/worktree-manager.js +9 -1
- package/dist/src/lib/workflow/worktree-resolver.d.ts +73 -0
- package/dist/src/lib/workflow/worktree-resolver.js +126 -0
- package/package.json +4 -3
- package/templates/hooks/pre-tool.sh +331 -12
- package/templates/scripts/cleanup-worktree.sh +36 -15
- package/templates/scripts/new-feature.sh +25 -19
- package/templates/skills/_shared/references/subagent-types.md +7 -18
- package/templates/skills/assess/SKILL.md +5 -1
- package/templates/skills/exec/SKILL.md +62 -8
- package/templates/skills/fullsolve/SKILL.md +187 -28
- package/templates/skills/loop/SKILL.md +127 -23
- package/templates/skills/merger/SKILL.md +130 -13
- package/templates/skills/qa/SKILL.md +306 -8
- package/templates/skills/release/SKILL.md +79 -0
- package/templates/skills/spec/SKILL.md +40 -20
- package/templates/skills/spec/references/recommended-workflow.md +14 -1
- package/templates/skills/test/SKILL.md +1 -1
- package/templates/skills/testgen/SKILL.md +23 -6
- package/templates/agents/sequant-explorer.md +0 -24
|
@@ -0,0 +1,389 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CheckoutLock — working-tree-scoped lock (#901).
|
|
3
|
+
*
|
|
4
|
+
* The per-issue lock from #625 keys on issue number, so two sessions working
|
|
5
|
+
* different issues take different lock files and never contend. But
|
|
6
|
+
* `git checkout`, `switch`, `reset`, `rebase`, `merge` and `cherry-pick` are
|
|
7
|
+
* global to a working tree: the contended resource is the *checkout*, not the
|
|
8
|
+
* issue. This lock represents the checkout.
|
|
9
|
+
*
|
|
10
|
+
* Relationship to `LockManager`:
|
|
11
|
+
* - Stale semantics are *shared code*, not a parallel implementation — this
|
|
12
|
+
* class calls the same exported `classifyStaleness`, so the same-host
|
|
13
|
+
* dead-PID rule, the age ceiling and `SEQUANT_MAX_LOCK_AGE_MS` behave
|
|
14
|
+
* identically by construction (AC-4).
|
|
15
|
+
* - `LockManager`'s numeric key is left alone. Widening `lockPathFor` /
|
|
16
|
+
* `acquire` / `release` / `list` / `held` from `number` to `string` would
|
|
17
|
+
* ripple through `status.ts`, `merge.ts`, `resume.ts` and
|
|
18
|
+
* `run-orchestrator.ts`, all of which pass real issue numbers, for the
|
|
19
|
+
* benefit of exactly one new key. The cost of not widening is the
|
|
20
|
+
* duplicated `O_CREAT|O_EXCL` write below (~30 lines).
|
|
21
|
+
*
|
|
22
|
+
* Orchestrator / MCP mode: every public method is a no-op, mirroring
|
|
23
|
+
* `LockManager` (AC-5).
|
|
24
|
+
*/
|
|
25
|
+
import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, mkdirSync, } from "fs";
|
|
26
|
+
import { join } from "path";
|
|
27
|
+
import * as os from "os";
|
|
28
|
+
import { classifyStaleness, defaultIsPidAlive, isOrchestratorMode, resolveLocksDir, resolveMaxLockAgeMs, resolveSkillLockTtlMs, stealStaleLock, } from "./lock-manager.js";
|
|
29
|
+
import { CHECKOUT_LOCK_FILENAME, CheckoutLockFileSchema, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_SKILL_LOCK_TTL_MS, DEFAULT_STALE_AGE_MS, } from "./types.js";
|
|
30
|
+
/**
|
|
31
|
+
* Reserved holder id for `/release`, which mutates the main checkout but has
|
|
32
|
+
* no issue of its own (#911). The lock file and the `pre-tool.sh` guard both
|
|
33
|
+
* key on a positive integer, so the skill claims the tree under this sentinel
|
|
34
|
+
* rather than a symbolic label (which would require schema + CLI + hook
|
|
35
|
+
* changes — tracked in #911 as a follow-up).
|
|
36
|
+
*/
|
|
37
|
+
export const RELEASE_SENTINEL_ISSUE = 999999999;
|
|
38
|
+
/**
|
|
39
|
+
* Render a checkout-lock holder's issue for CLI display. The sentinel is not
|
|
40
|
+
* a real issue, and printing it as `#999999999` invites readers to go looking
|
|
41
|
+
* for one.
|
|
42
|
+
*/
|
|
43
|
+
export function describeCheckoutHolderIssue(issue) {
|
|
44
|
+
return issue === RELEASE_SENTINEL_ISSUE ? "/release (sentinel)" : `#${issue}`;
|
|
45
|
+
}
|
|
46
|
+
/**
|
|
47
|
+
* Does `identity` own the checkout `holder` claimed? (#906)
|
|
48
|
+
*
|
|
49
|
+
* One predicate for both `acquire`'s reentrancy check and `release`'s
|
|
50
|
+
* permission check, so the two cannot disagree about who the holder is: a
|
|
51
|
+
* session able to release by a given identity is exactly the one able to
|
|
52
|
+
* re-acquire by it. Exported for the hook-parity tests.
|
|
53
|
+
*
|
|
54
|
+
* The rules are ordered, and the order is load-bearing:
|
|
55
|
+
*
|
|
56
|
+
* 1. Cross-host callers never own the lock. Checked first — no weaker rule
|
|
57
|
+
* below may overturn it.
|
|
58
|
+
* 2. When *both* sides carry a `sessionId`, equality decides and nothing
|
|
59
|
+
* falls through: a mismatch is positive proof of non-ownership, so
|
|
60
|
+
* consulting a weaker signal afterwards could only overturn a stronger
|
|
61
|
+
* one. (Dormant in the shipped flow — no env var carries Claude Code's
|
|
62
|
+
* session id into a skill shell, so `acquire` never passes one. Kept
|
|
63
|
+
* because leaking a lock for its TTL is the safer failure.)
|
|
64
|
+
* 3. Same PID on the same host: a live process releasing its own lock.
|
|
65
|
+
* 4. `skipPidCheck` locks only: the holder's issue number. A skill shell's
|
|
66
|
+
* PID is dead by the time the next block runs — that is what
|
|
67
|
+
* `skipPidCheck` marks — so the issue is the only identity left, and the
|
|
68
|
+
* hook's blocking side (`pre-tool.sh`) already decides holder-ness the
|
|
69
|
+
* same way. Deliberately a *courtesy* check, not a security boundary:
|
|
70
|
+
* the issue number is readable from the lock file and `clear --force`
|
|
71
|
+
* exists. It defends against the accident this rule was written for — a
|
|
72
|
+
* *blocked* session running its release contract, which by construction
|
|
73
|
+
* carries a different issue.
|
|
74
|
+
* 5. Anything else is refused.
|
|
75
|
+
*/
|
|
76
|
+
export function isCheckoutOwner(holder, identity) {
|
|
77
|
+
if (holder.hostname !== identity.hostname)
|
|
78
|
+
return false;
|
|
79
|
+
if (holder.sessionId && identity.sessionId) {
|
|
80
|
+
return holder.sessionId === identity.sessionId;
|
|
81
|
+
}
|
|
82
|
+
if (holder.pid === identity.pid)
|
|
83
|
+
return true;
|
|
84
|
+
if (holder.skipPidCheck === true &&
|
|
85
|
+
identity.issue !== undefined &&
|
|
86
|
+
identity.issue === holder.issue) {
|
|
87
|
+
return true;
|
|
88
|
+
}
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Build the refusal text for a blocked session (AC-2 + AC-3).
|
|
93
|
+
*
|
|
94
|
+
* AC-2 requires the message name the holding session and its issue; AC-3
|
|
95
|
+
* requires it say how to proceed rather than only reporting the block. Both
|
|
96
|
+
* halves are produced here, in one place, so the CLI and the hook cannot
|
|
97
|
+
* drift on wording.
|
|
98
|
+
*
|
|
99
|
+
* @param holder The session currently holding the checkout.
|
|
100
|
+
* @param blocked The issue the *refused* session is working on, when known —
|
|
101
|
+
* used to name the worktree it should be using instead.
|
|
102
|
+
* @param nowMs Clock, for the human-readable age.
|
|
103
|
+
*/
|
|
104
|
+
export function formatCheckoutLockedMessage(holder, blocked = {}, nowMs = Date.now()) {
|
|
105
|
+
const ageMs = nowMs - Date.parse(holder.startedAt);
|
|
106
|
+
const ageText = Number.isFinite(ageMs)
|
|
107
|
+
? `${Math.max(0, Math.floor(ageMs / 60_000))}m ago`
|
|
108
|
+
: "unknown age";
|
|
109
|
+
const lines = [
|
|
110
|
+
`The working tree is held by the session working #${holder.issue} ` +
|
|
111
|
+
`(PID ${holder.pid} on ${holder.hostname}, started ${holder.startedAt}, ${ageText}).`,
|
|
112
|
+
`Command: ${holder.command}`,
|
|
113
|
+
"",
|
|
114
|
+
"Branch-mutating git operations here would race with that session.",
|
|
115
|
+
"",
|
|
116
|
+
"To proceed:",
|
|
117
|
+
];
|
|
118
|
+
if (blocked.issue !== undefined) {
|
|
119
|
+
lines.push(` • Work in your own worktree instead: ../worktrees/feature/${blocked.issue}-*/`, ` (create it with: ./scripts/new-feature.sh ${blocked.issue})`);
|
|
120
|
+
}
|
|
121
|
+
else {
|
|
122
|
+
lines.push(" • Work in your issue's worktree instead: ../worktrees/feature/<issue>-*/", " (create it with: ./scripts/new-feature.sh <issue>)");
|
|
123
|
+
}
|
|
124
|
+
lines.push(" • Or run the command with `git -C <worktree>` so it does not touch this tree.", " • If that session is gone, clear the stale holder:",
|
|
125
|
+
// `--force` is not optional advice (#906). Plain `clear` refuses a holder
|
|
126
|
+
// that still reads fresh, and a leaked skill-shell lock reads fresh for
|
|
127
|
+
// the full 6h TTL — so the un-forced form fails in exactly the situation
|
|
128
|
+
// that sends someone here.
|
|
129
|
+
" sequant locks checkout clear --force");
|
|
130
|
+
return lines.join("\n");
|
|
131
|
+
}
|
|
132
|
+
export class CheckoutLock {
|
|
133
|
+
locksDir;
|
|
134
|
+
staleAgeMs;
|
|
135
|
+
skillLockTtlMs;
|
|
136
|
+
maxLockAgeMs;
|
|
137
|
+
orchestratorMode;
|
|
138
|
+
hostname;
|
|
139
|
+
pid;
|
|
140
|
+
isPidAlive;
|
|
141
|
+
now;
|
|
142
|
+
constructor(options = {}) {
|
|
143
|
+
this.locksDir = resolveLocksDir(options.locksDir);
|
|
144
|
+
this.staleAgeMs = options.staleAgeMs ?? DEFAULT_STALE_AGE_MS;
|
|
145
|
+
this.skillLockTtlMs =
|
|
146
|
+
options.skillLockTtlMs ??
|
|
147
|
+
resolveSkillLockTtlMs() ??
|
|
148
|
+
DEFAULT_SKILL_LOCK_TTL_MS;
|
|
149
|
+
this.maxLockAgeMs =
|
|
150
|
+
options.maxLockAgeMs ?? resolveMaxLockAgeMs() ?? DEFAULT_MAX_LOCK_AGE_MS;
|
|
151
|
+
this.orchestratorMode = options.orchestratorMode ?? isOrchestratorMode();
|
|
152
|
+
this.hostname = options.hostname ?? os.hostname();
|
|
153
|
+
this.pid = options.pid ?? process.pid;
|
|
154
|
+
this.isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
|
|
155
|
+
this.now = options.now ?? Date.now;
|
|
156
|
+
}
|
|
157
|
+
/** True if all operations are no-ops (orchestrator/MCP mode). */
|
|
158
|
+
get isNoop() {
|
|
159
|
+
return this.orchestratorMode;
|
|
160
|
+
}
|
|
161
|
+
/** Absolute path to the checkout lock file. */
|
|
162
|
+
get lockPath() {
|
|
163
|
+
return join(this.locksDir, CHECKOUT_LOCK_FILENAME);
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* This process's identity, for callers that don't have a session id.
|
|
167
|
+
*
|
|
168
|
+
* Deliberately carries no `issue` (#906): a bare `release()` losing the
|
|
169
|
+
* power to remove a *skill-shell* lock is the fix working, not an omission.
|
|
170
|
+
* A caller that legitimately owns such a lock knows its issue and must say
|
|
171
|
+
* so — `release({ ...lock.selfIdentity, issue })`.
|
|
172
|
+
*/
|
|
173
|
+
get selfIdentity() {
|
|
174
|
+
return { pid: this.pid, hostname: this.hostname };
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* Claim the checkout for `issue`.
|
|
178
|
+
*
|
|
179
|
+
* Re-acquiring while already the holder succeeds idempotently
|
|
180
|
+
* (`reentrant: true`) — a session must not be able to block itself part-way
|
|
181
|
+
* through its own run.
|
|
182
|
+
*/
|
|
183
|
+
acquire(issue, command, options = {}) {
|
|
184
|
+
if (this.orchestratorMode) {
|
|
185
|
+
return { acquired: true, lockPath: "", reentrant: false };
|
|
186
|
+
}
|
|
187
|
+
const lockPath = this.lockPath;
|
|
188
|
+
mkdirSync(this.locksDir, { recursive: true });
|
|
189
|
+
const existing = this.readSafe(lockPath);
|
|
190
|
+
if (existing) {
|
|
191
|
+
// `issue` belongs in the identity for the same reason `release` needs
|
|
192
|
+
// it (#906): without it a session could release its own skill-shell
|
|
193
|
+
// lock by issue but not re-acquire it, and acquire would refuse the
|
|
194
|
+
// holder against its own lock part-way through a run.
|
|
195
|
+
const identity = {
|
|
196
|
+
sessionId: options.sessionId,
|
|
197
|
+
pid: this.pid,
|
|
198
|
+
hostname: this.hostname,
|
|
199
|
+
issue,
|
|
200
|
+
};
|
|
201
|
+
if (isCheckoutOwner(existing, identity)) {
|
|
202
|
+
return { acquired: true, lockPath, reentrant: true };
|
|
203
|
+
}
|
|
204
|
+
const staleReason = this.staleness(existing);
|
|
205
|
+
if (staleReason) {
|
|
206
|
+
// Compare-and-swap steal, not a blind unlink (#908): shared with
|
|
207
|
+
// `LockManager` via `stealStaleLock` so the two lock classes cannot
|
|
208
|
+
// drift. Only the classified stale inode is removed — never a fresh
|
|
209
|
+
// lock a racing winner created at this path. Fall through to
|
|
210
|
+
// `writeAtomic` regardless; its `O_CREAT|O_EXCL` picks the real holder.
|
|
211
|
+
stealStaleLock(lockPath, {
|
|
212
|
+
pid: existing.pid,
|
|
213
|
+
hostname: existing.hostname,
|
|
214
|
+
startedAt: existing.startedAt,
|
|
215
|
+
}, { pid: this.pid, now: this.now() });
|
|
216
|
+
}
|
|
217
|
+
else {
|
|
218
|
+
return {
|
|
219
|
+
acquired: false,
|
|
220
|
+
holder: existing,
|
|
221
|
+
lockPath,
|
|
222
|
+
stale: false,
|
|
223
|
+
staleReason: null,
|
|
224
|
+
};
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return this.writeAtomic(lockPath, issue, command, options);
|
|
228
|
+
}
|
|
229
|
+
/**
|
|
230
|
+
* Release the checkout if `identity` owns it. Returns true when a lock was
|
|
231
|
+
* removed — `false` covers both "nothing held" and "held, but not yours".
|
|
232
|
+
*
|
|
233
|
+
* Ownership is `isCheckoutOwner`, the same predicate `acquire` uses. Before
|
|
234
|
+
* #906 this method took any same-host caller's word for a `skipPidCheck`
|
|
235
|
+
* lock, which made acquire and release asymmetric in the one scenario the
|
|
236
|
+
* lock exists for: a second session's *acquire* was correctly refused while
|
|
237
|
+
* the holder was fresh, but its *release* — which every `/fullsolve` halt
|
|
238
|
+
* branch runs — succeeded and handed the tree away mid-run.
|
|
239
|
+
*
|
|
240
|
+
* `LockManager.releaseExternal` keeps the looser same-host rule safely
|
|
241
|
+
* because its lock *file* is issue-keyed: naming the file already proves the
|
|
242
|
+
* caller knows the issue. `checkout.lock` has a constant filename, so that
|
|
243
|
+
* proof has to move into the identity — which is exactly what rule 4 of
|
|
244
|
+
* `isCheckoutOwner` asks for.
|
|
245
|
+
*/
|
|
246
|
+
release(identity) {
|
|
247
|
+
if (this.orchestratorMode)
|
|
248
|
+
return false;
|
|
249
|
+
const lockPath = this.lockPath;
|
|
250
|
+
const current = this.readSafe(lockPath);
|
|
251
|
+
if (!current)
|
|
252
|
+
return false;
|
|
253
|
+
if (!isCheckoutOwner(current, identity ?? this.selfIdentity))
|
|
254
|
+
return false;
|
|
255
|
+
this.unlinkSafe(lockPath);
|
|
256
|
+
return true;
|
|
257
|
+
}
|
|
258
|
+
/** Read the holder without acquiring. Null when free or unparseable. */
|
|
259
|
+
check() {
|
|
260
|
+
if (this.orchestratorMode)
|
|
261
|
+
return null;
|
|
262
|
+
return this.readSafe(this.lockPath);
|
|
263
|
+
}
|
|
264
|
+
/** Holder plus computed staleness metadata, for `locks list`. */
|
|
265
|
+
listing() {
|
|
266
|
+
if (this.orchestratorMode)
|
|
267
|
+
return null;
|
|
268
|
+
const holder = this.readSafe(this.lockPath);
|
|
269
|
+
if (!holder)
|
|
270
|
+
return null;
|
|
271
|
+
const ageMs = this.now() - Date.parse(holder.startedAt);
|
|
272
|
+
const staleReason = this.staleness(holder);
|
|
273
|
+
return {
|
|
274
|
+
holder,
|
|
275
|
+
ageMs: Number.isFinite(ageMs) ? ageMs : 0,
|
|
276
|
+
stale: staleReason !== null,
|
|
277
|
+
staleReason,
|
|
278
|
+
lockPath: this.lockPath,
|
|
279
|
+
};
|
|
280
|
+
}
|
|
281
|
+
/**
|
|
282
|
+
* Manually clear the checkout lock. With `safetyCheck` (default), refuses to
|
|
283
|
+
* clear a holder that is still fresh — mirrors `LockManager.clearLock`.
|
|
284
|
+
*
|
|
285
|
+
* A file that exists but does not parse is removed unconditionally (#906).
|
|
286
|
+
* That state is reachable: `writeAtomic` creates the file with `openSync`
|
|
287
|
+
* and writes to it as a second step, so a process killed in between leaves a
|
|
288
|
+
* zero-byte `checkout.lock` (#856 documents the group-SIGKILL that does it).
|
|
289
|
+
* Before this branch existed such a file was unclearable by any command —
|
|
290
|
+
* `clear` read it, saw `null`, and reported `no-lock` without unlinking
|
|
291
|
+
* (`--force` only ever reached the *staleness* check, never the read), while
|
|
292
|
+
* `acquire` threw raw `EEXIST` and the hook, unable to parse any field,
|
|
293
|
+
* blocked on it forever. `safetyCheck` is not consulted because there is no
|
|
294
|
+
* holder to protect: unparseable bytes name no session.
|
|
295
|
+
*/
|
|
296
|
+
clear(options = {}) {
|
|
297
|
+
if (this.orchestratorMode) {
|
|
298
|
+
return { cleared: false, reason: "orchestrator-mode" };
|
|
299
|
+
}
|
|
300
|
+
const safetyCheck = options.safetyCheck ?? true;
|
|
301
|
+
const lockPath = this.lockPath;
|
|
302
|
+
const holder = this.readSafe(lockPath);
|
|
303
|
+
if (!holder) {
|
|
304
|
+
if (existsSync(lockPath)) {
|
|
305
|
+
this.unlinkSafe(lockPath);
|
|
306
|
+
return { cleared: true, reason: "cleared-corrupt" };
|
|
307
|
+
}
|
|
308
|
+
return { cleared: false, reason: "no-lock" };
|
|
309
|
+
}
|
|
310
|
+
if (safetyCheck && !this.staleness(holder)) {
|
|
311
|
+
return { cleared: false, reason: "fresh-holder" };
|
|
312
|
+
}
|
|
313
|
+
this.unlinkSafe(lockPath);
|
|
314
|
+
return { cleared: true, reason: "cleared" };
|
|
315
|
+
}
|
|
316
|
+
// ── internals ────────────────────────────────────────────────────────────
|
|
317
|
+
/**
|
|
318
|
+
* Delegates wholesale to the per-issue lock's classifier so the two locks
|
|
319
|
+
* cannot drift on staleness (AC-4).
|
|
320
|
+
*/
|
|
321
|
+
staleness(holder) {
|
|
322
|
+
return classifyStaleness({
|
|
323
|
+
holder,
|
|
324
|
+
myHostname: this.hostname,
|
|
325
|
+
now: this.now(),
|
|
326
|
+
staleAgeMs: this.staleAgeMs,
|
|
327
|
+
skillLockTtlMs: this.skillLockTtlMs,
|
|
328
|
+
maxLockAgeMs: this.maxLockAgeMs,
|
|
329
|
+
isPidAlive: this.isPidAlive,
|
|
330
|
+
});
|
|
331
|
+
}
|
|
332
|
+
writeAtomic(lockPath, issue, command, options) {
|
|
333
|
+
const payload = {
|
|
334
|
+
pid: this.pid,
|
|
335
|
+
hostname: this.hostname,
|
|
336
|
+
startedAt: new Date(this.now()).toISOString(),
|
|
337
|
+
command,
|
|
338
|
+
issue,
|
|
339
|
+
...(options.sessionId ? { sessionId: options.sessionId } : {}),
|
|
340
|
+
...(options.skipPidCheck ? { skipPidCheck: true } : {}),
|
|
341
|
+
};
|
|
342
|
+
let fd;
|
|
343
|
+
try {
|
|
344
|
+
fd = openSync(lockPath, "wx", 0o644);
|
|
345
|
+
}
|
|
346
|
+
catch (err) {
|
|
347
|
+
if (err.code === "EEXIST") {
|
|
348
|
+
const winner = this.readSafe(lockPath);
|
|
349
|
+
if (winner) {
|
|
350
|
+
return {
|
|
351
|
+
acquired: false,
|
|
352
|
+
holder: winner,
|
|
353
|
+
lockPath,
|
|
354
|
+
stale: false,
|
|
355
|
+
staleReason: null,
|
|
356
|
+
};
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
throw err;
|
|
360
|
+
}
|
|
361
|
+
try {
|
|
362
|
+
writeSync(fd, JSON.stringify(payload, null, 2));
|
|
363
|
+
}
|
|
364
|
+
finally {
|
|
365
|
+
closeSync(fd);
|
|
366
|
+
}
|
|
367
|
+
return { acquired: true, lockPath, reentrant: false };
|
|
368
|
+
}
|
|
369
|
+
readSafe(lockPath) {
|
|
370
|
+
if (!existsSync(lockPath))
|
|
371
|
+
return null;
|
|
372
|
+
try {
|
|
373
|
+
const parsed = CheckoutLockFileSchema.safeParse(JSON.parse(readFileSync(lockPath, "utf-8")));
|
|
374
|
+
return parsed.success ? parsed.data : null;
|
|
375
|
+
}
|
|
376
|
+
catch {
|
|
377
|
+
return null;
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
unlinkSafe(lockPath) {
|
|
381
|
+
try {
|
|
382
|
+
unlinkSync(lockPath);
|
|
383
|
+
}
|
|
384
|
+
catch (err) {
|
|
385
|
+
if (err.code !== "ENOENT")
|
|
386
|
+
throw err;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
}
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public surface for the issue-level concurrency lock (#625)
|
|
2
|
+
* Public surface for the issue-level concurrency lock (#625) and the
|
|
3
|
+
* checkout-scoped lock (#901).
|
|
3
4
|
*/
|
|
5
|
+
export { CheckoutLock, RELEASE_SENTINEL_ISSUE, describeCheckoutHolderIssue, formatCheckoutLockedMessage, isCheckoutOwner, } from "./checkout-lock.js";
|
|
6
|
+
export type { CheckoutLockOptions } from "./checkout-lock.js";
|
|
4
7
|
export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, resolveMaxLockAgeMs, } from "./lock-manager.js";
|
|
5
8
|
export type { LockManagerOptions } from "./lock-manager.js";
|
|
6
|
-
export { DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
|
|
7
|
-
export type { AcquireResult, LockFile, LockListing, SignalOtherResult, SignalReason, StaleReason, } from "./types.js";
|
|
9
|
+
export { CHECKOUT_LOCK_FILENAME, CheckoutLockFileSchema, DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
|
|
10
|
+
export type { AcquireResult, CheckoutAcquireResult, CheckoutHolderIdentity, CheckoutLockFile, CheckoutLockListing, LockFile, LockListing, SignalOtherResult, SignalReason, StaleReason, } from "./types.js";
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Public surface for the issue-level concurrency lock (#625)
|
|
2
|
+
* Public surface for the issue-level concurrency lock (#625) and the
|
|
3
|
+
* checkout-scoped lock (#901).
|
|
3
4
|
*/
|
|
5
|
+
export { CheckoutLock, RELEASE_SENTINEL_ISSUE, describeCheckoutHolderIssue, formatCheckoutLockedMessage, isCheckoutOwner, } from "./checkout-lock.js";
|
|
4
6
|
export { LockManager, classifyStaleness, defaultIsPidAlive, formatLockedMessage, isOrchestratorMode, resolveLocksDir, resolveMaxLockAgeMs, } from "./lock-manager.js";
|
|
5
|
-
export { DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
|
|
7
|
+
export { CHECKOUT_LOCK_FILENAME, CheckoutLockFileSchema, DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
|
|
@@ -18,6 +18,7 @@
|
|
|
18
18
|
* method is a no-op (no fs touches, no warnings). Mirrors the
|
|
19
19
|
* `OrchestratorRenderer` pattern at `src/lib/cli-ui/run-renderer.ts:244`.
|
|
20
20
|
*/
|
|
21
|
+
import { renameSync, linkSync } from "fs";
|
|
21
22
|
import { type AcquireResult, type LockFile, type LockListing, type SignalOtherResult, type StaleReason } from "./types.js";
|
|
22
23
|
export interface LockManagerOptions {
|
|
23
24
|
/** Directory holding `<issue>.lock` files (default: `.sequant/locks`). */
|
|
@@ -52,7 +53,11 @@ export interface LockManagerOptions {
|
|
|
52
53
|
}
|
|
53
54
|
/** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
|
|
54
55
|
export declare function isOrchestratorMode(): boolean;
|
|
55
|
-
/**
|
|
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
|
+
*/
|
|
56
61
|
export declare function resolveLocksDir(explicit?: string): string;
|
|
57
62
|
/**
|
|
58
63
|
* Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
|
|
@@ -86,6 +91,81 @@ export declare function classifyStaleness(args: {
|
|
|
86
91
|
maxLockAgeMs?: number;
|
|
87
92
|
isPidAlive: (pid: number) => boolean;
|
|
88
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;
|
|
89
169
|
export declare class LockManager {
|
|
90
170
|
private readonly locksDir;
|
|
91
171
|
private readonly staleAgeMs;
|