sequant 2.2.0 → 2.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/README.md +73 -0
  4. package/dist/bin/cli.js +94 -9
  5. package/dist/src/commands/doctor.d.ts +25 -0
  6. package/dist/src/commands/doctor.js +36 -1
  7. package/dist/src/commands/locks.d.ts +67 -0
  8. package/dist/src/commands/locks.js +290 -0
  9. package/dist/src/commands/merge.js +11 -0
  10. package/dist/src/commands/prompt.d.ts +39 -0
  11. package/dist/src/commands/prompt.js +179 -0
  12. package/dist/src/commands/run-display.d.ts +11 -2
  13. package/dist/src/commands/run-display.js +62 -28
  14. package/dist/src/commands/run-progress.d.ts +32 -0
  15. package/dist/src/commands/run-progress.js +76 -0
  16. package/dist/src/commands/run.js +80 -18
  17. package/dist/src/commands/stats.d.ts +2 -0
  18. package/dist/src/commands/stats.js +94 -8
  19. package/dist/src/commands/status.js +12 -0
  20. package/dist/src/commands/watch.d.ts +16 -0
  21. package/dist/src/commands/watch.js +147 -0
  22. package/dist/src/lib/ac-linter.d.ts +1 -1
  23. package/dist/src/lib/ac-linter.js +81 -0
  24. package/dist/src/lib/assess-collision-detect.d.ts +91 -0
  25. package/dist/src/lib/assess-collision-detect.js +217 -0
  26. package/dist/src/lib/assess-comment-parser.d.ts +59 -1
  27. package/dist/src/lib/assess-comment-parser.js +124 -2
  28. package/dist/src/lib/cli-ui/format.d.ts +19 -0
  29. package/dist/src/lib/cli-ui/format.js +34 -0
  30. package/dist/src/lib/cli-ui/run-renderer-types.d.ts +181 -0
  31. package/dist/src/lib/cli-ui/run-renderer-types.js +7 -0
  32. package/dist/src/lib/cli-ui/run-renderer.d.ts +239 -0
  33. package/dist/src/lib/cli-ui/run-renderer.js +1173 -0
  34. package/dist/src/lib/heuristics/behavior-rule-detector.d.ts +94 -0
  35. package/dist/src/lib/heuristics/behavior-rule-detector.js +467 -0
  36. package/dist/src/lib/locks/index.d.ts +7 -0
  37. package/dist/src/lib/locks/index.js +5 -0
  38. package/dist/src/lib/locks/lock-manager.d.ts +168 -0
  39. package/dist/src/lib/locks/lock-manager.js +433 -0
  40. package/dist/src/lib/locks/types.d.ts +59 -0
  41. package/dist/src/lib/locks/types.js +31 -0
  42. package/dist/src/lib/qa/markdown-only-ci.d.ts +46 -0
  43. package/dist/src/lib/qa/markdown-only-ci.js +74 -0
  44. package/dist/src/lib/relay/activation.d.ts +60 -0
  45. package/dist/src/lib/relay/activation.js +122 -0
  46. package/dist/src/lib/relay/archive.d.ts +34 -0
  47. package/dist/src/lib/relay/archive.js +106 -0
  48. package/dist/src/lib/relay/frame.d.ts +20 -0
  49. package/dist/src/lib/relay/frame.js +76 -0
  50. package/dist/src/lib/relay/index.d.ts +13 -0
  51. package/dist/src/lib/relay/index.js +13 -0
  52. package/dist/src/lib/relay/paths.d.ts +43 -0
  53. package/dist/src/lib/relay/paths.js +59 -0
  54. package/dist/src/lib/relay/pid.d.ts +34 -0
  55. package/dist/src/lib/relay/pid.js +72 -0
  56. package/dist/src/lib/relay/reader.d.ts +35 -0
  57. package/dist/src/lib/relay/reader.js +115 -0
  58. package/dist/src/lib/relay/types.d.ts +68 -0
  59. package/dist/src/lib/relay/types.js +76 -0
  60. package/dist/src/lib/relay/writer.d.ts +48 -0
  61. package/dist/src/lib/relay/writer.js +113 -0
  62. package/dist/src/lib/settings.d.ts +31 -1
  63. package/dist/src/lib/settings.js +18 -3
  64. package/dist/src/lib/version-check.d.ts +60 -5
  65. package/dist/src/lib/version-check.js +97 -9
  66. package/dist/src/lib/workflow/batch-executor.d.ts +20 -1
  67. package/dist/src/lib/workflow/batch-executor.js +248 -175
  68. package/dist/src/lib/workflow/config-resolver.js +4 -0
  69. package/dist/src/lib/workflow/heartbeat.d.ts +71 -0
  70. package/dist/src/lib/workflow/heartbeat.js +194 -0
  71. package/dist/src/lib/workflow/phase-executor.d.ts +62 -8
  72. package/dist/src/lib/workflow/phase-executor.js +157 -16
  73. package/dist/src/lib/workflow/phase-mapper.d.ts +3 -2
  74. package/dist/src/lib/workflow/phase-mapper.js +17 -20
  75. package/dist/src/lib/workflow/platforms/github.d.ts +1 -1
  76. package/dist/src/lib/workflow/platforms/github.js +20 -3
  77. package/dist/src/lib/workflow/pr-status.d.ts +18 -2
  78. package/dist/src/lib/workflow/pr-status.js +41 -9
  79. package/dist/src/lib/workflow/qa-stagnation.d.ts +117 -0
  80. package/dist/src/lib/workflow/qa-stagnation.js +179 -0
  81. package/dist/src/lib/workflow/run-orchestrator.d.ts +39 -0
  82. package/dist/src/lib/workflow/run-orchestrator.js +340 -15
  83. package/dist/src/lib/workflow/run-reflect.js +1 -1
  84. package/dist/src/lib/workflow/run-state.d.ts +71 -0
  85. package/dist/src/lib/workflow/run-state.js +14 -0
  86. package/dist/src/lib/workflow/state-cleanup.d.ts +13 -5
  87. package/dist/src/lib/workflow/state-cleanup.js +17 -5
  88. package/dist/src/lib/workflow/state-manager.d.ts +12 -1
  89. package/dist/src/lib/workflow/state-manager.js +37 -0
  90. package/dist/src/lib/workflow/state-schema.d.ts +62 -0
  91. package/dist/src/lib/workflow/state-schema.js +35 -1
  92. package/dist/src/lib/workflow/types.d.ts +74 -1
  93. package/dist/src/lib/workflow/worktree-manager.d.ts +8 -1
  94. package/dist/src/lib/workflow/worktree-manager.js +15 -6
  95. package/dist/src/mcp/tools/run.d.ts +44 -0
  96. package/dist/src/mcp/tools/run.js +104 -13
  97. package/dist/src/ui/tui/App.d.ts +14 -0
  98. package/dist/src/ui/tui/App.js +41 -0
  99. package/dist/src/ui/tui/ElapsedTimer.d.ts +10 -0
  100. package/dist/src/ui/tui/ElapsedTimer.js +31 -0
  101. package/dist/src/ui/tui/Header.d.ts +6 -0
  102. package/dist/src/ui/tui/Header.js +15 -0
  103. package/dist/src/ui/tui/IssueBox.d.ts +16 -0
  104. package/dist/src/ui/tui/IssueBox.js +68 -0
  105. package/dist/src/ui/tui/Spinner.d.ts +9 -0
  106. package/dist/src/ui/tui/Spinner.js +18 -0
  107. package/dist/src/ui/tui/index.d.ts +15 -0
  108. package/dist/src/ui/tui/index.js +29 -0
  109. package/dist/src/ui/tui/theme.d.ts +29 -0
  110. package/dist/src/ui/tui/theme.js +52 -0
  111. package/dist/src/ui/tui/truncate.d.ts +11 -0
  112. package/dist/src/ui/tui/truncate.js +31 -0
  113. package/package.json +10 -3
  114. package/templates/agents/sequant-explorer.md +1 -0
  115. package/templates/agents/sequant-qa-checker.md +2 -1
  116. package/templates/agents/sequant-testgen.md +1 -0
  117. package/templates/hooks/post-tool.sh +11 -0
  118. package/templates/hooks/pre-tool.sh +18 -9
  119. package/templates/hooks/relay-check.sh +107 -0
  120. package/templates/relay/frame.txt +11 -0
  121. package/templates/scripts/cleanup-worktree.sh +25 -3
  122. package/templates/scripts/new-feature.sh +6 -0
  123. package/templates/skills/_shared/references/behavior-rule-detection.md +205 -0
  124. package/templates/skills/_shared/references/subagent-types.md +21 -8
  125. package/templates/skills/assess/SKILL.md +103 -49
  126. package/templates/skills/assess/references/predicted-collision-detection.md +109 -0
  127. package/templates/skills/docs/SKILL.md +141 -22
  128. package/templates/skills/exec/SKILL.md +10 -8
  129. package/templates/skills/fullsolve/SKILL.md +79 -5
  130. package/templates/skills/loop/SKILL.md +28 -0
  131. package/templates/skills/merger/SKILL.md +621 -0
  132. package/templates/skills/qa/SKILL.md +727 -8
  133. package/templates/skills/setup/SKILL.md +6 -0
  134. package/templates/skills/spec/SKILL.md +52 -0
  135. package/templates/skills/spec/references/parallel-groups.md +7 -0
  136. package/templates/skills/spec/references/recommended-workflow.md +4 -2
  137. package/templates/skills/testgen/SKILL.md +24 -17
@@ -0,0 +1,168 @@
1
+ /**
2
+ * LockManager — per-issue filesystem lock to prevent concurrent sequant
3
+ * sessions from targeting the same issue (#625).
4
+ *
5
+ * Each lock is a single file at `<locksDir>/<issue>.lock`, claimed via
6
+ * `open(O_CREAT|O_EXCL)`. A separate file (rather than a field inside
7
+ * `state.json`) keeps acquisition atomic — no read-modify-write race.
8
+ *
9
+ * Stale detection (in order):
10
+ * 1. `hostname === os.hostname()`: check `process.kill(pid, 0)`.
11
+ * Not alive → cleared.
12
+ * 2. Cross-host: PID check is meaningless. Use age only.
13
+ * 3. Age fallback (any host): `startedAt > staleAgeMs ago` → cleared.
14
+ *
15
+ * MCP / orchestrator mode: when `SEQUANT_ORCHESTRATOR` is set, every public
16
+ * method is a no-op (no fs touches, no warnings). Mirrors the
17
+ * `OrchestratorRenderer` pattern at `src/lib/cli-ui/run-renderer.ts:244`.
18
+ */
19
+ import { type AcquireResult, type LockFile, type LockListing, type SignalOtherResult } from "./types.js";
20
+ export interface LockManagerOptions {
21
+ /** Directory holding `<issue>.lock` files (default: `.sequant/locks`). */
22
+ locksDir?: string;
23
+ /**
24
+ * Age cutoff (ms) before a cross-host lock is considered stale by time.
25
+ * Default 2h. Does NOT apply to skill-shell locks — see `skillLockTtlMs`.
26
+ */
27
+ staleAgeMs?: number;
28
+ /**
29
+ * Age cutoff (ms) for skill-shell locks (`skipPidCheck: true`). Default 6h.
30
+ * Longer than `staleAgeMs` because skill shells can't refresh PID liveness;
31
+ * the lock has to bridge long /fullsolve runs with multi-iteration QA loops.
32
+ */
33
+ skillLockTtlMs?: number;
34
+ /** Override for orchestrator detection (test seam). */
35
+ orchestratorMode?: boolean;
36
+ /** Override for `os.hostname()` (test seam). */
37
+ hostname?: string;
38
+ /** Override for current process PID (test seam). */
39
+ pid?: number;
40
+ /** Predicate: is PID alive on this host? (test seam) */
41
+ isPidAlive?: (pid: number) => boolean;
42
+ /** Clock (ms since epoch). Test seam. */
43
+ now?: () => number;
44
+ }
45
+ /** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
46
+ export declare function isOrchestratorMode(): boolean;
47
+ /** Resolve the locks directory honoring `SEQUANT_LOCKS_DIR` for test isolation. */
48
+ export declare function resolveLocksDir(explicit?: string): string;
49
+ /**
50
+ * Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
51
+ * skill-shell lock TTL. Returns `null` when unset or unparseable so the
52
+ * caller can fall back to the constructor option / default.
53
+ */
54
+ export declare function resolveSkillLockTtlMs(): number | null;
55
+ /** Default same-host PID check. `process.kill(pid, 0)` throws if not alive. */
56
+ export declare function defaultIsPidAlive(pid: number): boolean;
57
+ /** Build the canonical "issue is in use" error message (AC: error format). */
58
+ export declare function formatLockedMessage(issue: number, holder: LockFile): string;
59
+ /**
60
+ * Decide whether a lock should be treated as stale.
61
+ * Pure function: no I/O. Returns `null` if the lock is fresh.
62
+ */
63
+ export declare function classifyStaleness(args: {
64
+ holder: LockFile;
65
+ myHostname: string;
66
+ now: number;
67
+ staleAgeMs: number;
68
+ /** TTL for skill-shell (skipPidCheck) locks; falls back to staleAgeMs. */
69
+ skillLockTtlMs?: number;
70
+ isPidAlive: (pid: number) => boolean;
71
+ }): "pid-dead" | "age-exceeded" | null;
72
+ export declare class LockManager {
73
+ private readonly locksDir;
74
+ private readonly staleAgeMs;
75
+ private readonly skillLockTtlMs;
76
+ private readonly orchestratorMode;
77
+ private readonly hostname;
78
+ private readonly pid;
79
+ private readonly isPidAlive;
80
+ private readonly now;
81
+ /** Issues this instance has claimed and not yet released. */
82
+ private readonly held;
83
+ constructor(options?: LockManagerOptions);
84
+ /** True if all operations are no-ops (orchestrator/MCP mode). */
85
+ get isNoop(): boolean;
86
+ /** Absolute path to the locks directory. */
87
+ getLocksDir(): string;
88
+ /** Path to the lock file for a given issue. */
89
+ lockPathFor(issue: number): string;
90
+ /**
91
+ * Try to acquire the lock for `issue`. Returns a discriminated union.
92
+ *
93
+ * Behavior:
94
+ * - Same-host stale (PID dead): silently cleared, then acquired.
95
+ * - Cross-host within age window: blocked.
96
+ * - Cross-host beyond `staleAgeMs`: silently cleared, then acquired.
97
+ * - Orchestrator mode: returns `{ acquired: true, lockPath: '' }` no-op.
98
+ *
99
+ * `options.skipPidCheck` marks the lock so future stale checks skip the
100
+ * same-host PID probe and fall back to age-only — used for skill shells
101
+ * whose Node PID dies between acquire and release.
102
+ */
103
+ acquire(issue: number, command: string, options?: {
104
+ skipPidCheck?: boolean;
105
+ }): AcquireResult;
106
+ /**
107
+ * Take over the lock unconditionally (writes a new lock). Used by --force.
108
+ * Does NOT signal the prior PID — caller invokes `signal()` separately
109
+ * to opt in to that behavior (AC: --force does NOT signal).
110
+ */
111
+ forceAcquire(issue: number, command: string, options?: {
112
+ skipPidCheck?: boolean;
113
+ }): {
114
+ lockPath: string;
115
+ previous: LockFile | null;
116
+ };
117
+ /**
118
+ * SIGTERM the prior PID iff it is alive on this host. The `reason` discriminator
119
+ * lets callers produce accurate log lines for each refusal branch (#637).
120
+ * No-op in orchestrator mode or for cross-host holders.
121
+ */
122
+ signalOther(holder: LockFile, signal?: NodeJS.Signals): SignalOtherResult;
123
+ /**
124
+ * Release the lock for `issue` if this process is its holder.
125
+ * Safe to call repeatedly; safe to call when no lock exists.
126
+ */
127
+ release(issue: number): void;
128
+ /**
129
+ * Release a lock claimed by a previous, now-dead, short-lived process on
130
+ * the same host — the skill-shell pattern (`skipPidCheck: true`). Used by
131
+ * `sequant locks release` to let skills hand back ownership. Returns
132
+ * `true` when a lock was removed.
133
+ */
134
+ releaseExternal(issue: number): boolean;
135
+ /** Release every lock this instance holds. */
136
+ releaseAll(): void;
137
+ /**
138
+ * Read the holder for `issue` without acquiring. Returns null when missing
139
+ * or unparseable. Used by read-only commands (`status`, `merge`, `assess`).
140
+ */
141
+ check(issue: number): LockFile | null;
142
+ /** List every active lock with computed staleness metadata. */
143
+ list(): LockListing[];
144
+ /**
145
+ * Manually clear a lock. Used by `sequant locks clear`. Returns true if a
146
+ * lock was removed. With `safetyCheck` (default), refuses to clear a
147
+ * fresh same-host lock whose PID is alive — the caller should use
148
+ * `--force` semantics for that.
149
+ */
150
+ clearLock(issue: number, options?: {
151
+ safetyCheck?: boolean;
152
+ }): {
153
+ cleared: boolean;
154
+ reason: string;
155
+ };
156
+ private ensureLocksDir;
157
+ /**
158
+ * Write a new lock atomically using `O_CREAT | O_EXCL`. Races safely:
159
+ * if another process wins, returns `{ acquired: false }` with the winner.
160
+ */
161
+ private writeAtomic;
162
+ private readLockSafe;
163
+ private unlinkSafe;
164
+ /** True iff the lock file at `path` is missing (test helper). */
165
+ static missing(path: string): boolean;
166
+ /** Stat helper for tests — returns mtime or null. */
167
+ static mtime(path: string): Date | null;
168
+ }
@@ -0,0 +1,433 @@
1
+ /**
2
+ * LockManager — per-issue filesystem lock to prevent concurrent sequant
3
+ * sessions from targeting the same issue (#625).
4
+ *
5
+ * Each lock is a single file at `<locksDir>/<issue>.lock`, claimed via
6
+ * `open(O_CREAT|O_EXCL)`. A separate file (rather than a field inside
7
+ * `state.json`) keeps acquisition atomic — no read-modify-write race.
8
+ *
9
+ * Stale detection (in order):
10
+ * 1. `hostname === os.hostname()`: check `process.kill(pid, 0)`.
11
+ * Not alive → cleared.
12
+ * 2. Cross-host: PID check is meaningless. Use age only.
13
+ * 3. Age fallback (any host): `startedAt > staleAgeMs ago` → cleared.
14
+ *
15
+ * MCP / orchestrator mode: when `SEQUANT_ORCHESTRATOR` is set, every public
16
+ * method is a no-op (no fs touches, no warnings). Mirrors the
17
+ * `OrchestratorRenderer` pattern at `src/lib/cli-ui/run-renderer.ts:244`.
18
+ */
19
+ import { openSync, closeSync, writeSync, readFileSync, existsSync, unlinkSync, mkdirSync, readdirSync, statSync, } from "fs";
20
+ import { join, resolve } from "path";
21
+ import * as os from "os";
22
+ import { DEFAULT_LOCKS_DIR, DEFAULT_SKILL_LOCK_TTL_MS, DEFAULT_STALE_AGE_MS, LockFileSchema, } from "./types.js";
23
+ /** Detect orchestrator mode purely from env (no caching) so tests can mutate. */
24
+ export function isOrchestratorMode() {
25
+ return Boolean(process.env.SEQUANT_ORCHESTRATOR);
26
+ }
27
+ /** Resolve the locks directory honoring `SEQUANT_LOCKS_DIR` for test isolation. */
28
+ export function resolveLocksDir(explicit) {
29
+ const fromEnv = process.env.SEQUANT_LOCKS_DIR;
30
+ return resolve(explicit ?? fromEnv ?? DEFAULT_LOCKS_DIR);
31
+ }
32
+ /**
33
+ * Resolve `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds) — env override for the
34
+ * skill-shell lock TTL. Returns `null` when unset or unparseable so the
35
+ * caller can fall back to the constructor option / default.
36
+ */
37
+ export function resolveSkillLockTtlMs() {
38
+ const raw = process.env.SEQUANT_SKILL_LOCK_TTL_MS;
39
+ if (raw === undefined || raw === "")
40
+ return null;
41
+ const ms = Number.parseInt(raw, 10);
42
+ if (!Number.isFinite(ms) || ms <= 0)
43
+ return null;
44
+ return ms;
45
+ }
46
+ /** Default same-host PID check. `process.kill(pid, 0)` throws if not alive. */
47
+ export function defaultIsPidAlive(pid) {
48
+ if (!Number.isInteger(pid) || pid <= 0)
49
+ return false;
50
+ try {
51
+ process.kill(pid, 0);
52
+ return true;
53
+ }
54
+ catch (err) {
55
+ const code = err.code;
56
+ // EPERM = exists but signal not permitted → alive.
57
+ if (code === "EPERM")
58
+ return true;
59
+ return false;
60
+ }
61
+ }
62
+ /** Build the canonical "issue is in use" error message (AC: error format). */
63
+ export function formatLockedMessage(issue, holder) {
64
+ return (`Issue #${issue} is being worked on by PID ${holder.pid} since ` +
65
+ `${holder.startedAt} (${holder.command}). ` +
66
+ `Use --force to take over, or wait for the other session.`);
67
+ }
68
+ /**
69
+ * Decide whether a lock should be treated as stale.
70
+ * Pure function: no I/O. Returns `null` if the lock is fresh.
71
+ */
72
+ export function classifyStaleness(args) {
73
+ const { holder, myHostname, now, staleAgeMs, isPidAlive } = args;
74
+ const skillTtl = args.skillLockTtlMs ?? staleAgeMs;
75
+ // 1. Same-host PID check is authoritative — except when the holder asked
76
+ // us to skip it (skill shells exit before the lock is released; their
77
+ // PID is dead but the skill is still running in Claude Code).
78
+ if (holder.hostname === myHostname && !holder.skipPidCheck) {
79
+ if (!isPidAlive(holder.pid))
80
+ return "pid-dead";
81
+ return null;
82
+ }
83
+ // 2. Cross-host or skipPidCheck: PID is meaningless. Fall through to age.
84
+ // skipPidCheck uses its own TTL (default 6h) so long /fullsolve runs
85
+ // with multi-iteration QA loops don't lose their own lock; cross-host
86
+ // uses the stricter staleAgeMs (default 2h).
87
+ const ttl = holder.skipPidCheck ? skillTtl : staleAgeMs;
88
+ const ageMs = now - Date.parse(holder.startedAt);
89
+ if (!Number.isFinite(ageMs))
90
+ return null;
91
+ if (ageMs > ttl)
92
+ return "age-exceeded";
93
+ return null;
94
+ }
95
+ export class LockManager {
96
+ locksDir;
97
+ staleAgeMs;
98
+ skillLockTtlMs;
99
+ orchestratorMode;
100
+ hostname;
101
+ pid;
102
+ isPidAlive;
103
+ now;
104
+ /** Issues this instance has claimed and not yet released. */
105
+ held = new Set();
106
+ constructor(options = {}) {
107
+ this.locksDir = resolveLocksDir(options.locksDir);
108
+ this.staleAgeMs = options.staleAgeMs ?? DEFAULT_STALE_AGE_MS;
109
+ this.skillLockTtlMs =
110
+ options.skillLockTtlMs ??
111
+ resolveSkillLockTtlMs() ??
112
+ DEFAULT_SKILL_LOCK_TTL_MS;
113
+ this.orchestratorMode = options.orchestratorMode ?? isOrchestratorMode();
114
+ this.hostname = options.hostname ?? os.hostname();
115
+ this.pid = options.pid ?? process.pid;
116
+ this.isPidAlive = options.isPidAlive ?? defaultIsPidAlive;
117
+ this.now = options.now ?? Date.now;
118
+ }
119
+ /** True if all operations are no-ops (orchestrator/MCP mode). */
120
+ get isNoop() {
121
+ return this.orchestratorMode;
122
+ }
123
+ /** Absolute path to the locks directory. */
124
+ getLocksDir() {
125
+ return this.locksDir;
126
+ }
127
+ /** Path to the lock file for a given issue. */
128
+ lockPathFor(issue) {
129
+ return join(this.locksDir, `${issue}.lock`);
130
+ }
131
+ /**
132
+ * Try to acquire the lock for `issue`. Returns a discriminated union.
133
+ *
134
+ * Behavior:
135
+ * - Same-host stale (PID dead): silently cleared, then acquired.
136
+ * - Cross-host within age window: blocked.
137
+ * - Cross-host beyond `staleAgeMs`: silently cleared, then acquired.
138
+ * - Orchestrator mode: returns `{ acquired: true, lockPath: '' }` no-op.
139
+ *
140
+ * `options.skipPidCheck` marks the lock so future stale checks skip the
141
+ * same-host PID probe and fall back to age-only — used for skill shells
142
+ * whose Node PID dies between acquire and release.
143
+ */
144
+ acquire(issue, command, options = {}) {
145
+ if (this.orchestratorMode) {
146
+ return { acquired: true, lockPath: "" };
147
+ }
148
+ const lockPath = this.lockPathFor(issue);
149
+ this.ensureLocksDir();
150
+ // Auto-clear stale holder, then retry.
151
+ const existing = this.readLockSafe(lockPath);
152
+ if (existing) {
153
+ const staleReason = classifyStaleness({
154
+ holder: existing,
155
+ myHostname: this.hostname,
156
+ now: this.now(),
157
+ staleAgeMs: this.staleAgeMs,
158
+ skillLockTtlMs: this.skillLockTtlMs,
159
+ isPidAlive: this.isPidAlive,
160
+ });
161
+ if (staleReason) {
162
+ this.unlinkSafe(lockPath);
163
+ }
164
+ else {
165
+ return {
166
+ acquired: false,
167
+ holder: existing,
168
+ lockPath,
169
+ stale: false,
170
+ staleReason: null,
171
+ };
172
+ }
173
+ }
174
+ return this.writeAtomic(issue, lockPath, command, options.skipPidCheck);
175
+ }
176
+ /**
177
+ * Take over the lock unconditionally (writes a new lock). Used by --force.
178
+ * Does NOT signal the prior PID — caller invokes `signal()` separately
179
+ * to opt in to that behavior (AC: --force does NOT signal).
180
+ */
181
+ forceAcquire(issue, command, options = {}) {
182
+ if (this.orchestratorMode) {
183
+ return { lockPath: "", previous: null };
184
+ }
185
+ const lockPath = this.lockPathFor(issue);
186
+ this.ensureLocksDir();
187
+ const previous = this.readLockSafe(lockPath);
188
+ if (previous)
189
+ this.unlinkSafe(lockPath);
190
+ const result = this.writeAtomic(issue, lockPath, command, options.skipPidCheck);
191
+ if (!result.acquired) {
192
+ throw new Error(`forceAcquire raced and lost on issue #${issue}: ${formatLockedMessage(issue, result.holder)}`);
193
+ }
194
+ return { lockPath: result.lockPath, previous };
195
+ }
196
+ /**
197
+ * SIGTERM the prior PID iff it is alive on this host. The `reason` discriminator
198
+ * lets callers produce accurate log lines for each refusal branch (#637).
199
+ * No-op in orchestrator mode or for cross-host holders.
200
+ */
201
+ signalOther(holder, signal = "SIGTERM") {
202
+ if (this.orchestratorMode)
203
+ return { sent: false, reason: "orchestrator" };
204
+ if (holder.hostname !== this.hostname) {
205
+ return { sent: false, reason: "cross-host" };
206
+ }
207
+ // Defense-in-depth: never signal ourselves or our parent. A real lock
208
+ // file's pid should never match this process or its parent; a malformed
209
+ // file or recycled PID could otherwise let us SIGTERM our own shell
210
+ // (#637, defense follow-up to the #633 flake).
211
+ if (holder.pid === this.pid || holder.pid === process.ppid) {
212
+ return { sent: false, reason: "self-or-parent" };
213
+ }
214
+ if (!this.isPidAlive(holder.pid))
215
+ return { sent: false, reason: "pid-dead" };
216
+ try {
217
+ process.kill(holder.pid, signal);
218
+ return { sent: true, reason: "sent" };
219
+ }
220
+ catch {
221
+ return { sent: false, reason: "kill-failed" };
222
+ }
223
+ }
224
+ /**
225
+ * Release the lock for `issue` if this process is its holder.
226
+ * Safe to call repeatedly; safe to call when no lock exists.
227
+ */
228
+ release(issue) {
229
+ if (this.orchestratorMode)
230
+ return;
231
+ const lockPath = this.lockPathFor(issue);
232
+ const current = this.readLockSafe(lockPath);
233
+ if (current &&
234
+ current.pid === this.pid &&
235
+ current.hostname === this.hostname) {
236
+ this.unlinkSafe(lockPath);
237
+ }
238
+ this.held.delete(issue);
239
+ }
240
+ /**
241
+ * Release a lock claimed by a previous, now-dead, short-lived process on
242
+ * the same host — the skill-shell pattern (`skipPidCheck: true`). Used by
243
+ * `sequant locks release` to let skills hand back ownership. Returns
244
+ * `true` when a lock was removed.
245
+ */
246
+ releaseExternal(issue) {
247
+ if (this.orchestratorMode)
248
+ return false;
249
+ const lockPath = this.lockPathFor(issue);
250
+ const current = this.readLockSafe(lockPath);
251
+ if (!current)
252
+ return false;
253
+ // Only owner-host can release. The `skipPidCheck` flag is the explicit
254
+ // signal that "the original PID won't be alive — match on host instead".
255
+ if (current.hostname !== this.hostname)
256
+ return false;
257
+ if (!current.skipPidCheck && current.pid !== this.pid)
258
+ return false;
259
+ this.unlinkSafe(lockPath);
260
+ this.held.delete(issue);
261
+ return true;
262
+ }
263
+ /** Release every lock this instance holds. */
264
+ releaseAll() {
265
+ if (this.orchestratorMode)
266
+ return;
267
+ for (const issue of [...this.held]) {
268
+ this.release(issue);
269
+ }
270
+ }
271
+ /**
272
+ * Read the holder for `issue` without acquiring. Returns null when missing
273
+ * or unparseable. Used by read-only commands (`status`, `merge`, `assess`).
274
+ */
275
+ check(issue) {
276
+ if (this.orchestratorMode)
277
+ return null;
278
+ return this.readLockSafe(this.lockPathFor(issue));
279
+ }
280
+ /** List every active lock with computed staleness metadata. */
281
+ list() {
282
+ if (this.orchestratorMode)
283
+ return [];
284
+ if (!existsSync(this.locksDir))
285
+ return [];
286
+ const out = [];
287
+ const entries = readdirSync(this.locksDir);
288
+ for (const name of entries) {
289
+ if (!name.endsWith(".lock"))
290
+ continue;
291
+ const issueStr = name.slice(0, -".lock".length);
292
+ const issue = Number(issueStr);
293
+ if (!Number.isInteger(issue))
294
+ continue;
295
+ const lockPath = join(this.locksDir, name);
296
+ const holder = this.readLockSafe(lockPath);
297
+ if (!holder)
298
+ continue;
299
+ const now = this.now();
300
+ const ageMs = now - Date.parse(holder.startedAt);
301
+ const staleReason = classifyStaleness({
302
+ holder,
303
+ myHostname: this.hostname,
304
+ now,
305
+ staleAgeMs: this.staleAgeMs,
306
+ skillLockTtlMs: this.skillLockTtlMs,
307
+ isPidAlive: this.isPidAlive,
308
+ });
309
+ out.push({
310
+ issue,
311
+ holder,
312
+ ageMs: Number.isFinite(ageMs) ? ageMs : 0,
313
+ stale: staleReason !== null,
314
+ staleReason,
315
+ lockPath,
316
+ });
317
+ }
318
+ return out.sort((a, b) => a.issue - b.issue);
319
+ }
320
+ /**
321
+ * Manually clear a lock. Used by `sequant locks clear`. Returns true if a
322
+ * lock was removed. With `safetyCheck` (default), refuses to clear a
323
+ * fresh same-host lock whose PID is alive — the caller should use
324
+ * `--force` semantics for that.
325
+ */
326
+ clearLock(issue, options = {}) {
327
+ if (this.orchestratorMode)
328
+ return { cleared: false, reason: "orchestrator-mode" };
329
+ const safetyCheck = options.safetyCheck ?? true;
330
+ const lockPath = this.lockPathFor(issue);
331
+ const holder = this.readLockSafe(lockPath);
332
+ if (!holder)
333
+ return { cleared: false, reason: "no-lock" };
334
+ if (safetyCheck) {
335
+ const staleReason = classifyStaleness({
336
+ holder,
337
+ myHostname: this.hostname,
338
+ now: this.now(),
339
+ staleAgeMs: this.staleAgeMs,
340
+ skillLockTtlMs: this.skillLockTtlMs,
341
+ isPidAlive: this.isPidAlive,
342
+ });
343
+ if (!staleReason) {
344
+ return { cleared: false, reason: "fresh-same-host-alive" };
345
+ }
346
+ }
347
+ this.unlinkSafe(lockPath);
348
+ return { cleared: true, reason: "cleared" };
349
+ }
350
+ // ── internals ────────────────────────────────────────────────────────────
351
+ ensureLocksDir() {
352
+ mkdirSync(this.locksDir, { recursive: true });
353
+ }
354
+ /**
355
+ * Write a new lock atomically using `O_CREAT | O_EXCL`. Races safely:
356
+ * if another process wins, returns `{ acquired: false }` with the winner.
357
+ */
358
+ writeAtomic(issue, lockPath, command, skipPidCheck) {
359
+ const payload = {
360
+ pid: this.pid,
361
+ hostname: this.hostname,
362
+ startedAt: new Date(this.now()).toISOString(),
363
+ command,
364
+ ...(skipPidCheck ? { skipPidCheck: true } : {}),
365
+ };
366
+ const body = JSON.stringify(payload, null, 2);
367
+ let fd;
368
+ try {
369
+ // 0o644: world-readable, owner-writable (matches other .sequant files).
370
+ fd = openSync(lockPath, "wx", 0o644);
371
+ }
372
+ catch (err) {
373
+ if (err.code === "EEXIST") {
374
+ const winner = this.readLockSafe(lockPath);
375
+ if (winner) {
376
+ return {
377
+ acquired: false,
378
+ holder: winner,
379
+ lockPath,
380
+ stale: false,
381
+ staleReason: null,
382
+ };
383
+ }
384
+ // File appeared then vanished — fall through to throw below.
385
+ }
386
+ throw err;
387
+ }
388
+ try {
389
+ writeSync(fd, body);
390
+ }
391
+ finally {
392
+ closeSync(fd);
393
+ }
394
+ this.held.add(issue);
395
+ return { acquired: true, lockPath };
396
+ }
397
+ readLockSafe(lockPath) {
398
+ if (!existsSync(lockPath))
399
+ return null;
400
+ try {
401
+ const text = readFileSync(lockPath, "utf-8");
402
+ const parsed = LockFileSchema.safeParse(JSON.parse(text));
403
+ if (!parsed.success)
404
+ return null;
405
+ return parsed.data;
406
+ }
407
+ catch {
408
+ return null;
409
+ }
410
+ }
411
+ unlinkSafe(lockPath) {
412
+ try {
413
+ unlinkSync(lockPath);
414
+ }
415
+ catch (err) {
416
+ if (err.code !== "ENOENT")
417
+ throw err;
418
+ }
419
+ }
420
+ /** True iff the lock file at `path` is missing (test helper). */
421
+ static missing(path) {
422
+ return !existsSync(path);
423
+ }
424
+ /** Stat helper for tests — returns mtime or null. */
425
+ static mtime(path) {
426
+ try {
427
+ return statSync(path).mtime;
428
+ }
429
+ catch {
430
+ return null;
431
+ }
432
+ }
433
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * Types for the issue-level concurrency lock (#625).
3
+ */
4
+ import { z } from "zod";
5
+ /** Default age cutoff (ms) for cross-host stale locks. */
6
+ export declare const DEFAULT_STALE_AGE_MS: number;
7
+ /**
8
+ * Default age cutoff (ms) for skill-shell locks (`skipPidCheck: true`).
9
+ * Longer than `DEFAULT_STALE_AGE_MS` because skill shells can't refresh
10
+ * their own PID liveness — the lock has to outlive long /fullsolve runs
11
+ * with multi-iteration QA loops. 6h covers virtually every run while
12
+ * still bounding the orphan window on crash/abort.
13
+ *
14
+ * Override per-process via `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds).
15
+ */
16
+ export declare const DEFAULT_SKILL_LOCK_TTL_MS: number;
17
+ /** Default lock directory relative to the project root. */
18
+ export declare const DEFAULT_LOCKS_DIR = ".sequant/locks";
19
+ /** On-disk lock payload. */
20
+ export declare const LockFileSchema: z.ZodObject<{
21
+ pid: z.ZodNumber;
22
+ hostname: z.ZodString;
23
+ startedAt: z.ZodString;
24
+ command: z.ZodString;
25
+ skipPidCheck: z.ZodOptional<z.ZodBoolean>;
26
+ }, z.core.$strip>;
27
+ export type LockFile = z.infer<typeof LockFileSchema>;
28
+ /** Outcome of `LockManager.acquire()`. */
29
+ export type AcquireResult = {
30
+ acquired: true;
31
+ lockPath: string;
32
+ } | {
33
+ acquired: false;
34
+ holder: LockFile;
35
+ lockPath: string;
36
+ /** True when the holder appears stale and could be cleared with `--force`. */
37
+ stale: boolean;
38
+ staleReason?: "pid-dead" | "age-exceeded" | null;
39
+ };
40
+ /** Listing entry from `LockManager.list()`. */
41
+ export interface LockListing {
42
+ issue: number;
43
+ holder: LockFile;
44
+ ageMs: number;
45
+ stale: boolean;
46
+ staleReason: "pid-dead" | "age-exceeded" | null;
47
+ lockPath: string;
48
+ }
49
+ /**
50
+ * Discriminator for `LockManager.signalOther()`. Distinguishes the branches
51
+ * that previously all collapsed to `false`, so callers can produce accurate
52
+ * log lines (#637).
53
+ */
54
+ export type SignalReason = "sent" | "orchestrator" | "cross-host" | "self-or-parent" | "pid-dead" | "kill-failed";
55
+ /** Outcome of `LockManager.signalOther()`. */
56
+ export interface SignalOtherResult {
57
+ sent: boolean;
58
+ reason: SignalReason;
59
+ }
@@ -0,0 +1,31 @@
1
+ /**
2
+ * Types for the issue-level concurrency lock (#625).
3
+ */
4
+ import { z } from "zod";
5
+ /** Default age cutoff (ms) for cross-host stale locks. */
6
+ export const DEFAULT_STALE_AGE_MS = 2 * 60 * 60 * 1000; // 2h
7
+ /**
8
+ * Default age cutoff (ms) for skill-shell locks (`skipPidCheck: true`).
9
+ * Longer than `DEFAULT_STALE_AGE_MS` because skill shells can't refresh
10
+ * their own PID liveness — the lock has to outlive long /fullsolve runs
11
+ * with multi-iteration QA loops. 6h covers virtually every run while
12
+ * still bounding the orphan window on crash/abort.
13
+ *
14
+ * Override per-process via `SEQUANT_SKILL_LOCK_TTL_MS` (milliseconds).
15
+ */
16
+ export const DEFAULT_SKILL_LOCK_TTL_MS = 6 * 60 * 60 * 1000; // 6h
17
+ /** Default lock directory relative to the project root. */
18
+ export const DEFAULT_LOCKS_DIR = ".sequant/locks";
19
+ /** On-disk lock payload. */
20
+ export const LockFileSchema = z.object({
21
+ pid: z.number().int().positive(),
22
+ hostname: z.string(),
23
+ startedAt: z.string(), // ISO-8601 UTC
24
+ command: z.string(),
25
+ /**
26
+ * True when the holder cannot vouch for its PID staying alive (e.g. a skill
27
+ * shell that exits immediately after acquire). Stale detection falls back
28
+ * to age-only on the holder's host, same path as cross-host locks.
29
+ */
30
+ skipPidCheck: z.boolean().optional(),
31
+ });