jules-orchestrator-kit 0.29.1 → 0.32.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/README.md +169 -86
- package/bin/agentctl.mjs +267 -4
- package/bin/mcp-server.mjs +0 -0
- package/index.mjs +21 -1
- package/package.json +1 -1
- package/scripts/stale-base-check.mjs +2 -2
- package/src/config.mjs +18 -2
- package/src/dag-engine.mjs +76 -0
- package/src/engine.mjs +331 -32
- package/src/envelope.mjs +32 -2
- package/src/execution_envelope.mjs +9 -1
- package/src/git.mjs +41 -0
- package/src/mcp.mjs +34 -0
- package/src/memory.mjs +148 -0
- package/src/ops/checkpoint.mjs +167 -0
- package/src/ops/command-registry.mjs +274 -0
- package/src/ops/doctor-planner.mjs +214 -0
- package/src/ops/doctor-registry.mjs +442 -0
- package/src/ops/ide-scaffold.mjs +139 -0
- package/src/ops/receipts.mjs +171 -0
- package/src/ops/swarm-actions.mjs +156 -0
- package/src/ops/task-actions.mjs +214 -0
- package/src/ops/tdd-generator.mjs +115 -0
- package/src/ops/transaction.mjs +278 -0
- package/src/prompt-guard.mjs +8 -1
- package/src/provider.mjs +164 -8
- package/src/remediation.mjs +164 -0
- package/src/security.mjs +14 -6
- package/src/stack-detector.mjs +15 -5
- package/src/state.mjs +71 -7
- package/src/task-optimizer.mjs +306 -0
- package/src/telemetry.mjs +35 -2
- package/src/tui.mjs +2 -6
- package/src/ux/capabilities.mjs +180 -0
- package/src/ux/diff-viewer.mjs +289 -0
- package/src/ux/key-decoder.mjs +281 -0
- package/src/ux/layout.mjs +195 -0
- package/src/ux/log-viewer.mjs +110 -0
- package/src/ux/palette.mjs +179 -0
- package/src/ux/queue-model.mjs +206 -0
- package/src/ux/renderer.mjs +188 -0
- package/src/ux/swarm-model.mjs +146 -0
- package/src/ux/terminal-session.mjs +215 -0
- package/src/ux/widgets.mjs +323 -0
- package/src/webhook.mjs +120 -0
- package/src/wizard-task.mjs +6 -0
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { readFileSync, existsSync, appendFileSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { getStateDir, ensureDir } from "./state.mjs";
|
|
5
|
+
import { redactSecrets } from "./security.mjs";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Appends a structured remediation pattern entry to .agent/state/remediations.jsonl
|
|
9
|
+
* when an OODA repair loop successfully resolves a failure.
|
|
10
|
+
*
|
|
11
|
+
* @param {string} root - Project root directory
|
|
12
|
+
* @param {Object} entry - Remediation payload
|
|
13
|
+
* @param {string} [entry.fingerprint] - SHA-256 failure fingerprint from fingerprintFailureState()
|
|
14
|
+
* @param {Array<string>} [entry.targetFiles] - Array of affected file paths
|
|
15
|
+
* @param {string} [entry.symptom] - Error output or failure description
|
|
16
|
+
* @param {string} [entry.remediationHint] - Actionable fix description or diff summary
|
|
17
|
+
* @returns {Object} Saved remediation entry
|
|
18
|
+
*/
|
|
19
|
+
export function recordRemediation(root, entry = {}) {
|
|
20
|
+
const stateDir = getStateDir(root);
|
|
21
|
+
ensureDir(stateDir);
|
|
22
|
+
const filePath = join(stateDir, "remediations.jsonl");
|
|
23
|
+
|
|
24
|
+
const targetFiles = Array.isArray(entry.targetFiles)
|
|
25
|
+
? entry.targetFiles.map((f) => String(f).replace(/\\/g, "/"))
|
|
26
|
+
: entry.targetFile
|
|
27
|
+
? [String(entry.targetFile).replace(/\\/g, "/")]
|
|
28
|
+
: [];
|
|
29
|
+
|
|
30
|
+
const record = {
|
|
31
|
+
id: entry.id || `rem-${Date.now()}-${randomUUID().substring(0, 8)}`,
|
|
32
|
+
timestamp: new Date().toISOString(),
|
|
33
|
+
fingerprint: String(entry.fingerprint || "").trim(),
|
|
34
|
+
targetFiles,
|
|
35
|
+
symptom: redactSecrets(String(entry.symptom || "").trim()),
|
|
36
|
+
remediationHint: redactSecrets(String(entry.remediationHint || "").trim()),
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
appendFileSync(filePath, JSON.stringify(record) + "\n", "utf-8");
|
|
40
|
+
return record;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Reads and queries remediation records from .agent/state/remediations.jsonl.
|
|
45
|
+
* Matches by targetFiles (path token overlap) or failure fingerprint.
|
|
46
|
+
*
|
|
47
|
+
* @param {string} root - Project root directory
|
|
48
|
+
* @param {Object} [query] - Query parameters
|
|
49
|
+
* @param {Array<string>} [query.targetFiles] - Files to match against remediations
|
|
50
|
+
* @param {string} [query.fingerprint] - Fingerprint to match against remediations
|
|
51
|
+
* @param {number} [query.limit=5] - Maximum number of matching remediations to return
|
|
52
|
+
* @returns {Array<Object>} List of matching remediation records
|
|
53
|
+
*/
|
|
54
|
+
export function queryRemediations(root, query = {}) {
|
|
55
|
+
const stateDir = getStateDir(root);
|
|
56
|
+
const filePath = join(stateDir, "remediations.jsonl");
|
|
57
|
+
|
|
58
|
+
if (!existsSync(filePath)) {
|
|
59
|
+
return [];
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const targetFiles = (query.targetFiles || []).map((f) => String(f).replace(/\\/g, "/").toLowerCase());
|
|
63
|
+
const fingerprint = String(query.fingerprint || "").trim();
|
|
64
|
+
const limit = Math.max(1, Number(query.limit) || 5);
|
|
65
|
+
|
|
66
|
+
try {
|
|
67
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
68
|
+
const lines = raw.split("\n").filter((l) => l.trim().length > 0);
|
|
69
|
+
const matches = [];
|
|
70
|
+
|
|
71
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
72
|
+
if (matches.length >= limit) break;
|
|
73
|
+
try {
|
|
74
|
+
const record = JSON.parse(lines[i]);
|
|
75
|
+
if (!record || typeof record !== "object") continue;
|
|
76
|
+
|
|
77
|
+
let isMatch = false;
|
|
78
|
+
|
|
79
|
+
// Exact fingerprint match
|
|
80
|
+
if (fingerprint && record.fingerprint === fingerprint) {
|
|
81
|
+
isMatch = true;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// File path match
|
|
85
|
+
if (!isMatch && targetFiles.length > 0 && Array.isArray(record.targetFiles)) {
|
|
86
|
+
const recordFiles = record.targetFiles.map((f) => String(f).toLowerCase());
|
|
87
|
+
for (const tf of targetFiles) {
|
|
88
|
+
if (recordFiles.some((rf) => rf === tf || rf.endsWith(tf) || tf.endsWith(rf))) {
|
|
89
|
+
isMatch = true;
|
|
90
|
+
break;
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// If no filter query was specified, match all records
|
|
96
|
+
if (!fingerprint && targetFiles.length === 0) {
|
|
97
|
+
isMatch = true;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (isMatch) {
|
|
101
|
+
matches.push(record);
|
|
102
|
+
}
|
|
103
|
+
} catch (_) {}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
return matches;
|
|
107
|
+
} catch (_) {
|
|
108
|
+
return [];
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Harvests a failure state or OODA repair outcome into remediations.jsonl.
|
|
114
|
+
*
|
|
115
|
+
* @param {string} root - Project root directory
|
|
116
|
+
* @param {Object} details - Failure details
|
|
117
|
+
* @param {string} [details.symptom] - Error message or failure trace
|
|
118
|
+
* @param {string} [details.remediationHint] - Fix description or diff snippet
|
|
119
|
+
* @param {Array<string>} [details.targetFiles] - Affected target files
|
|
120
|
+
* @param {string} [details.fingerprint] - Optional fingerprint
|
|
121
|
+
* @returns {Object|null} Harvested remediation record
|
|
122
|
+
*/
|
|
123
|
+
export function harvestFailureRecord(root, details = {}) {
|
|
124
|
+
if (!details || (!details.symptom && !details.remediationHint)) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
return recordRemediation(root, {
|
|
128
|
+
symptom: details.symptom || details.error || "OODA Failure Detected",
|
|
129
|
+
remediationHint: details.remediationHint || details.solution || details.diff || "Verify contract and run test suite.",
|
|
130
|
+
targetFiles: details.targetFiles || details.referencedPaths || [],
|
|
131
|
+
fingerprint: details.fingerprint || "",
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Queries relevant past failure remediations and returns a formatted prompt block
|
|
137
|
+
* for memory hydration.
|
|
138
|
+
*
|
|
139
|
+
* @param {string} root - Project root directory
|
|
140
|
+
* @param {Object} [options]
|
|
141
|
+
* @param {Array<string>} [options.targetFiles=[]] - Files associated with current task
|
|
142
|
+
* @param {string} [options.fingerprint=""] - Failure fingerprint
|
|
143
|
+
* @param {number} [options.limit=3] - Maximum memory records to hydrate
|
|
144
|
+
* @returns {string} Formatted [LEARNED_REMEDIATIONS_CONTEXT] section string (or empty string if none match)
|
|
145
|
+
*/
|
|
146
|
+
export function hydrateMemory(root, options = {}) {
|
|
147
|
+
const matches = queryRemediations(root, {
|
|
148
|
+
targetFiles: options.targetFiles || [],
|
|
149
|
+
fingerprint: options.fingerprint || "",
|
|
150
|
+
limit: options.limit || 3,
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
if (!matches || matches.length === 0) {
|
|
154
|
+
return "";
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
const items = matches.map((m, idx) => {
|
|
158
|
+
const filesStr = Array.isArray(m.targetFiles) && m.targetFiles.length > 0 ? ` (Files: ${m.targetFiles.join(", ")})` : "";
|
|
159
|
+
return `${idx + 1}. [Symptom${filesStr}]: ${m.symptom}\n [Proven Fix]: ${m.remediationHint}`;
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
return `[LEARNED_REMEDIATIONS_CONTEXT]\nThe following historical failure-and-fix patterns were harvested from previous OODA runs in this repository. Avoid repeating these failure modes:\n\n${items.join("\n\n")}`;
|
|
163
|
+
}
|
|
164
|
+
|
package/src/security.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync } from "node:fs";
|
|
1
|
+
import { openSync, writeSync, fsyncSync, closeSync, renameSync, realpathSync, existsSync, lstatSync, unlinkSync } from "node:fs";
|
|
2
2
|
import { dirname, join, basename } from "node:path";
|
|
3
3
|
import { randomBytes } from "node:crypto";
|
|
4
4
|
import { normalizePath } from "./config.mjs";
|
|
@@ -61,18 +61,26 @@ export function safeAtomicWrite(filePath, content, options = {}) {
|
|
|
61
61
|
const dir = dirname(filePath);
|
|
62
62
|
const tempFile = join(dir, `.tmp-${basename(filePath)}-${randomBytes(6).toString("hex")}`);
|
|
63
63
|
|
|
64
|
-
|
|
64
|
+
let fd;
|
|
65
65
|
try {
|
|
66
|
+
fd = openSync(tempFile, "wx", mode);
|
|
66
67
|
writeSync(fd, content, null, encoding);
|
|
67
68
|
if (options.sync !== false) {
|
|
68
69
|
fsyncSync(fd);
|
|
69
70
|
}
|
|
70
|
-
} finally {
|
|
71
71
|
closeSync(fd);
|
|
72
|
+
fd = undefined;
|
|
73
|
+
renameSync(tempFile, filePath);
|
|
74
|
+
return true;
|
|
75
|
+
} catch (err) {
|
|
76
|
+
if (fd !== undefined) {
|
|
77
|
+
try { closeSync(fd); } catch (_) {}
|
|
78
|
+
}
|
|
79
|
+
try {
|
|
80
|
+
if (existsSync(tempFile)) unlinkSync(tempFile);
|
|
81
|
+
} catch (_) {}
|
|
82
|
+
throw err;
|
|
72
83
|
}
|
|
73
|
-
|
|
74
|
-
renameSync(tempFile, filePath);
|
|
75
|
-
return true;
|
|
76
84
|
}
|
|
77
85
|
|
|
78
86
|
export function shannonEntropy(str) {
|
package/src/stack-detector.mjs
CHANGED
|
@@ -119,7 +119,10 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
119
119
|
return { ...container, stack: "make", testCmd: "make test", buildCmd: "make build", triggerFile: "Makefile" };
|
|
120
120
|
}
|
|
121
121
|
|
|
122
|
-
// 5. Python / Elixir / Ruby / Java
|
|
122
|
+
// 5. Python / Django / Elixir / Ruby / Java
|
|
123
|
+
if (existsSync(join(projectRoot, "manage.py"))) {
|
|
124
|
+
return { ...container, stack: "django", testCmd: "python manage.py test --keepdb", buildCmd: "python manage.py check", triggerFile: "manage.py" };
|
|
125
|
+
}
|
|
123
126
|
if (existsSync(join(projectRoot, "pyproject.toml")) || existsSync(join(projectRoot, "requirements.txt")) || existsSync(join(projectRoot, "setup.py"))) {
|
|
124
127
|
const triggerFile = existsSync(join(projectRoot, "pyproject.toml")) ? "pyproject.toml" : existsSync(join(projectRoot, "requirements.txt")) ? "requirements.txt" : "setup.py";
|
|
125
128
|
return { ...container, stack: "python", testCmd: "pytest", buildCmd: "python3 -m compileall -q .", triggerFile };
|
|
@@ -138,14 +141,21 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
138
141
|
return { ...container, stack: "gradle", testCmd: "./gradlew test", buildCmd: "./gradlew assemble", triggerFile };
|
|
139
142
|
}
|
|
140
143
|
|
|
141
|
-
// 6. JS / TS Runtimes
|
|
144
|
+
// 6. JS / TS Runtimes & ORMs (Prisma, Drizzle)
|
|
145
|
+
let setupCmd = "";
|
|
146
|
+
if (existsSync(join(projectRoot, "prisma", "schema.prisma"))) {
|
|
147
|
+
setupCmd = "npx prisma db push --schema=prisma/schema.prisma";
|
|
148
|
+
} else if (existsSync(join(projectRoot, "drizzle.config.ts")) || existsSync(join(projectRoot, "drizzle.config.js"))) {
|
|
149
|
+
setupCmd = "npx drizzle-kit push";
|
|
150
|
+
}
|
|
151
|
+
|
|
142
152
|
if (existsSync(join(projectRoot, "bunfig.toml")) || existsSync(join(projectRoot, "bun.lockb"))) {
|
|
143
153
|
const triggerFile = existsSync(join(projectRoot, "bunfig.toml")) ? "bunfig.toml" : "bun.lockb";
|
|
144
|
-
return { ...container, stack: "bun", testCmd: "bun test", buildCmd: "bun run build", triggerFile };
|
|
154
|
+
return { ...container, stack: "bun", setupCmd, testCmd: "bun test", buildCmd: "bun run build", triggerFile };
|
|
145
155
|
}
|
|
146
156
|
if (existsSync(join(projectRoot, "deno.json")) || existsSync(join(projectRoot, "deno.jsonc"))) {
|
|
147
157
|
const triggerFile = existsSync(join(projectRoot, "deno.json")) ? "deno.json" : "deno.jsonc";
|
|
148
|
-
return { ...container, stack: "deno", testCmd: "deno test", buildCmd: "deno task build", triggerFile };
|
|
158
|
+
return { ...container, stack: "deno", setupCmd, testCmd: "deno test", buildCmd: "deno task build", triggerFile };
|
|
149
159
|
}
|
|
150
160
|
if (existsSync(join(projectRoot, "package.json"))) {
|
|
151
161
|
let testScript = "npm test";
|
|
@@ -161,7 +171,7 @@ export function detectPolyglotStack(projectRoot = process.cwd()) {
|
|
|
161
171
|
testScript = "npm test";
|
|
162
172
|
buildScript = "npm run build";
|
|
163
173
|
}
|
|
164
|
-
return { ...container, stack: "node", testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
|
|
174
|
+
return { ...container, stack: "node", setupCmd, testCmd: testScript, buildCmd: buildScript, triggerFile: "package.json" };
|
|
165
175
|
}
|
|
166
176
|
|
|
167
177
|
// 7. .NET / C# / F# / PHP / Python root file extension fallback
|
package/src/state.mjs
CHANGED
|
@@ -10,10 +10,20 @@ import {
|
|
|
10
10
|
fsyncSync,
|
|
11
11
|
unlinkSync,
|
|
12
12
|
} from "node:fs";
|
|
13
|
-
import { join } from "node:path";
|
|
13
|
+
import { join, basename } from "node:path";
|
|
14
14
|
import { createHash, randomUUID } from "node:crypto";
|
|
15
15
|
import { hostname } from "node:os";
|
|
16
16
|
import { resolveRoot } from "./config.mjs";
|
|
17
|
+
import { redactSecrets } from "./security.mjs";
|
|
18
|
+
|
|
19
|
+
function scrubStateValue(value) {
|
|
20
|
+
if (typeof value === "string") return redactSecrets(value);
|
|
21
|
+
if (Array.isArray(value)) return value.map(scrubStateValue);
|
|
22
|
+
if (value && typeof value === "object") {
|
|
23
|
+
return Object.fromEntries(Object.entries(value).map(([key, child]) => [key, scrubStateValue(child)]));
|
|
24
|
+
}
|
|
25
|
+
return value;
|
|
26
|
+
}
|
|
17
27
|
|
|
18
28
|
export function ensureDir(dirPath) {
|
|
19
29
|
if (!dirPath || typeof dirPath !== "string" || dirPath.includes("\0")) {
|
|
@@ -109,7 +119,7 @@ export function appendLedger(entry, rootOrOpts = resolveRoot()) {
|
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
const timestamp = new Date().toISOString();
|
|
112
|
-
const rawPayload = { timestamp, ...entry, prevHash };
|
|
122
|
+
const rawPayload = { timestamp, ...scrubStateValue(entry), prevHash };
|
|
113
123
|
const hash = createHash("sha256").update(JSON.stringify(rawPayload)).digest("hex");
|
|
114
124
|
const payload = { ...rawPayload, hash };
|
|
115
125
|
|
|
@@ -321,7 +331,17 @@ export async function withBudget(fn, root = resolveRoot(), limit = 300) {
|
|
|
321
331
|
if (err && typeof err === "object") {
|
|
322
332
|
err.reservationId = reservation.reservationId;
|
|
323
333
|
}
|
|
324
|
-
|
|
334
|
+
try {
|
|
335
|
+
rollbackBudgetReservation(root, reservation.reservationId);
|
|
336
|
+
if (err && typeof err === "object") err.budgetReservationRolledBack = true;
|
|
337
|
+
} catch (_) {}
|
|
338
|
+
try {
|
|
339
|
+
appendLedger({
|
|
340
|
+
event: "budget_reservation_failed",
|
|
341
|
+
reservationId: reservation.reservationId,
|
|
342
|
+
error: err?.message || String(err),
|
|
343
|
+
}, root);
|
|
344
|
+
} catch (_) {}
|
|
325
345
|
throw err;
|
|
326
346
|
}
|
|
327
347
|
}
|
|
@@ -363,8 +383,8 @@ export function isPidAlive(pid, expectedStartTime = null) {
|
|
|
363
383
|
if (!pid || typeof pid !== "number") return false;
|
|
364
384
|
try {
|
|
365
385
|
process.kill(pid, 0);
|
|
366
|
-
} catch (
|
|
367
|
-
return false;
|
|
386
|
+
} catch (err) {
|
|
387
|
+
if (err?.code !== "EPERM") return false;
|
|
368
388
|
}
|
|
369
389
|
|
|
370
390
|
if (expectedStartTime !== null && expectedStartTime !== undefined && process.platform === "linux") {
|
|
@@ -381,7 +401,14 @@ export function isPidAlive(pid, expectedStartTime = null) {
|
|
|
381
401
|
return true;
|
|
382
402
|
}
|
|
383
403
|
|
|
404
|
+
function assertSafeTaskId(taskId) {
|
|
405
|
+
if (typeof taskId !== "string" || !/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(taskId) || basename(taskId) !== taskId) {
|
|
406
|
+
throw new Error("Invalid task id for lock path");
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
384
410
|
export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveRoot(), opts = {}) {
|
|
411
|
+
assertSafeTaskId(taskId);
|
|
385
412
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : (rootOrOpts?.root || resolveRoot());
|
|
386
413
|
const branch = (typeof rootOrOpts === "object" && rootOrOpts?.branch) || opts?.branch || process.env.JULES_BRANCH || process.env.BRANCH_NAME || "";
|
|
387
414
|
const lockDir = getLockDir(root);
|
|
@@ -405,11 +432,13 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
405
432
|
}
|
|
406
433
|
|
|
407
434
|
const startTime = getProcessStartTime(process.pid);
|
|
435
|
+
const concurrencyGroup = String(opts?.concurrencyGroup || opts?.concurrency_group || "").trim();
|
|
408
436
|
const payload = {
|
|
409
437
|
agent: agentName,
|
|
410
438
|
taskId,
|
|
411
439
|
branch,
|
|
412
440
|
files,
|
|
441
|
+
concurrencyGroup,
|
|
413
442
|
pid: process.pid,
|
|
414
443
|
processStartTime: startTime,
|
|
415
444
|
starttime: startTime,
|
|
@@ -418,11 +447,11 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
418
447
|
acquiredAt: new Date().toISOString(),
|
|
419
448
|
};
|
|
420
449
|
|
|
450
|
+
let fd;
|
|
421
451
|
try {
|
|
422
|
-
|
|
452
|
+
fd = openSync(lockFile, "wx");
|
|
423
453
|
writeSync(fd, JSON.stringify(payload, null, 2), "utf-8");
|
|
424
454
|
fsyncSync(fd);
|
|
425
|
-
closeSync(fd);
|
|
426
455
|
return { ok: true, lockFile };
|
|
427
456
|
} catch (err) {
|
|
428
457
|
if (err.code === "EEXIST") {
|
|
@@ -433,11 +462,21 @@ export function acquireLock(agentName, taskId, files = [], rootOrOpts = resolveR
|
|
|
433
462
|
return { ok: false, holder: "unknown", taskId };
|
|
434
463
|
}
|
|
435
464
|
}
|
|
465
|
+
try { unlinkSync(lockFile); } catch (_) {}
|
|
436
466
|
throw err;
|
|
467
|
+
} finally {
|
|
468
|
+
if (fd !== undefined) {
|
|
469
|
+
try { closeSync(fd); } catch (_) {}
|
|
470
|
+
}
|
|
437
471
|
}
|
|
438
472
|
}
|
|
439
473
|
|
|
440
474
|
export function releaseLock(taskId, rootOrOpts = resolveRoot()) {
|
|
475
|
+
try {
|
|
476
|
+
assertSafeTaskId(taskId);
|
|
477
|
+
} catch (_) {
|
|
478
|
+
return false;
|
|
479
|
+
}
|
|
441
480
|
const root = typeof rootOrOpts === "string" ? rootOrOpts : resolveRoot();
|
|
442
481
|
const lockDir = getLockDir(root);
|
|
443
482
|
const lockFile = join(lockDir, `${taskId}.json`);
|
|
@@ -469,3 +508,28 @@ export function lockStatus(rootOrOpts = resolveRoot()) {
|
|
|
469
508
|
return locks;
|
|
470
509
|
}
|
|
471
510
|
|
|
511
|
+
/**
|
|
512
|
+
* Checks if a specific concurrency group is currently locked by an active task session.
|
|
513
|
+
*
|
|
514
|
+
* @param {string} groupName - Concurrency group name
|
|
515
|
+
* @param {string|Object} [rootOrOpts=resolveRoot()] - Project root path or options
|
|
516
|
+
* @param {string} [excludeTaskId=""] - Optional task ID to exclude from check
|
|
517
|
+
* @returns {boolean} True if group is active/locked
|
|
518
|
+
*/
|
|
519
|
+
export function isConcurrencyGroupLocked(groupName, rootOrOpts = resolveRoot(), excludeTaskId = "") {
|
|
520
|
+
if (!groupName || typeof groupName !== "string" || !groupName.trim()) {
|
|
521
|
+
return false;
|
|
522
|
+
}
|
|
523
|
+
const targetGroup = groupName.trim().toLowerCase();
|
|
524
|
+
const locks = lockStatus(rootOrOpts);
|
|
525
|
+
for (const lock of locks) {
|
|
526
|
+
if (excludeTaskId && lock.taskId === excludeTaskId) continue;
|
|
527
|
+
const lockGroup = String(lock.concurrencyGroup || lock.concurrency_group || "").trim().toLowerCase();
|
|
528
|
+
if (lockGroup === targetGroup) {
|
|
529
|
+
return true;
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
return false;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
|