squadrant 0.16.0 → 0.16.2
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/dist/index.js +228 -150
- package/dist/index.js.map +1 -1
- package/dist/squadrantd.js +55 -41
- package/dist/squadrantd.js.map +1 -1
- package/package.json +3 -2
- package/plugin/skills/captain-ops/SKILL.md +1 -1
- package/scripts/heavy-lock.mjs +129 -0
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "squadrant",
|
|
3
3
|
"packageManager": "pnpm@10.30.3",
|
|
4
|
-
"version": "0.16.
|
|
4
|
+
"version": "0.16.2",
|
|
5
5
|
"description": "Multi-project orchestration for your coding agents (Claude, Codex, opencode, Gemini)",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"bin": {
|
|
@@ -17,7 +17,8 @@
|
|
|
17
17
|
"scripts": {
|
|
18
18
|
"build": "tsc -b --force packages/shared packages/core packages/agents packages/workspaces packages/web packages/cli && tsup",
|
|
19
19
|
"dev": "tsup --watch",
|
|
20
|
-
"test": "vitest",
|
|
20
|
+
"test": "node scripts/heavy-lock.mjs -- vitest run",
|
|
21
|
+
"test:watch": "vitest",
|
|
21
22
|
"lint": "tsc --noEmit",
|
|
22
23
|
"codex:gen-types": "bash scripts/gen-codex-types.sh"
|
|
23
24
|
},
|
|
@@ -235,7 +235,7 @@ After a crew task completes:
|
|
|
235
235
|
1. Review the work — read the diff, check the branch.
|
|
236
236
|
2. Merge their branch if appropriate.
|
|
237
237
|
3. Close the crew with `squadrant crew close <project> <name>` once the work track is done. (Or let the crew exit itself — the tab closes when the CLI ends.)
|
|
238
|
-
4. After closing a crew, VERIFY no orphaned processes remain — e.g. `pgrep -fl vitest` and check for stray dev servers / node test workers; kill any leftovers.
|
|
238
|
+
4. After closing a crew, VERIFY no orphaned processes remain — e.g. `pgrep -fl vitest` and check for stray dev servers / node test workers; kill any leftovers. `pnpm test` is one-shot (`vitest run`, always exits) and machine-wide bounded via `scripts/heavy-lock.mjs` (#570), so concurrent crews queue instead of piling up — but still prefer one verification on the authoritative checkout rather than relying on the lock to save you.
|
|
239
239
|
5. Record learnings if any (see "Recording Learnings" below).
|
|
240
240
|
6. Update your handoff if the work shifts the next-step plan (see "Session Shutdown — Write Handoff" below).
|
|
241
241
|
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Machine-wide semaphore for heavy commands (test runs, builds) so concurrent
|
|
3
|
+
// crews WAIT for a slot instead of piling up and saturating the machine (#570).
|
|
4
|
+
//
|
|
5
|
+
// macOS has no flock(1), so the lock is mkdir-based: mkdir is atomic on POSIX,
|
|
6
|
+
// so "did I create this directory" is a race-free ownership check. Each slot
|
|
7
|
+
// directory holds the holder's pid; a slot whose pid is no longer alive is
|
|
8
|
+
// stale (its holder crashed or was SIGKILLed) and gets reclaimed rather than
|
|
9
|
+
// deadlocking the repo forever.
|
|
10
|
+
//
|
|
11
|
+
// Usage: node scripts/heavy-lock.mjs -- <command> [args...]
|
|
12
|
+
// Env: SQUADRANT_HEAVY_MAX (default 2) — max concurrent holders machine-wide.
|
|
13
|
+
import { spawn } from "node:child_process";
|
|
14
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { tmpdir } from "node:os";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
const MAX = Math.max(1, Number.parseInt(process.env.SQUADRANT_HEAVY_MAX, 10) || 2);
|
|
19
|
+
const LOCK_ROOT = join(tmpdir(), "squadrant-heavy-lock");
|
|
20
|
+
const POLL_MS = 1000;
|
|
21
|
+
const LOG_EVERY_MS = 10_000;
|
|
22
|
+
|
|
23
|
+
const sep = process.argv.indexOf("--");
|
|
24
|
+
if (sep === -1 || sep === process.argv.length - 1) {
|
|
25
|
+
console.error("usage: node scripts/heavy-lock.mjs -- <command> [args...]");
|
|
26
|
+
process.exit(1);
|
|
27
|
+
}
|
|
28
|
+
const command = process.argv[sep + 1];
|
|
29
|
+
const commandArgs = process.argv.slice(sep + 2);
|
|
30
|
+
|
|
31
|
+
mkdirSync(LOCK_ROOT, { recursive: true });
|
|
32
|
+
|
|
33
|
+
const slotDir = (n) => join(LOCK_ROOT, `slot-${n}`);
|
|
34
|
+
|
|
35
|
+
function isAlive(pid) {
|
|
36
|
+
try {
|
|
37
|
+
process.kill(pid, 0);
|
|
38
|
+
return true;
|
|
39
|
+
} catch {
|
|
40
|
+
return false;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Returns true if we now own slot n. Reclaims the slot if its holder is dead.
|
|
45
|
+
function tryAcquire(n) {
|
|
46
|
+
const dir = slotDir(n);
|
|
47
|
+
try {
|
|
48
|
+
mkdirSync(dir);
|
|
49
|
+
writeFileSync(join(dir, "pid"), String(process.pid));
|
|
50
|
+
return true;
|
|
51
|
+
} catch (err) {
|
|
52
|
+
if (err.code !== "EEXIST") throw err;
|
|
53
|
+
}
|
|
54
|
+
let holderPid;
|
|
55
|
+
try {
|
|
56
|
+
holderPid = Number.parseInt(readFileSync(join(dir, "pid"), "utf8"), 10);
|
|
57
|
+
} catch {
|
|
58
|
+
return false; // slot mid-creation by another process; contended, not stale
|
|
59
|
+
}
|
|
60
|
+
if (holderPid && isAlive(holderPid)) return false;
|
|
61
|
+
// Stale slot (holder pid is dead) — reclaim. rmSync+mkdirSync isn't a single
|
|
62
|
+
// atomic op, but the final mkdirSync is: if another process reclaims first,
|
|
63
|
+
// ours throws EEXIST and we just loop around and try again.
|
|
64
|
+
rmSync(dir, { recursive: true, force: true });
|
|
65
|
+
try {
|
|
66
|
+
mkdirSync(dir);
|
|
67
|
+
} catch (err) {
|
|
68
|
+
if (err.code === "EEXIST") return false;
|
|
69
|
+
throw err;
|
|
70
|
+
}
|
|
71
|
+
writeFileSync(join(dir, "pid"), String(process.pid));
|
|
72
|
+
return true;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function countBusy() {
|
|
76
|
+
let busy = 0;
|
|
77
|
+
for (let n = 0; n < MAX; n++) {
|
|
78
|
+
try {
|
|
79
|
+
readFileSync(join(slotDir(n), "pid"));
|
|
80
|
+
busy++;
|
|
81
|
+
} catch {
|
|
82
|
+
// slot not held
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return busy;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
async function acquireSlot() {
|
|
89
|
+
let lastLog = 0;
|
|
90
|
+
for (;;) {
|
|
91
|
+
for (let n = 0; n < MAX; n++) {
|
|
92
|
+
if (tryAcquire(n)) return n;
|
|
93
|
+
}
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
if (now - lastLog >= LOG_EVERY_MS) {
|
|
96
|
+
console.error(`[heavy-lock] waiting for test slot (${countBusy()} ahead, max ${MAX} concurrent)...`);
|
|
97
|
+
lastLog = now;
|
|
98
|
+
}
|
|
99
|
+
await new Promise((r) => setTimeout(r, POLL_MS));
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
let heldSlot = null;
|
|
104
|
+
function release() {
|
|
105
|
+
if (heldSlot === null) return;
|
|
106
|
+
const n = heldSlot;
|
|
107
|
+
heldSlot = null;
|
|
108
|
+
rmSync(slotDir(n), { recursive: true, force: true });
|
|
109
|
+
}
|
|
110
|
+
process.on("exit", release);
|
|
111
|
+
|
|
112
|
+
const slot = await acquireSlot();
|
|
113
|
+
heldSlot = slot;
|
|
114
|
+
console.error(`[heavy-lock] slot ${slot}/${MAX - 1} acquired — running: ${command} ${commandArgs.join(" ")}`);
|
|
115
|
+
|
|
116
|
+
const child = spawn(command, commandArgs, { stdio: "inherit" });
|
|
117
|
+
process.on("SIGINT", () => child.kill("SIGINT"));
|
|
118
|
+
process.on("SIGTERM", () => child.kill("SIGTERM"));
|
|
119
|
+
|
|
120
|
+
let exitCode;
|
|
121
|
+
try {
|
|
122
|
+
exitCode = await new Promise((resolve, reject) => {
|
|
123
|
+
child.on("exit", (code, signal) => resolve(signal ? 1 : (code ?? 1)));
|
|
124
|
+
child.on("error", reject);
|
|
125
|
+
});
|
|
126
|
+
} finally {
|
|
127
|
+
release();
|
|
128
|
+
}
|
|
129
|
+
process.exit(exitCode);
|