sequant 2.10.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.
- package/.claude-plugin/plugin.json +1 -1
- package/README.md +6 -2
- package/dist/bin/cli.js +47 -2
- 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 +15 -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/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/settings.d.ts +73 -0
- package/dist/src/lib/settings.js +45 -0
- package/dist/src/lib/test-tautology-detector.d.ts +4 -3
- package/dist/src/lib/test-tautology-detector.js +101 -41
- package/dist/src/lib/workflow/batch-executor.js +78 -19
- package/dist/src/lib/workflow/config-resolver.d.ts +25 -0
- package/dist/src/lib/workflow/config-resolver.js +89 -0
- package/dist/src/lib/workflow/drivers/agent-driver.d.ts +15 -0
- package/dist/src/lib/workflow/drivers/claude-code.js +5 -0
- 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/phase-detection.d.ts +12 -0
- package/dist/src/lib/workflow/phase-detection.js +5 -1
- package/dist/src/lib/workflow/phase-executor.js +10 -0
- package/dist/src/lib/workflow/ready-gate.d.ts +28 -0
- package/dist/src/lib/workflow/ready-gate.js +24 -3
- package/dist/src/lib/workflow/run-log-schema.d.ts +55 -0
- package/dist/src/lib/workflow/run-log-schema.js +31 -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/types.d.ts +64 -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 +3 -2
- package/templates/hooks/pre-tool.sh +228 -0
- 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 +61 -7
- package/templates/skills/fullsolve/SKILL.md +127 -21
- package/templates/skills/loop/SKILL.md +56 -11
- package/templates/skills/merger/SKILL.md +98 -10
- package/templates/skills/qa/SKILL.md +59 -6
- package/templates/skills/release/SKILL.md +79 -0
- package/templates/skills/spec/SKILL.md +31 -15
- package/templates/skills/spec/references/recommended-workflow.md +14 -1
- package/templates/skills/testgen/SKILL.md +23 -6
- package/templates/agents/sequant-explorer.md +0 -24
|
@@ -1332,7 +1332,13 @@ function renderSummaryDetail(r, ctx) {
|
|
|
1332
1332
|
.map((p) => (p.success ? c.green(p.name) : c.red(p.name)))
|
|
1333
1333
|
.join(" → ");
|
|
1334
1334
|
const pr = r.prNumber ? ` · PR #${r.prNumber}` : "";
|
|
1335
|
-
|
|
1335
|
+
// #920: a phase-restricted run (no PR, no failure) still needs the reason
|
|
1336
|
+
// stated — otherwise a passing spec-only run looks indistinguishable from
|
|
1337
|
+
// one where PR creation was simply never attempted for no stated reason.
|
|
1338
|
+
const extras = !r.prNumber && r.prSkippedReason
|
|
1339
|
+
? [c.dim(`PR skipped — ${r.prSkippedReason}`)]
|
|
1340
|
+
: [];
|
|
1341
|
+
return { summary: `${phaseSeq}${pr}`, extras };
|
|
1336
1342
|
}
|
|
1337
1343
|
// Failed → multi-line detail.
|
|
1338
1344
|
const reason = r.failureReason ?? "failure";
|
|
@@ -0,0 +1,193 @@
|
|
|
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 { type CheckoutAcquireResult, type CheckoutHolderIdentity, type CheckoutLockFile, type CheckoutLockListing } from "./types.js";
|
|
26
|
+
/**
|
|
27
|
+
* Reserved holder id for `/release`, which mutates the main checkout but has
|
|
28
|
+
* no issue of its own (#911). The lock file and the `pre-tool.sh` guard both
|
|
29
|
+
* key on a positive integer, so the skill claims the tree under this sentinel
|
|
30
|
+
* rather than a symbolic label (which would require schema + CLI + hook
|
|
31
|
+
* changes — tracked in #911 as a follow-up).
|
|
32
|
+
*/
|
|
33
|
+
export declare const RELEASE_SENTINEL_ISSUE = 999999999;
|
|
34
|
+
/**
|
|
35
|
+
* Render a checkout-lock holder's issue for CLI display. The sentinel is not
|
|
36
|
+
* a real issue, and printing it as `#999999999` invites readers to go looking
|
|
37
|
+
* for one.
|
|
38
|
+
*/
|
|
39
|
+
export declare function describeCheckoutHolderIssue(issue: number): string;
|
|
40
|
+
export interface CheckoutLockOptions {
|
|
41
|
+
/** Directory holding lock files (default: `.sequant/locks`). */
|
|
42
|
+
locksDir?: string;
|
|
43
|
+
/** Age cutoff (ms) for cross-host locks. Default 2h. */
|
|
44
|
+
staleAgeMs?: number;
|
|
45
|
+
/** Age cutoff (ms) for skill-shell locks (`skipPidCheck`). Default 6h. */
|
|
46
|
+
skillLockTtlMs?: number;
|
|
47
|
+
/** Absolute age ceiling (ms). Default 24h (#856). */
|
|
48
|
+
maxLockAgeMs?: number;
|
|
49
|
+
/** Override for orchestrator detection (test seam). */
|
|
50
|
+
orchestratorMode?: boolean;
|
|
51
|
+
/** Override for `os.hostname()` (test seam). */
|
|
52
|
+
hostname?: string;
|
|
53
|
+
/** Override for current process PID (test seam). */
|
|
54
|
+
pid?: number;
|
|
55
|
+
/** Predicate: is PID alive on this host? (test seam) */
|
|
56
|
+
isPidAlive?: (pid: number) => boolean;
|
|
57
|
+
/** Clock (ms since epoch). Test seam. */
|
|
58
|
+
now?: () => number;
|
|
59
|
+
}
|
|
60
|
+
/**
|
|
61
|
+
* Does `identity` own the checkout `holder` claimed? (#906)
|
|
62
|
+
*
|
|
63
|
+
* One predicate for both `acquire`'s reentrancy check and `release`'s
|
|
64
|
+
* permission check, so the two cannot disagree about who the holder is: a
|
|
65
|
+
* session able to release by a given identity is exactly the one able to
|
|
66
|
+
* re-acquire by it. Exported for the hook-parity tests.
|
|
67
|
+
*
|
|
68
|
+
* The rules are ordered, and the order is load-bearing:
|
|
69
|
+
*
|
|
70
|
+
* 1. Cross-host callers never own the lock. Checked first — no weaker rule
|
|
71
|
+
* below may overturn it.
|
|
72
|
+
* 2. When *both* sides carry a `sessionId`, equality decides and nothing
|
|
73
|
+
* falls through: a mismatch is positive proof of non-ownership, so
|
|
74
|
+
* consulting a weaker signal afterwards could only overturn a stronger
|
|
75
|
+
* one. (Dormant in the shipped flow — no env var carries Claude Code's
|
|
76
|
+
* session id into a skill shell, so `acquire` never passes one. Kept
|
|
77
|
+
* because leaking a lock for its TTL is the safer failure.)
|
|
78
|
+
* 3. Same PID on the same host: a live process releasing its own lock.
|
|
79
|
+
* 4. `skipPidCheck` locks only: the holder's issue number. A skill shell's
|
|
80
|
+
* PID is dead by the time the next block runs — that is what
|
|
81
|
+
* `skipPidCheck` marks — so the issue is the only identity left, and the
|
|
82
|
+
* hook's blocking side (`pre-tool.sh`) already decides holder-ness the
|
|
83
|
+
* same way. Deliberately a *courtesy* check, not a security boundary:
|
|
84
|
+
* the issue number is readable from the lock file and `clear --force`
|
|
85
|
+
* exists. It defends against the accident this rule was written for — a
|
|
86
|
+
* *blocked* session running its release contract, which by construction
|
|
87
|
+
* carries a different issue.
|
|
88
|
+
* 5. Anything else is refused.
|
|
89
|
+
*/
|
|
90
|
+
export declare function isCheckoutOwner(holder: CheckoutLockFile, identity: CheckoutHolderIdentity): boolean;
|
|
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 declare function formatCheckoutLockedMessage(holder: CheckoutLockFile, blocked?: {
|
|
105
|
+
issue?: number;
|
|
106
|
+
}, nowMs?: number): string;
|
|
107
|
+
export declare class CheckoutLock {
|
|
108
|
+
private readonly locksDir;
|
|
109
|
+
private readonly staleAgeMs;
|
|
110
|
+
private readonly skillLockTtlMs;
|
|
111
|
+
private readonly maxLockAgeMs;
|
|
112
|
+
private readonly orchestratorMode;
|
|
113
|
+
private readonly hostname;
|
|
114
|
+
private readonly pid;
|
|
115
|
+
private readonly isPidAlive;
|
|
116
|
+
private readonly now;
|
|
117
|
+
constructor(options?: CheckoutLockOptions);
|
|
118
|
+
/** True if all operations are no-ops (orchestrator/MCP mode). */
|
|
119
|
+
get isNoop(): boolean;
|
|
120
|
+
/** Absolute path to the checkout lock file. */
|
|
121
|
+
get lockPath(): string;
|
|
122
|
+
/**
|
|
123
|
+
* This process's identity, for callers that don't have a session id.
|
|
124
|
+
*
|
|
125
|
+
* Deliberately carries no `issue` (#906): a bare `release()` losing the
|
|
126
|
+
* power to remove a *skill-shell* lock is the fix working, not an omission.
|
|
127
|
+
* A caller that legitimately owns such a lock knows its issue and must say
|
|
128
|
+
* so — `release({ ...lock.selfIdentity, issue })`.
|
|
129
|
+
*/
|
|
130
|
+
get selfIdentity(): CheckoutHolderIdentity;
|
|
131
|
+
/**
|
|
132
|
+
* Claim the checkout for `issue`.
|
|
133
|
+
*
|
|
134
|
+
* Re-acquiring while already the holder succeeds idempotently
|
|
135
|
+
* (`reentrant: true`) — a session must not be able to block itself part-way
|
|
136
|
+
* through its own run.
|
|
137
|
+
*/
|
|
138
|
+
acquire(issue: number, command: string, options?: {
|
|
139
|
+
sessionId?: string;
|
|
140
|
+
skipPidCheck?: boolean;
|
|
141
|
+
}): CheckoutAcquireResult;
|
|
142
|
+
/**
|
|
143
|
+
* Release the checkout if `identity` owns it. Returns true when a lock was
|
|
144
|
+
* removed — `false` covers both "nothing held" and "held, but not yours".
|
|
145
|
+
*
|
|
146
|
+
* Ownership is `isCheckoutOwner`, the same predicate `acquire` uses. Before
|
|
147
|
+
* #906 this method took any same-host caller's word for a `skipPidCheck`
|
|
148
|
+
* lock, which made acquire and release asymmetric in the one scenario the
|
|
149
|
+
* lock exists for: a second session's *acquire* was correctly refused while
|
|
150
|
+
* the holder was fresh, but its *release* — which every `/fullsolve` halt
|
|
151
|
+
* branch runs — succeeded and handed the tree away mid-run.
|
|
152
|
+
*
|
|
153
|
+
* `LockManager.releaseExternal` keeps the looser same-host rule safely
|
|
154
|
+
* because its lock *file* is issue-keyed: naming the file already proves the
|
|
155
|
+
* caller knows the issue. `checkout.lock` has a constant filename, so that
|
|
156
|
+
* proof has to move into the identity — which is exactly what rule 4 of
|
|
157
|
+
* `isCheckoutOwner` asks for.
|
|
158
|
+
*/
|
|
159
|
+
release(identity?: CheckoutHolderIdentity): boolean;
|
|
160
|
+
/** Read the holder without acquiring. Null when free or unparseable. */
|
|
161
|
+
check(): CheckoutLockFile | null;
|
|
162
|
+
/** Holder plus computed staleness metadata, for `locks list`. */
|
|
163
|
+
listing(): CheckoutLockListing | null;
|
|
164
|
+
/**
|
|
165
|
+
* Manually clear the checkout lock. With `safetyCheck` (default), refuses to
|
|
166
|
+
* clear a holder that is still fresh — mirrors `LockManager.clearLock`.
|
|
167
|
+
*
|
|
168
|
+
* A file that exists but does not parse is removed unconditionally (#906).
|
|
169
|
+
* That state is reachable: `writeAtomic` creates the file with `openSync`
|
|
170
|
+
* and writes to it as a second step, so a process killed in between leaves a
|
|
171
|
+
* zero-byte `checkout.lock` (#856 documents the group-SIGKILL that does it).
|
|
172
|
+
* Before this branch existed such a file was unclearable by any command —
|
|
173
|
+
* `clear` read it, saw `null`, and reported `no-lock` without unlinking
|
|
174
|
+
* (`--force` only ever reached the *staleness* check, never the read), while
|
|
175
|
+
* `acquire` threw raw `EEXIST` and the hook, unable to parse any field,
|
|
176
|
+
* blocked on it forever. `safetyCheck` is not consulted because there is no
|
|
177
|
+
* holder to protect: unparseable bytes name no session.
|
|
178
|
+
*/
|
|
179
|
+
clear(options?: {
|
|
180
|
+
safetyCheck?: boolean;
|
|
181
|
+
}): {
|
|
182
|
+
cleared: boolean;
|
|
183
|
+
reason: string;
|
|
184
|
+
};
|
|
185
|
+
/**
|
|
186
|
+
* Delegates wholesale to the per-issue lock's classifier so the two locks
|
|
187
|
+
* cannot drift on staleness (AC-4).
|
|
188
|
+
*/
|
|
189
|
+
private staleness;
|
|
190
|
+
private writeAtomic;
|
|
191
|
+
private readSafe;
|
|
192
|
+
private unlinkSafe;
|
|
193
|
+
}
|
|
@@ -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";
|