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
|
@@ -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;
|
|
@@ -18,18 +18,57 @@
|
|
|
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 { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, mkdirSync, readdirSync, statSync, } from "fs";
|
|
22
|
-
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";
|
|
23
24
|
import * as os from "os";
|
|
24
25
|
import { DEFAULT_LOCKS_DIR, DEFAULT_MAX_LOCK_AGE_MS, DEFAULT_SKILL_LOCK_TTL_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
|
|
25
26
|
/** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
|
|
26
27
|
export function isOrchestratorMode() {
|
|
27
28
|
return Boolean(process.env.SEQUANT_ORCHESTRATOR);
|
|
28
29
|
}
|
|
29
|
-
/**
|
|
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
|
+
*/
|
|
30
65
|
export function resolveLocksDir(explicit) {
|
|
31
66
|
const fromEnv = process.env.SEQUANT_LOCKS_DIR;
|
|
32
|
-
|
|
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);
|
|
33
72
|
}
|
|
34
73
|
/**
|
|
35
74
|
* Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
|
|
@@ -121,6 +160,184 @@ export function classifyStaleness(args) {
|
|
|
121
160
|
return "age-exceeded";
|
|
122
161
|
return null;
|
|
123
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
|
+
}
|
|
124
341
|
export class LockManager {
|
|
125
342
|
locksDir;
|
|
126
343
|
staleAgeMs;
|
|
@@ -192,7 +409,15 @@ export class LockManager {
|
|
|
192
409
|
isPidAlive: this.isPidAlive,
|
|
193
410
|
});
|
|
194
411
|
if (staleReason) {
|
|
195
|
-
|
|
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() });
|
|
196
421
|
}
|
|
197
422
|
else {
|
|
198
423
|
return {
|
|
@@ -49,6 +49,78 @@ export declare const LockFileSchema: z.ZodObject<{
|
|
|
49
49
|
skipPidCheck: z.ZodOptional<z.ZodBoolean>;
|
|
50
50
|
}, z.core.$strip>;
|
|
51
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
|
+
}
|
|
52
124
|
/** Outcome of `LockManager.acquire()`. */
|
|
53
125
|
export type AcquireResult = {
|
|
54
126
|
acquired: true;
|
|
@@ -45,3 +45,31 @@ export const LockFileSchema = z.object({
|
|
|
45
45
|
*/
|
|
46
46
|
skipPidCheck: z.boolean().optional(),
|
|
47
47
|
});
|
|
48
|
+
/**
|
|
49
|
+
* Filename of the checkout-scoped lock (#901), stored alongside the numeric
|
|
50
|
+
* `<issue>.lock` files. Deliberately non-numeric so `LockManager.list()` —
|
|
51
|
+
* which parses each filename as an issue number and skips anything else —
|
|
52
|
+
* ignores it rather than surfacing a bogus `NaN` issue.
|
|
53
|
+
*/
|
|
54
|
+
export const CHECKOUT_LOCK_FILENAME = "checkout.lock";
|
|
55
|
+
/**
|
|
56
|
+
* On-disk payload for the checkout-scoped lock (#901).
|
|
57
|
+
*
|
|
58
|
+
* The per-issue lock from #625 is keyed on issue number, so two sessions
|
|
59
|
+
* working *different* issues take different lock files and never contend —
|
|
60
|
+
* yet `git checkout`, `reset`, `rebase` and `merge` are global to a working
|
|
61
|
+
* tree. This lock represents the tree itself.
|
|
62
|
+
*
|
|
63
|
+
* Extends `LockFileSchema` with:
|
|
64
|
+
* - `issue` — which issue the holder is working on, so a refusal can name it
|
|
65
|
+
* (AC-2) and point the loser at the right worktree (AC-3).
|
|
66
|
+
* - `sessionId` — Claude Code's per-session id, read from the PreToolUse
|
|
67
|
+
* stdin envelope. Optional: it is the *preferred* holder identity because a
|
|
68
|
+
* skill shell's PID dies immediately after acquire, but the hook falls back
|
|
69
|
+
* to `pid`+`hostname` when the envelope does not carry one, so nothing
|
|
70
|
+
* depends on it being present.
|
|
71
|
+
*/
|
|
72
|
+
export const CheckoutLockFileSchema = LockFileSchema.extend({
|
|
73
|
+
issue: z.number().int().positive(),
|
|
74
|
+
sessionId: z.string().optional(),
|
|
75
|
+
});
|
|
@@ -57,6 +57,23 @@ export interface AgentSettings {
|
|
|
57
57
|
*/
|
|
58
58
|
isolateParallel: boolean;
|
|
59
59
|
}
|
|
60
|
+
/**
|
|
61
|
+
* The Agent SDK's closed reasoning-effort enum (#914). Single source of
|
|
62
|
+
* truth — reused by `PhasePolicySchema`'s zod validation below and by
|
|
63
|
+
* `cli-flags.ts:parsePhaseSpecFlag` for the `--efforts` CLI boundary, so the
|
|
64
|
+
* two validation points cannot drift apart on which values are accepted.
|
|
65
|
+
*/
|
|
66
|
+
export declare const EFFORT_LEVELS: readonly ["low", "medium", "high", "xhigh", "max"];
|
|
67
|
+
/**
|
|
68
|
+
* A single phase's `model`/`effort` override for the claude-code driver
|
|
69
|
+
* (#914). See `RunSettings.phases`.
|
|
70
|
+
*/
|
|
71
|
+
export interface PhasePolicy {
|
|
72
|
+
/** Model alias/ID, passed through unvalidated to the Agent SDK. */
|
|
73
|
+
model?: string;
|
|
74
|
+
/** Reasoning effort — validated against the SDK's closed enum. */
|
|
75
|
+
effort?: (typeof EFFORT_LEVELS)[number];
|
|
76
|
+
}
|
|
60
77
|
/**
|
|
61
78
|
* Aider-specific settings for the aider agent driver.
|
|
62
79
|
*/
|
|
@@ -166,6 +183,21 @@ export interface RunSettings {
|
|
|
166
183
|
* Default: true.
|
|
167
184
|
*/
|
|
168
185
|
relay?: boolean;
|
|
186
|
+
/**
|
|
187
|
+
* Per-phase `model`/`effort` overrides for the claude-code driver (#914),
|
|
188
|
+
* keyed by phase name. Absent by default — zero behavior change until
|
|
189
|
+
* opted in. Overridable per-invocation with `--models`/`--efforts`
|
|
190
|
+
* (CLI > settings > absent, resolved by `resolvePhasePolicies` in
|
|
191
|
+
* `config-resolver.ts`).
|
|
192
|
+
*/
|
|
193
|
+
phases?: Record<string, PhasePolicy>;
|
|
194
|
+
/**
|
|
195
|
+
* Evidence-based effort escalation on quality-loop retries (#915). Default
|
|
196
|
+
* `false` — raising effort raises token spend, which is the user's call.
|
|
197
|
+
* Overridable per-invocation with `--escalate-effort` (CLI > settings >
|
|
198
|
+
* default). See `effort-escalation.ts` for the resolver.
|
|
199
|
+
*/
|
|
200
|
+
effortEscalation: boolean;
|
|
169
201
|
}
|
|
170
202
|
/**
|
|
171
203
|
* Scope assessment threshold configuration
|
|
@@ -304,6 +336,25 @@ export declare const AgentSettingsSchema: z.ZodObject<{
|
|
|
304
336
|
}>>;
|
|
305
337
|
isolateParallel: z.ZodDefault<z.ZodBoolean>;
|
|
306
338
|
}, z.core.$strip>;
|
|
339
|
+
/**
|
|
340
|
+
* Zod schema for a single phase's model/effort override (#914).
|
|
341
|
+
*
|
|
342
|
+
* Model aliases/IDs pass through unvalidated — they churn independently of
|
|
343
|
+
* sequant releases, and the Agent SDK's `query()` call errors clearly on a
|
|
344
|
+
* bad one. Effort validates against the SDK's closed enum at settings-parse
|
|
345
|
+
* time since that set is stable and a typo here would otherwise silently
|
|
346
|
+
* fall through to the SDK default.
|
|
347
|
+
*/
|
|
348
|
+
export declare const PhasePolicySchema: z.ZodObject<{
|
|
349
|
+
model: z.ZodOptional<z.ZodString>;
|
|
350
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
351
|
+
low: "low";
|
|
352
|
+
medium: "medium";
|
|
353
|
+
high: "high";
|
|
354
|
+
xhigh: "xhigh";
|
|
355
|
+
max: "max";
|
|
356
|
+
}>>;
|
|
357
|
+
}, z.core.$strip>;
|
|
307
358
|
/** Zod schema for RunSettings */
|
|
308
359
|
export declare const RunSettingsSchema: z.ZodObject<{
|
|
309
360
|
logJson: z.ZodDefault<z.ZodBoolean>;
|
|
@@ -335,6 +386,17 @@ export declare const RunSettingsSchema: z.ZodObject<{
|
|
|
335
386
|
extraArgs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
336
387
|
}, z.core.$strip>>;
|
|
337
388
|
relay: z.ZodDefault<z.ZodBoolean>;
|
|
389
|
+
phases: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
390
|
+
model: z.ZodOptional<z.ZodString>;
|
|
391
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
392
|
+
low: "low";
|
|
393
|
+
medium: "medium";
|
|
394
|
+
high: "high";
|
|
395
|
+
xhigh: "xhigh";
|
|
396
|
+
max: "max";
|
|
397
|
+
}>>;
|
|
398
|
+
}, z.core.$strip>>>;
|
|
399
|
+
effortEscalation: z.ZodDefault<z.ZodBoolean>;
|
|
338
400
|
}, z.core.$strip>;
|
|
339
401
|
/** Zod schema for ScopeThreshold (base — fields required, no defaults) */
|
|
340
402
|
export declare const ScopeThresholdSchema: z.ZodObject<{
|
|
@@ -428,6 +490,17 @@ export declare const SettingsSchema: z.ZodObject<{
|
|
|
428
490
|
extraArgs: z.ZodOptional<z.ZodArray<z.ZodString>>;
|
|
429
491
|
}, z.core.$strip>>;
|
|
430
492
|
relay: z.ZodDefault<z.ZodBoolean>;
|
|
493
|
+
phases: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodObject<{
|
|
494
|
+
model: z.ZodOptional<z.ZodString>;
|
|
495
|
+
effort: z.ZodOptional<z.ZodEnum<{
|
|
496
|
+
low: "low";
|
|
497
|
+
medium: "medium";
|
|
498
|
+
high: "high";
|
|
499
|
+
xhigh: "xhigh";
|
|
500
|
+
max: "max";
|
|
501
|
+
}>>;
|
|
502
|
+
}, z.core.$strip>>>;
|
|
503
|
+
effortEscalation: z.ZodDefault<z.ZodBoolean>;
|
|
431
504
|
}, z.core.$strip>>;
|
|
432
505
|
agents: z.ZodDefault<z.ZodObject<{
|
|
433
506
|
parallel: z.ZodDefault<z.ZodBoolean>;
|
package/dist/src/lib/settings.js
CHANGED
|
@@ -13,10 +13,18 @@
|
|
|
13
13
|
import { readFile, writeFile, fileExists, ensureDir } from "./fs.js";
|
|
14
14
|
import { dirname } from "path";
|
|
15
15
|
import { z } from "zod";
|
|
16
|
+
import { getPhaseNames } from "./workflow/phase-registry.js";
|
|
16
17
|
/** Path to project-level settings file */
|
|
17
18
|
export const SETTINGS_PATH = ".sequant/settings.json";
|
|
18
19
|
/** Current settings schema version */
|
|
19
20
|
export const SETTINGS_VERSION = "1.0";
|
|
21
|
+
/**
|
|
22
|
+
* The Agent SDK's closed reasoning-effort enum (#914). Single source of
|
|
23
|
+
* truth — reused by `PhasePolicySchema`'s zod validation below and by
|
|
24
|
+
* `cli-flags.ts:parsePhaseSpecFlag` for the `--efforts` CLI boundary, so the
|
|
25
|
+
* two validation points cannot drift apart on which values are accepted.
|
|
26
|
+
*/
|
|
27
|
+
export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"];
|
|
20
28
|
// ─── Zod Schemas (AC-1, AC-5) ────────────────────────────────────────────────
|
|
21
29
|
/** Zod schema for RotationSettings */
|
|
22
30
|
export const RotationSettingsSchema = z.object({
|
|
@@ -36,6 +44,19 @@ export const AgentSettingsSchema = z.object({
|
|
|
36
44
|
model: z.enum(["haiku", "sonnet", "opus"]).default("haiku"),
|
|
37
45
|
isolateParallel: z.boolean().default(false),
|
|
38
46
|
});
|
|
47
|
+
/**
|
|
48
|
+
* Zod schema for a single phase's model/effort override (#914).
|
|
49
|
+
*
|
|
50
|
+
* Model aliases/IDs pass through unvalidated — they churn independently of
|
|
51
|
+
* sequant releases, and the Agent SDK's `query()` call errors clearly on a
|
|
52
|
+
* bad one. Effort validates against the SDK's closed enum at settings-parse
|
|
53
|
+
* time since that set is stable and a typo here would otherwise silently
|
|
54
|
+
* fall through to the SDK default.
|
|
55
|
+
*/
|
|
56
|
+
export const PhasePolicySchema = z.object({
|
|
57
|
+
model: z.string().optional(),
|
|
58
|
+
effort: z.enum(EFFORT_LEVELS).optional(),
|
|
59
|
+
});
|
|
39
60
|
/** Zod schema for RunSettings */
|
|
40
61
|
export const RunSettingsSchema = z.object({
|
|
41
62
|
logJson: z.boolean().default(true),
|
|
@@ -66,6 +87,23 @@ export const RunSettingsSchema = z.object({
|
|
|
66
87
|
agent: z.string().optional(),
|
|
67
88
|
aider: AiderSettingsSchema.optional(),
|
|
68
89
|
relay: z.boolean().default(true),
|
|
90
|
+
/**
|
|
91
|
+
* Per-phase `model`/`effort` overrides for the claude-code driver (#914).
|
|
92
|
+
* Absent by default — zero behavior change until opted in. Keyed by phase
|
|
93
|
+
* name (validated against `getPhaseNames()` via `KNOWN_KEYS["run.phases"]`
|
|
94
|
+
* as a non-fatal warning, not a schema-level rejection — a typo'd phase
|
|
95
|
+
* name here should not crash a run the way an invalid `effort` enum does).
|
|
96
|
+
*/
|
|
97
|
+
phases: z.record(z.string(), PhasePolicySchema).optional(),
|
|
98
|
+
/**
|
|
99
|
+
* Evidence-based effort escalation on quality-loop retries (#915). Default
|
|
100
|
+
* `false` — raising effort raises token spend, which is the user's call.
|
|
101
|
+
* When enabled, a retried phase execution (loop iteration ≥ 2, or a
|
|
102
|
+
* `sequant ready` QA-pass loop re-run) resolves one effort tier above its
|
|
103
|
+
* configured/inherited base for that execution only. See
|
|
104
|
+
* `effort-escalation.ts` for the resolver.
|
|
105
|
+
*/
|
|
106
|
+
effortEscalation: z.boolean().default(false),
|
|
69
107
|
});
|
|
70
108
|
/** Zod schema for ScopeThreshold (base — fields required, no defaults) */
|
|
71
109
|
export const ScopeThresholdSchema = z.object({
|
|
@@ -186,7 +224,13 @@ const KNOWN_KEYS = {
|
|
|
186
224
|
"agent",
|
|
187
225
|
"aider",
|
|
188
226
|
"relay",
|
|
227
|
+
"phases",
|
|
189
228
|
]),
|
|
229
|
+
// #914: keyed by real phase name so a typo (`run.phases.exce`) warns
|
|
230
|
+
// instead of silently resolving to nothing. Computed from the registry
|
|
231
|
+
// rather than hardcoded so a new phase registration doesn't need a
|
|
232
|
+
// matching edit here.
|
|
233
|
+
"run.phases": new Set(getPhaseNames()),
|
|
190
234
|
agents: new Set(["parallel", "model", "isolateParallel"]),
|
|
191
235
|
scopeAssessment: new Set([
|
|
192
236
|
"enabled",
|
|
@@ -406,6 +450,7 @@ export const DEFAULT_SETTINGS = {
|
|
|
406
450
|
staleBranchThreshold: 5, // Block QA/test if feature is >5 commits behind main
|
|
407
451
|
resolvedIssueTTL: 7, // Auto-prune resolved issues after 7 days
|
|
408
452
|
relay: true, // Enable interactive relay (#383) by default
|
|
453
|
+
effortEscalation: false, // #915: off by default — raises token spend
|
|
409
454
|
},
|
|
410
455
|
agents: DEFAULT_AGENT_SETTINGS,
|
|
411
456
|
scopeAssessment: DEFAULT_SCOPE_ASSESSMENT_SETTINGS,
|