@wrongstack/core 0.282.2 → 0.283.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/dist/{agent-subagent-runner-DCczSoQj.d.ts → agent-subagent-runner-BsuWhB28.d.ts} +2 -2
- package/dist/coordination/index.d.ts +12 -12
- package/dist/coordination/index.js +315 -41
- package/dist/coordination/index.js.map +1 -1
- package/dist/defaults/index.d.ts +12 -12
- package/dist/defaults/index.js +316 -53
- package/dist/defaults/index.js.map +1 -1
- package/dist/{events-BOv8h6I1.d.ts → events-KmxSmvho.d.ts} +5 -0
- package/dist/execution/index.d.ts +7 -7
- package/dist/extension/index.d.ts +2 -2
- package/dist/{global-mailbox-MDDFhLYh.d.ts → global-mailbox-rWVt8YlO.d.ts} +70 -7
- package/dist/{goal-store-BEmDmSKF.d.ts → goal-store-8owBXJT2.d.ts} +1 -1
- package/dist/hq/index.d.ts +3 -3
- package/dist/hq/index.js +106 -32
- package/dist/hq/index.js.map +1 -1
- package/dist/{index-Dd-PJJ8A.d.ts → index-BhCteHAF.d.ts} +1 -1
- package/dist/index.d.ts +289 -19
- package/dist/index.js +919 -110
- package/dist/index.js.map +1 -1
- package/dist/infrastructure/index.d.ts +1 -1
- package/dist/kernel/index.d.ts +4 -4
- package/dist/kernel/index.js.map +1 -1
- package/dist/models/index.js +105 -42
- package/dist/models/index.js.map +1 -1
- package/dist/{multi-agent-coordinator-BRqtpn-a.d.ts → multi-agent-coordinator-CrjeTB9i.d.ts} +1 -1
- package/dist/{null-fleet-bus-BkptvIVo.d.ts → null-fleet-bus-D4e9R7Qc.d.ts} +9 -4
- package/dist/observability/index.d.ts +1 -1
- package/dist/{parallel-eternal-engine-BXECuOVG.d.ts → parallel-eternal-engine-iY2uxocj.d.ts} +4 -4
- package/dist/{provider-runner-CJtCs1Rw.d.ts → provider-runner-oQhDaZmH.d.ts} +1 -1
- package/dist/sdd/index.d.ts +4 -4
- package/dist/sdd/index.js.map +1 -1
- package/dist/storage/index.d.ts +5 -5
- package/dist/storage/index.js.map +1 -1
- package/dist/{todos-checkpoint-Bw83WMh9.d.ts → todos-checkpoint-BwCrj4Cb.d.ts} +1 -1
- package/dist/{tool-executor-4mWN2vHW.d.ts → tool-executor-kElSEgO0.d.ts} +4 -4
- package/dist/types/index.d.ts +8 -8
- package/dist/types/index.js +105 -45
- package/dist/types/index.js.map +1 -1
- package/dist/{worktree-manager-BrtjUFYk.d.ts → worktree-manager-BjyAW30D.d.ts} +1 -1
- package/instructions/modes/audit-lite.md +13 -0
- package/instructions/modes/debug-lite.md +13 -0
- package/instructions/modes/plan-lite.md +14 -0
- package/instructions/modes/refactor-lite.md +13 -0
- package/instructions/modes/research-lite.md +13 -0
- package/instructions/modes/review-lite.md +14 -0
- package/instructions/modes/test-lite.md +13 -0
- package/package.json +1 -1
|
@@ -4974,6 +4974,16 @@ function getKanbanPath(projectRoot, boardId) {
|
|
|
4974
4974
|
}
|
|
4975
4975
|
return resolved;
|
|
4976
4976
|
}
|
|
4977
|
+
function getKanbanEventsPath(projectRoot, boardId) {
|
|
4978
|
+
assertValidBoardId(boardId);
|
|
4979
|
+
const dir = path10.resolve(getKanbanDir(projectRoot));
|
|
4980
|
+
const resolved = path10.resolve(dir, `${boardId}.events.jsonl`);
|
|
4981
|
+
const rel = path10.relative(dir, resolved);
|
|
4982
|
+
if (rel.startsWith("..") || path10.isAbsolute(rel)) {
|
|
4983
|
+
throw invalidBoardId(boardId);
|
|
4984
|
+
}
|
|
4985
|
+
return resolved;
|
|
4986
|
+
}
|
|
4977
4987
|
function isValidBoardId(boardId) {
|
|
4978
4988
|
return BOARD_ID_RE.test(boardId) && !boardId.includes("..");
|
|
4979
4989
|
}
|
|
@@ -5026,6 +5036,14 @@ async function readBoard(projectRoot, boardRef) {
|
|
|
5026
5036
|
throw err;
|
|
5027
5037
|
}
|
|
5028
5038
|
}
|
|
5039
|
+
async function appendKanbanEvent(projectRoot, boardId, event) {
|
|
5040
|
+
const filePath = getKanbanEventsPath(projectRoot, boardId);
|
|
5041
|
+
await withFileLock(filePath, async () => {
|
|
5042
|
+
await fsp7.mkdir(path10.dirname(filePath), { recursive: true });
|
|
5043
|
+
await fsp7.appendFile(filePath, `${JSON.stringify(event)}
|
|
5044
|
+
`, "utf8");
|
|
5045
|
+
});
|
|
5046
|
+
}
|
|
5029
5047
|
async function mutateBoard(projectRoot, boardRef, mutator) {
|
|
5030
5048
|
const boardId = await resolveBoardRef(projectRoot, boardRef);
|
|
5031
5049
|
if (!boardId) return null;
|
|
@@ -5124,9 +5142,12 @@ async function listBoards(projectRoot) {
|
|
|
5124
5142
|
return listBoardSummaries(projectRoot);
|
|
5125
5143
|
}
|
|
5126
5144
|
async function updateTaskAssignment(projectRoot, boardId, taskId, patch) {
|
|
5145
|
+
let event;
|
|
5127
5146
|
const updated = await mutateBoard(projectRoot, boardId, (board) => {
|
|
5128
5147
|
const task = findTask(board, taskId);
|
|
5129
5148
|
if (!task) return null;
|
|
5149
|
+
const previousColumnId = task.columnId;
|
|
5150
|
+
const beforeAssignment = task.assignment ? { ...task.assignment } : void 0;
|
|
5130
5151
|
const nextAssignment = {
|
|
5131
5152
|
...task.assignment ?? { status: "assigned" }
|
|
5132
5153
|
};
|
|
@@ -5164,10 +5185,17 @@ async function updateTaskAssignment(projectRoot, boardId, taskId, patch) {
|
|
|
5164
5185
|
}
|
|
5165
5186
|
delete task.completedAt;
|
|
5166
5187
|
}
|
|
5188
|
+
syncTaskColumnForStatus(board, task, previousColumnId);
|
|
5167
5189
|
task.updatedAt = nowIso();
|
|
5168
5190
|
board.updatedAt = task.updatedAt;
|
|
5191
|
+
event = createKanbanEvent(board.id, task, assignmentEventType(task.assignment.status), {
|
|
5192
|
+
before: beforeAssignment,
|
|
5193
|
+
after: { ...task.assignment },
|
|
5194
|
+
note: patch.error ?? patch.lastResult
|
|
5195
|
+
});
|
|
5169
5196
|
return task;
|
|
5170
5197
|
});
|
|
5198
|
+
if (updated && event) await emitKanbanEvent(projectRoot, event);
|
|
5171
5199
|
return updated?.result ? updated.board : null;
|
|
5172
5200
|
}
|
|
5173
5201
|
async function claimReadyTask(projectRoot, input = {}) {
|
|
@@ -5214,14 +5242,25 @@ function buildAssignment(input) {
|
|
|
5214
5242
|
...input.fallbackProfile !== void 0 ? { fallbackProfile: input.fallbackProfile } : {},
|
|
5215
5243
|
...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
|
|
5216
5244
|
...input.tools !== void 0 ? { tools: input.tools } : {},
|
|
5217
|
-
...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {}
|
|
5245
|
+
...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
|
|
5246
|
+
...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
|
|
5247
|
+
...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
|
|
5248
|
+
...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
|
|
5249
|
+
...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
|
|
5250
|
+
...input.attempt !== void 0 ? { attempt: input.attempt } : {},
|
|
5251
|
+
...input.maxAttempts !== void 0 ? { maxAttempts: input.maxAttempts } : {},
|
|
5252
|
+
...input.costCeilingUsd !== void 0 ? { costCeilingUsd: input.costCeilingUsd } : {},
|
|
5253
|
+
...input.retryPolicy !== void 0 ? { retryPolicy: input.retryPolicy } : {},
|
|
5254
|
+
...input.lastFailureKind !== void 0 ? { lastFailureKind: input.lastFailureKind } : {}
|
|
5218
5255
|
};
|
|
5219
5256
|
}
|
|
5220
5257
|
async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
|
|
5258
|
+
let event;
|
|
5221
5259
|
const updated = await mutateBoard(projectRoot, boardId, (board) => {
|
|
5222
5260
|
const candidates = input.taskId ? [findTask(board, input.taskId)].filter((task2) => Boolean(task2)) : board.tasks.filter((task2) => isTaskReadyForWork(board, task2)).sort(compareTasksForWork);
|
|
5223
5261
|
const task = candidates.find((candidate) => isTaskReadyForWork(board, candidate));
|
|
5224
5262
|
if (!task) return null;
|
|
5263
|
+
const previousColumnId = task.columnId;
|
|
5225
5264
|
const current = task.assignment;
|
|
5226
5265
|
const assignment = buildAssignment({
|
|
5227
5266
|
...current?.agentId !== void 0 ? { agentId: current.agentId } : {},
|
|
@@ -5233,6 +5272,15 @@ async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
|
|
|
5233
5272
|
...current?.fallbackModels !== void 0 ? { fallbackModels: current.fallbackModels } : {},
|
|
5234
5273
|
...current?.tools !== void 0 ? { tools: current.tools } : {},
|
|
5235
5274
|
...current?.allowedCapabilities !== void 0 ? { allowedCapabilities: current.allowedCapabilities } : {},
|
|
5275
|
+
...current?.leaseId !== void 0 ? { leaseId: current.leaseId } : {},
|
|
5276
|
+
...current?.claimedAt !== void 0 ? { claimedAt: current.claimedAt } : {},
|
|
5277
|
+
...current?.heartbeatAt !== void 0 ? { heartbeatAt: current.heartbeatAt } : {},
|
|
5278
|
+
...current?.leaseExpiresAt !== void 0 ? { leaseExpiresAt: current.leaseExpiresAt } : {},
|
|
5279
|
+
...current?.attempt !== void 0 ? { attempt: current.attempt } : {},
|
|
5280
|
+
...current?.maxAttempts !== void 0 ? { maxAttempts: current.maxAttempts } : {},
|
|
5281
|
+
...current?.costCeilingUsd !== void 0 ? { costCeilingUsd: current.costCeilingUsd } : {},
|
|
5282
|
+
...current?.retryPolicy !== void 0 ? { retryPolicy: current.retryPolicy } : {},
|
|
5283
|
+
...current?.lastFailureKind !== void 0 ? { lastFailureKind: current.lastFailureKind } : {},
|
|
5236
5284
|
...input.agentId !== void 0 ? { agentId: input.agentId } : {},
|
|
5237
5285
|
...input.name !== void 0 ? { name: input.name } : {},
|
|
5238
5286
|
...input.role !== void 0 ? { role: input.role } : {},
|
|
@@ -5242,10 +5290,20 @@ async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
|
|
|
5242
5290
|
...input.fallbackModels !== void 0 ? { fallbackModels: input.fallbackModels } : {},
|
|
5243
5291
|
...input.tools !== void 0 ? { tools: input.tools } : {},
|
|
5244
5292
|
...input.allowedCapabilities !== void 0 ? { allowedCapabilities: input.allowedCapabilities } : {},
|
|
5293
|
+
...input.leaseId !== void 0 ? { leaseId: input.leaseId } : {},
|
|
5294
|
+
...input.claimedAt !== void 0 ? { claimedAt: input.claimedAt } : {},
|
|
5295
|
+
...input.heartbeatAt !== void 0 ? { heartbeatAt: input.heartbeatAt } : {},
|
|
5296
|
+
...input.leaseExpiresAt !== void 0 ? { leaseExpiresAt: input.leaseExpiresAt } : {},
|
|
5297
|
+
...input.attempt !== void 0 ? { attempt: input.attempt } : {},
|
|
5298
|
+
...input.maxAttempts !== void 0 ? { maxAttempts: input.maxAttempts } : {},
|
|
5299
|
+
...input.costCeilingUsd !== void 0 ? { costCeilingUsd: input.costCeilingUsd } : {},
|
|
5300
|
+
...input.retryPolicy !== void 0 ? { retryPolicy: input.retryPolicy } : {},
|
|
5301
|
+
...input.lastFailureKind !== void 0 ? { lastFailureKind: input.lastFailureKind } : {},
|
|
5245
5302
|
...input.assignee !== void 0 ? { assignee: input.assignee } : {},
|
|
5246
5303
|
status: input.status ?? "queued"
|
|
5247
5304
|
});
|
|
5248
|
-
assignment.
|
|
5305
|
+
assignment.claimedAt = assignment.claimedAt ?? nowIso();
|
|
5306
|
+
assignment.dispatchedAt = assignment.dispatchedAt ?? assignment.claimedAt;
|
|
5249
5307
|
task.assignment = assignment;
|
|
5250
5308
|
if (assignment.agentId ?? assignment.role ?? assignment.name) {
|
|
5251
5309
|
task.assignedAgent = assignment.agentId ?? assignment.role ?? assignment.name;
|
|
@@ -5255,10 +5313,16 @@ async function claimReadyTaskOnBoard(projectRoot, boardId, input) {
|
|
|
5255
5313
|
}
|
|
5256
5314
|
task.status = assignment.status === "running" ? "in_progress" : "ready";
|
|
5257
5315
|
delete task.completedAt;
|
|
5316
|
+
syncTaskColumnForStatus(board, task, previousColumnId);
|
|
5258
5317
|
task.updatedAt = nowIso();
|
|
5259
5318
|
board.updatedAt = task.updatedAt;
|
|
5319
|
+
event = createKanbanEvent(board.id, task, "task.claimed", {
|
|
5320
|
+
before: current ? { ...current } : void 0,
|
|
5321
|
+
after: { ...assignment }
|
|
5322
|
+
});
|
|
5260
5323
|
return task;
|
|
5261
5324
|
});
|
|
5325
|
+
if (updated && event) await emitKanbanEvent(projectRoot, event);
|
|
5262
5326
|
return updated?.result ? { board: updated.board, task: updated.result } : null;
|
|
5263
5327
|
}
|
|
5264
5328
|
function compareTasksForWork(a, b) {
|
|
@@ -5292,6 +5356,18 @@ function findTask(board, taskId) {
|
|
|
5292
5356
|
}
|
|
5293
5357
|
return matches[0];
|
|
5294
5358
|
}
|
|
5359
|
+
function existingColumnId(board, columnId) {
|
|
5360
|
+
if (!columnId) return void 0;
|
|
5361
|
+
const exact = board.columns.find((column) => column.id === columnId);
|
|
5362
|
+
if (exact) return exact.id;
|
|
5363
|
+
const matches = board.columns.filter((column) => column.id.startsWith(columnId));
|
|
5364
|
+
if (matches.length > 1) {
|
|
5365
|
+
throw new Error(
|
|
5366
|
+
`Ambiguous kanban column id "${columnId}": ${matches.slice(0, 5).map((column) => column.id).join(", ")}`
|
|
5367
|
+
);
|
|
5368
|
+
}
|
|
5369
|
+
return matches[0]?.id;
|
|
5370
|
+
}
|
|
5295
5371
|
function isTaskReadyForWork(board, task) {
|
|
5296
5372
|
if (!["pending", "ready"].includes(task.status)) return false;
|
|
5297
5373
|
if (task.assignment && ["queued", "running"].includes(task.assignment.status)) return false;
|
|
@@ -5299,6 +5375,60 @@ function isTaskReadyForWork(board, task) {
|
|
|
5299
5375
|
if (!areDependenciesMet(board, task.id)) return false;
|
|
5300
5376
|
return true;
|
|
5301
5377
|
}
|
|
5378
|
+
function createKanbanEvent(boardId, task, type, details = {}) {
|
|
5379
|
+
return {
|
|
5380
|
+
id: randomUUID(),
|
|
5381
|
+
boardId,
|
|
5382
|
+
taskId: task.id,
|
|
5383
|
+
type,
|
|
5384
|
+
ts: nowIso(),
|
|
5385
|
+
...task.assignment?.agentId !== void 0 ? { actor: task.assignment.agentId } : {},
|
|
5386
|
+
...task.assignment?.subagentId !== void 0 ? { subagentId: task.assignment.subagentId } : {},
|
|
5387
|
+
...task.assignment?.runTaskId !== void 0 ? { runTaskId: task.assignment.runTaskId } : {},
|
|
5388
|
+
...details
|
|
5389
|
+
};
|
|
5390
|
+
}
|
|
5391
|
+
async function emitKanbanEvent(projectRoot, event) {
|
|
5392
|
+
try {
|
|
5393
|
+
await appendKanbanEvent(projectRoot, event.boardId, event);
|
|
5394
|
+
} catch {
|
|
5395
|
+
}
|
|
5396
|
+
}
|
|
5397
|
+
function assignmentEventType(status) {
|
|
5398
|
+
return status === "completed" ? "task.assignment.completed" : status === "failed" ? "task.assignment.failed" : status === "running" ? "task.assignment.running" : status === "cancelled" ? "task.assignment.cancelled" : "task.assignment.updated";
|
|
5399
|
+
}
|
|
5400
|
+
function columnIdForKanbanStatus(board, status) {
|
|
5401
|
+
const preferred = status === "completed" ? ["done", "completed"] : status === "in_progress" ? ["in-progress", "progress", "doing"] : status === "review" || status === "failed" ? ["review"] : status === "blocked" ? ["blocked", "backlog"] : status === "ready" ? ["todo", "ready", "backlog"] : status === "archived" ? ["done", "archive", "backlog"] : ["todo", "backlog"];
|
|
5402
|
+
for (const columnRef of preferred) {
|
|
5403
|
+
const columnId = existingColumnId(board, columnRef);
|
|
5404
|
+
if (columnId) return columnId;
|
|
5405
|
+
}
|
|
5406
|
+
return board.columns[0]?.id;
|
|
5407
|
+
}
|
|
5408
|
+
function normalizeColumnTaskOrders(board, columnId) {
|
|
5409
|
+
board.tasks.filter((task) => task.columnId === columnId).sort((a, b) => a.order - b.order || a.createdAt.localeCompare(b.createdAt)).forEach((task, index) => {
|
|
5410
|
+
task.order = index;
|
|
5411
|
+
});
|
|
5412
|
+
}
|
|
5413
|
+
function syncTaskColumnForStatus(board, task, previousColumnId) {
|
|
5414
|
+
const nextColumnId = columnIdForKanbanStatus(board, task.status);
|
|
5415
|
+
if (!nextColumnId || nextColumnId === task.columnId) return;
|
|
5416
|
+
task.columnId = nextColumnId;
|
|
5417
|
+
normalizeColumnTaskOrders(board, previousColumnId);
|
|
5418
|
+
placeTaskInColumn(board, task, nextColumnId, void 0);
|
|
5419
|
+
}
|
|
5420
|
+
function placeTaskInColumn(board, task, columnId, targetOrder) {
|
|
5421
|
+
const tasks = board.tasks.filter((candidate) => candidate.columnId === columnId && candidate.id !== task.id).sort((a, b) => a.order - b.order || a.createdAt.localeCompare(b.createdAt));
|
|
5422
|
+
const index = clampOrder(targetOrder, tasks.length);
|
|
5423
|
+
tasks.splice(index, 0, task);
|
|
5424
|
+
tasks.forEach((candidate, order) => {
|
|
5425
|
+
candidate.order = order;
|
|
5426
|
+
});
|
|
5427
|
+
}
|
|
5428
|
+
function clampOrder(order, max) {
|
|
5429
|
+
if (!Number.isFinite(order)) return max;
|
|
5430
|
+
return Math.max(0, Math.min(Math.trunc(order), max));
|
|
5431
|
+
}
|
|
5302
5432
|
function nowIso() {
|
|
5303
5433
|
return (/* @__PURE__ */ new Date()).toISOString();
|
|
5304
5434
|
}
|
|
@@ -5426,6 +5556,9 @@ function makeLLMClassifier(complete) {
|
|
|
5426
5556
|
}
|
|
5427
5557
|
|
|
5428
5558
|
// src/coordination/director-tools.ts
|
|
5559
|
+
function nowIso2() {
|
|
5560
|
+
return (/* @__PURE__ */ new Date()).toISOString();
|
|
5561
|
+
}
|
|
5429
5562
|
function makeSpawnTool(director, roster) {
|
|
5430
5563
|
const inputSchema = {
|
|
5431
5564
|
type: "object",
|
|
@@ -5983,8 +6116,8 @@ function makeAssignTool(director) {
|
|
|
5983
6116
|
function makeKanbanQueueTool(director, roster) {
|
|
5984
6117
|
return {
|
|
5985
6118
|
name: "kanban_queue",
|
|
5986
|
-
description: "Claim dependency-ready Kanban tasks and dispatch them into the Director fleet. Preserves per-task provider/model/role/tool routing metadata, assigns each claimed task to a spawned subagent, and can await results while writing completion back to Kanban.",
|
|
5987
|
-
usageHint: 'Use action:"dispatch_ready" with optional boardId/taskId/maxTasks. Set awaitCompletion:true when you want this call to update Kanban to completed/failed before returning; otherwise use await_tasks and kanban mark_assignment later.',
|
|
6119
|
+
description: "Claim dependency-ready Kanban tasks and dispatch them into the Director fleet. Preserves per-task provider/model/role/tool routing metadata, seeds lease metadata (claimedAt, leaseExpiresAt, heartbeatAt), assigns each claimed task to a spawned subagent, instructs workers to call kanban heartbeat_assignment periodically, and can await results while writing completion back to Kanban.",
|
|
6120
|
+
usageHint: 'Use action:"dispatch_ready" with optional boardId/taskId/maxTasks. Set heartbeatIntervalMs (default 60s) and leaseTtlMs (default 5m) to size stale-recovery windows. Set awaitCompletion:true when you want this call to update Kanban to completed/failed before returning; otherwise use await_tasks and kanban mark_assignment later.',
|
|
5988
6121
|
permission: "auto",
|
|
5989
6122
|
mutating: true,
|
|
5990
6123
|
capabilities: [ToolCapabilities.SUBAGENT_SPAWN, ToolCapabilities.FS_WRITE],
|
|
@@ -6015,6 +6148,16 @@ function makeKanbanQueueTool(director, roster) {
|
|
|
6015
6148
|
minimum: 1,
|
|
6016
6149
|
description: "Optional per-assigned-task tool-call cap."
|
|
6017
6150
|
},
|
|
6151
|
+
heartbeatIntervalMs: {
|
|
6152
|
+
type: "number",
|
|
6153
|
+
minimum: 1e3,
|
|
6154
|
+
description: "Suggested heartbeat interval for the worker. Default 60000 (60s). Workers are instructed to call kanban heartbeat_assignment at this cadence."
|
|
6155
|
+
},
|
|
6156
|
+
leaseTtlMs: {
|
|
6157
|
+
type: "number",
|
|
6158
|
+
minimum: 1e3,
|
|
6159
|
+
description: "Lease time-to-live seeded on dispatch. Default 300000 (5m). Workers should refresh via heartbeat_assignment before expiry."
|
|
6160
|
+
},
|
|
6018
6161
|
agentId: { type: "string" },
|
|
6019
6162
|
name: { type: "string" },
|
|
6020
6163
|
role: { type: "string" },
|
|
@@ -6037,6 +6180,14 @@ function makeKanbanQueueTool(director, roster) {
|
|
|
6037
6180
|
const projectRoot = ctx.projectRoot;
|
|
6038
6181
|
if (!projectRoot) return { error: "kanban_queue requires ctx.projectRoot." };
|
|
6039
6182
|
const maxTasks = Math.max(1, Math.min(20, Math.floor(i.maxTasks ?? 1)));
|
|
6183
|
+
const leaseTtlMs = Math.max(1e3, Math.floor(i.leaseTtlMs ?? 5 * 60 * 1e3));
|
|
6184
|
+
const claimedAt = nowIso2();
|
|
6185
|
+
const leaseSeeding = {
|
|
6186
|
+
leaseId: randomUUID(),
|
|
6187
|
+
claimedAt,
|
|
6188
|
+
heartbeatAt: claimedAt,
|
|
6189
|
+
leaseExpiresAt: new Date(Date.now() + leaseTtlMs).toISOString()
|
|
6190
|
+
};
|
|
6040
6191
|
const candidateTaskIds = i.taskId !== void 0 ? [i.taskId] : i.query ? (await listReadyTasks(projectRoot, {
|
|
6041
6192
|
...i.boardId !== void 0 ? { boardId: i.boardId } : {}
|
|
6042
6193
|
})).filter((candidate) => matchesKanbanQueueQuery(candidate.task, i.query ?? "")).slice(0, maxTasks).map((candidate) => candidate.task.id) : void 0;
|
|
@@ -6057,6 +6208,7 @@ function makeKanbanQueueTool(director, roster) {
|
|
|
6057
6208
|
...i.fallbackModels !== void 0 ? { fallbackModels: i.fallbackModels } : {},
|
|
6058
6209
|
...i.tools !== void 0 ? { tools: i.tools } : {},
|
|
6059
6210
|
...i.allowedCapabilities !== void 0 ? { allowedCapabilities: i.allowedCapabilities } : {},
|
|
6211
|
+
...leaseSeeding !== void 0 ? leaseSeeding : {},
|
|
6060
6212
|
status: "queued"
|
|
6061
6213
|
});
|
|
6062
6214
|
if (!claim) {
|
|
@@ -6065,13 +6217,34 @@ function makeKanbanQueueTool(director, roster) {
|
|
|
6065
6217
|
}
|
|
6066
6218
|
let subagentId;
|
|
6067
6219
|
let runTaskId;
|
|
6220
|
+
const costCeiling = claim.task.assignment?.costCeilingUsd;
|
|
6221
|
+
if (costCeiling !== void 0) {
|
|
6222
|
+
const remaining = director.getRemainingBudgetUsd();
|
|
6223
|
+
if (remaining !== void 0 && remaining < costCeiling) {
|
|
6224
|
+
await updateTaskAssignment(projectRoot, claim.board.id, claim.task.id, {
|
|
6225
|
+
status: "failed",
|
|
6226
|
+
error: `Cost ceiling ${costCeiling} exceeds remaining budget ${remaining.toFixed(4)}`,
|
|
6227
|
+
lastResult: "Skipped by kanban_queue cost gate (Sprint 3)"
|
|
6228
|
+
});
|
|
6229
|
+
errors.push({
|
|
6230
|
+
taskId: claim.task.id,
|
|
6231
|
+
error: `Cost ceiling ${costCeiling} exceeds remaining budget ${remaining.toFixed(4)}`
|
|
6232
|
+
});
|
|
6233
|
+
continue;
|
|
6234
|
+
}
|
|
6235
|
+
}
|
|
6068
6236
|
try {
|
|
6069
6237
|
const config = buildKanbanSubagentConfig(claim.task, i, roster);
|
|
6070
6238
|
subagentId = await director.spawn(config);
|
|
6071
6239
|
runTaskId = await director.assign({
|
|
6072
6240
|
id: randomUUID(),
|
|
6073
6241
|
subagentId,
|
|
6074
|
-
description: buildKanbanFleetTaskPrompt(claim.board, claim.task
|
|
6242
|
+
description: buildKanbanFleetTaskPrompt(claim.board, claim.task, {
|
|
6243
|
+
heartbeatIntervalMs: i.heartbeatIntervalMs ?? 6e4,
|
|
6244
|
+
leaseTtlMs,
|
|
6245
|
+
leaseId: leaseSeeding.leaseId,
|
|
6246
|
+
leaseExpiresAt: leaseSeeding.leaseExpiresAt
|
|
6247
|
+
}),
|
|
6075
6248
|
...i.maxToolCalls !== void 0 ? { maxToolCalls: i.maxToolCalls } : {},
|
|
6076
6249
|
...i.timeoutMs !== void 0 ? { timeoutMs: i.timeoutMs } : {},
|
|
6077
6250
|
context: {
|
|
@@ -6169,7 +6342,9 @@ function normalizeKanbanQueueInput(input) {
|
|
|
6169
6342
|
fallbackModels: stringArray(raw.fallbackModels),
|
|
6170
6343
|
tools: stringArray(raw.tools),
|
|
6171
6344
|
allowedCapabilities: stringArray(raw.allowedCapabilities),
|
|
6172
|
-
worktree: normalizeWorktreeOverride(raw.worktree)
|
|
6345
|
+
worktree: normalizeWorktreeOverride(raw.worktree),
|
|
6346
|
+
heartbeatIntervalMs: typeof raw.heartbeatIntervalMs === "number" ? raw.heartbeatIntervalMs : void 0,
|
|
6347
|
+
leaseTtlMs: typeof raw.leaseTtlMs === "number" ? raw.leaseTtlMs : void 0
|
|
6173
6348
|
};
|
|
6174
6349
|
}
|
|
6175
6350
|
function buildKanbanSubagentConfig(task, input, roster) {
|
|
@@ -6187,7 +6362,8 @@ function buildKanbanSubagentConfig(task, input, roster) {
|
|
|
6187
6362
|
...input.fallbackModels ?? assignment?.fallbackModels ? { fallbackModels: input.fallbackModels ?? assignment?.fallbackModels } : {},
|
|
6188
6363
|
...tools ? { tools: ensureKanbanTool(tools) } : {},
|
|
6189
6364
|
...input.allowedCapabilities ?? assignment?.allowedCapabilities ? { allowedCapabilities: input.allowedCapabilities ?? assignment?.allowedCapabilities } : {},
|
|
6190
|
-
...input.worktree !== void 0 ? { worktree: input.worktree } : {}
|
|
6365
|
+
...input.worktree !== void 0 ? { worktree: input.worktree } : {},
|
|
6366
|
+
...assignment?.costCeilingUsd !== void 0 ? { maxCostUsd: assignment.costCeilingUsd } : {}
|
|
6191
6367
|
};
|
|
6192
6368
|
}
|
|
6193
6369
|
function ensureKanbanTool(tools) {
|
|
@@ -6207,7 +6383,7 @@ function matchesKanbanQueueQuery(task, query) {
|
|
|
6207
6383
|
...task.labels ?? []
|
|
6208
6384
|
].filter(Boolean).some((value) => String(value).toLowerCase().includes(normalized));
|
|
6209
6385
|
}
|
|
6210
|
-
function buildKanbanFleetTaskPrompt(board, task) {
|
|
6386
|
+
function buildKanbanFleetTaskPrompt(board, task, lease) {
|
|
6211
6387
|
const dependencyLines = (task.dependsOn ?? []).map((depId) => board.tasks.find((candidate) => candidate.id === depId)).filter((dep) => Boolean(dep)).map((dep) => `- ${dep.title} [${dep.status}] (${dep.id})`);
|
|
6212
6388
|
const checks = task.successCriteria?.map((check) => `- ${check.description}`).join("\n");
|
|
6213
6389
|
const metrics = task.goalMetrics?.map(
|
|
@@ -6254,9 +6430,24 @@ ${checks}` : "",
|
|
|
6254
6430
|
metrics ? `Goal metrics:
|
|
6255
6431
|
${metrics}` : "",
|
|
6256
6432
|
task.labels?.length ? `Labels: ${task.labels.join(", ")}` : "",
|
|
6433
|
+
"Lease contract (Sprint 1):",
|
|
6434
|
+
`- leaseId: ${lease.leaseId}`,
|
|
6435
|
+
`- claimedAt: ${task.assignment?.claimedAt ?? "<unknown>"}`,
|
|
6436
|
+
`- leaseExpiresAt: ${lease.leaseExpiresAt}`,
|
|
6437
|
+
`- expected heartbeatIntervalMs: ${lease.heartbeatIntervalMs}`,
|
|
6438
|
+
`- expected leaseTtlMs: ${lease.leaseTtlMs}`,
|
|
6439
|
+
"",
|
|
6440
|
+
"Retry policy:",
|
|
6441
|
+
`- retryPolicy: ${task.assignment?.retryPolicy ?? task.retryPolicy ?? "<unset>"}`,
|
|
6442
|
+
`- maxAttempts: ${task.assignment?.maxAttempts ?? "<unset>"}`,
|
|
6443
|
+
`- costCeilingUsd: ${task.assignment?.costCeilingUsd ?? task.costCeilingUsd ?? "<unset>"}`,
|
|
6257
6444
|
"",
|
|
6258
6445
|
"Work this task end-to-end. If scope is too broad or too small, use the kanban tool to split_task or merge_tasks instead of losing traceability.",
|
|
6259
|
-
`When you start
|
|
6446
|
+
`When you start, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", and assignmentStatus "running". Include subagentId and runTaskId when you have them.`,
|
|
6447
|
+
`To stay alive in the queue, call kanban with action "heartbeat_assignment", boardId "${board.id}", taskId "${task.id}", and heartbeatAt set to the current time. Cadence must be <= heartbeatIntervalMs and well before leaseExpiresAt.`,
|
|
6448
|
+
`When you finish, call kanban with action "mark_assignment", boardId "${board.id}", taskId "${task.id}", and assignmentStatus "completed" or "failed". Include lastResult or error.`,
|
|
6449
|
+
`If you cannot finish in time, call kanban with action "heartbeat_assignment" to extend the lease, or with action "release_task" to release so another worker can claim. Do NOT silently abandon the assignment.`,
|
|
6450
|
+
'On failure the host may call kanban with action "recover_stale" (mode: retry/release/fail); respect its decisions and do not duplicate work in parallel.',
|
|
6260
6451
|
"When finished, report what changed, what you verified, and any remaining blockers."
|
|
6261
6452
|
].filter(Boolean).join("\n");
|
|
6262
6453
|
}
|
|
@@ -8446,6 +8637,15 @@ var Director = class _Director {
|
|
|
8446
8637
|
getLeaderContextPressure() {
|
|
8447
8638
|
return this.leaderContextPressure;
|
|
8448
8639
|
}
|
|
8640
|
+
/**
|
|
8641
|
+
* Remaining USD budget for the entire fleet (when a cap is configured).
|
|
8642
|
+
* Returns `undefined` when no cap was set (Infinity).
|
|
8643
|
+
*/
|
|
8644
|
+
getRemainingBudgetUsd() {
|
|
8645
|
+
if (this.maxFleetCostUsd === Number.POSITIVE_INFINITY) return void 0;
|
|
8646
|
+
const totalCost = this.usage.snapshot().total?.cost ?? 0;
|
|
8647
|
+
return Math.max(0, this.maxFleetCostUsd - totalCost);
|
|
8648
|
+
}
|
|
8449
8649
|
resolveMaxContext() {
|
|
8450
8650
|
const resolved = typeof this.maxContext === "function" ? this.maxContext() : this.maxContext;
|
|
8451
8651
|
return resolved && resolved > 0 ? resolved : 128e3;
|
|
@@ -12072,6 +12272,17 @@ var GlobalMailbox = class {
|
|
|
12072
12272
|
_messageCacheMtime = -1;
|
|
12073
12273
|
/** Size of the file when `_messageCache` was populated (extra guard). */
|
|
12074
12274
|
_messageCacheSize = -1;
|
|
12275
|
+
/**
|
|
12276
|
+
* Serializes reads of the message file so overlapping concurrent
|
|
12277
|
+
* `_readMessagesCached()` calls don't both enter the incremental "file
|
|
12278
|
+
* only grew" branch against the same stale `_messageCacheSize` and
|
|
12279
|
+
* each push the same tail bytes onto the cache (duplicating every
|
|
12280
|
+
* appended message). The chain runs each read to completion before
|
|
12281
|
+
* the next starts, in issue order — readers are read-only and never
|
|
12282
|
+
* conflict with each other on content, only on the cache mutation
|
|
12283
|
+
* that follows the read. Pattern mirrors `DefaultMemoryStore.runSerialized`.
|
|
12284
|
+
*/
|
|
12285
|
+
_readChain = Promise.resolve([]);
|
|
12075
12286
|
/**
|
|
12076
12287
|
* @param projectDir — `~/.wrongstack/projects/<slug>/`
|
|
12077
12288
|
* @param events — optional EventBus for real-time TUI/WebUI notifications
|
|
@@ -12125,7 +12336,8 @@ var GlobalMailbox = class {
|
|
|
12125
12336
|
await fsp7.mkdir(path10.dirname(this.messagePath), { recursive: true });
|
|
12126
12337
|
await withFileLock(this.messagePath, async () => {
|
|
12127
12338
|
await fsp7.appendFile(this.messagePath, line, "utf8");
|
|
12128
|
-
this.
|
|
12339
|
+
const { mtimeMs, size } = await this._statMessageFile();
|
|
12340
|
+
this._pushToCache(msg, mtimeMs, size);
|
|
12129
12341
|
});
|
|
12130
12342
|
this.publishHqMailboxEvent({
|
|
12131
12343
|
mailboxId: this.hqMailboxId,
|
|
@@ -12169,7 +12381,6 @@ var GlobalMailbox = class {
|
|
|
12169
12381
|
for (const a of input.acks) {
|
|
12170
12382
|
byId.set(a.messageId, a);
|
|
12171
12383
|
}
|
|
12172
|
-
let cacheSnapshot = null;
|
|
12173
12384
|
await withFileLock(this.messagePath, async () => {
|
|
12174
12385
|
const all = await this._readMessagesFresh();
|
|
12175
12386
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -12197,9 +12408,9 @@ var GlobalMailbox = class {
|
|
|
12197
12408
|
const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
|
|
12198
12409
|
await fsp7.writeFile(this.messagePath, serialized, "utf8");
|
|
12199
12410
|
}
|
|
12200
|
-
|
|
12411
|
+
const { mtimeMs, size } = await this._statMessageFile();
|
|
12412
|
+
this._setMessageCache(all, mtimeMs, size);
|
|
12201
12413
|
});
|
|
12202
|
-
if (cacheSnapshot) this._setMessageCache(cacheSnapshot);
|
|
12203
12414
|
for (const message of updated) {
|
|
12204
12415
|
this.publishHqMailboxEvent({
|
|
12205
12416
|
mailboxId: this.hqMailboxId,
|
|
@@ -12223,7 +12434,6 @@ var GlobalMailbox = class {
|
|
|
12223
12434
|
}
|
|
12224
12435
|
async softDelete(mailId, by) {
|
|
12225
12436
|
let updated = null;
|
|
12226
|
-
let cacheSnapshot = null;
|
|
12227
12437
|
await withFileLock(this.messagePath, async () => {
|
|
12228
12438
|
const all = await this._readMessagesFresh();
|
|
12229
12439
|
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
@@ -12239,9 +12449,9 @@ var GlobalMailbox = class {
|
|
|
12239
12449
|
}
|
|
12240
12450
|
const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
|
|
12241
12451
|
await fsp7.writeFile(this.messagePath, serialized, "utf8");
|
|
12242
|
-
|
|
12452
|
+
const { mtimeMs, size } = await this._statMessageFile();
|
|
12453
|
+
this._setMessageCache(all, mtimeMs, size);
|
|
12243
12454
|
});
|
|
12244
|
-
if (cacheSnapshot) this._setMessageCache(cacheSnapshot);
|
|
12245
12455
|
if (updated !== null) {
|
|
12246
12456
|
this.publishHqMailboxEvent({
|
|
12247
12457
|
mailboxId: this.hqMailboxId,
|
|
@@ -12254,7 +12464,6 @@ var GlobalMailbox = class {
|
|
|
12254
12464
|
}
|
|
12255
12465
|
async restore(mailId) {
|
|
12256
12466
|
let updated = null;
|
|
12257
|
-
let cacheSnapshot = null;
|
|
12258
12467
|
await withFileLock(this.messagePath, async () => {
|
|
12259
12468
|
const all = await this._readMessagesFresh();
|
|
12260
12469
|
for (const m of all) {
|
|
@@ -12265,9 +12474,9 @@ var GlobalMailbox = class {
|
|
|
12265
12474
|
}
|
|
12266
12475
|
const serialized = all.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
|
|
12267
12476
|
await fsp7.writeFile(this.messagePath, serialized, "utf8");
|
|
12268
|
-
|
|
12477
|
+
const { mtimeMs, size } = await this._statMessageFile();
|
|
12478
|
+
this._setMessageCache(all, mtimeMs, size);
|
|
12269
12479
|
});
|
|
12270
|
-
if (cacheSnapshot) this._setMessageCache(cacheSnapshot);
|
|
12271
12480
|
if (updated !== null) {
|
|
12272
12481
|
this.publishHqMailboxEvent({
|
|
12273
12482
|
mailboxId: this.hqMailboxId,
|
|
@@ -12512,8 +12721,9 @@ var GlobalMailbox = class {
|
|
|
12512
12721
|
async clearAll() {
|
|
12513
12722
|
await withFileLock(this.messagePath, async () => {
|
|
12514
12723
|
await fsp7.writeFile(this.messagePath, "", "utf8");
|
|
12724
|
+
const { mtimeMs, size } = await this._statMessageFile();
|
|
12725
|
+
this._setMessageCache([], mtimeMs, size);
|
|
12515
12726
|
});
|
|
12516
|
-
this._setMessageCache([]);
|
|
12517
12727
|
}
|
|
12518
12728
|
async purgeStale(opts) {
|
|
12519
12729
|
const COMPLETED_MAX_AGE_MS = opts?.completedMaxAgeMs ?? 864e5;
|
|
@@ -12545,7 +12755,8 @@ var GlobalMailbox = class {
|
|
|
12545
12755
|
const content = kept.map((m) => JSON.stringify(m)).join(LINE_SEPARATOR) + LINE_SEPARATOR;
|
|
12546
12756
|
await fsp7.writeFile(this.messagePath, content, "utf8");
|
|
12547
12757
|
}
|
|
12548
|
-
this.
|
|
12758
|
+
const { mtimeMs, size } = await this._statMessageFile();
|
|
12759
|
+
this._setMessageCache(kept, mtimeMs, size);
|
|
12549
12760
|
});
|
|
12550
12761
|
return {
|
|
12551
12762
|
completedPurged,
|
|
@@ -12632,14 +12843,43 @@ var GlobalMailbox = class {
|
|
|
12632
12843
|
* from writers that just took the file lock — the read reflects the
|
|
12633
12844
|
* authoritative post-lock state and should be served to subsequent
|
|
12634
12845
|
* queries without re-reading.
|
|
12846
|
+
*
|
|
12847
|
+
* The mtime/size are captured from the stat at read time so the cache
|
|
12848
|
+
* trackers match exactly what was parsed. Writers that subsequently
|
|
12849
|
+
* rewrite the file MUST re-stat after the write and re-promote with the
|
|
12850
|
+
* post-write values (see ackMany / softDelete / restore / purgeStale /
|
|
12851
|
+
* clearAll) — otherwise a concurrent reader misclassifies the rewrite
|
|
12852
|
+
* as a "file only grew" append and corrupts the cache.
|
|
12635
12853
|
*/
|
|
12636
12854
|
async _readMessagesFresh() {
|
|
12637
12855
|
const all = await this._readMessages();
|
|
12638
|
-
this.
|
|
12856
|
+
const { mtimeMs, size } = await this._statMessageFile();
|
|
12857
|
+
this._setMessageCache(all, mtimeMs, size);
|
|
12639
12858
|
return all;
|
|
12640
12859
|
}
|
|
12641
12860
|
/**
|
|
12642
|
-
*
|
|
12861
|
+
* Stat the message file, returning its mtimeMs and size. Returns
|
|
12862
|
+
* `-1/-1` when the file does not yet exist (ENOENT) so callers can
|
|
12863
|
+
* still promote a cache snapshot — the next read will re-stat and
|
|
12864
|
+
* fall through to a full re-read. Call from inside the file lock so
|
|
12865
|
+
* the result reflects the post-write on-disk state, not a later
|
|
12866
|
+
* intermediate state from another process.
|
|
12867
|
+
*/
|
|
12868
|
+
async _statMessageFile() {
|
|
12869
|
+
try {
|
|
12870
|
+
const st = await fsp7.stat(this.messagePath);
|
|
12871
|
+
return { mtimeMs: st.mtimeMs, size: st.size };
|
|
12872
|
+
} catch (err) {
|
|
12873
|
+
if (err.code === "ENOENT") {
|
|
12874
|
+
return { mtimeMs: -1, size: -1 };
|
|
12875
|
+
}
|
|
12876
|
+
throw err;
|
|
12877
|
+
}
|
|
12878
|
+
}
|
|
12879
|
+
/**
|
|
12880
|
+
* Read messages, consulting the mtime-bounded in-memory cache first,
|
|
12881
|
+
* serialized so concurrent callers don't both mutate the cache.
|
|
12882
|
+
*
|
|
12643
12883
|
* The mailbox file is shared across processes; every `send`/`ack`/
|
|
12644
12884
|
* `clearAll`/`purgeStale` takes the file lock, so writes are serialized
|
|
12645
12885
|
* and a changed mtimeMs is a definitive freshness signal. When the
|
|
@@ -12650,8 +12890,28 @@ var GlobalMailbox = class {
|
|
|
12650
12890
|
* When the file only grew (new messages appended by another process),
|
|
12651
12891
|
* we read and parse just the tail bytes instead of the entire file.
|
|
12652
12892
|
* This avoids re-parsing the full 10K-message history on every check.
|
|
12893
|
+
*
|
|
12894
|
+
* SERIALIZATION: the actual work is chained onto `_readChain` so two
|
|
12895
|
+
* overlapping calls can't both pass the `st.size > _messageCacheSize`
|
|
12896
|
+
* incremental check against the same stale tracker and each push the
|
|
12897
|
+
* same tail bytes onto the cache (duplicating every appended message).
|
|
12898
|
+
* Readers don't conflict on file content — only on the cache mutation
|
|
12899
|
+
* that follows the read — so we run them one at a time in issue order.
|
|
12900
|
+
* Errors in the chain are swallowed so a failed read never poisons
|
|
12901
|
+
* subsequent reads; each caller observes and re-throws its own error.
|
|
12902
|
+
*/
|
|
12903
|
+
_readMessagesCached() {
|
|
12904
|
+
const run = this._readChain.catch(() => void 0).then(() => this._readMessagesCachedWork());
|
|
12905
|
+
this._readChain = run.catch(() => []);
|
|
12906
|
+
return run;
|
|
12907
|
+
}
|
|
12908
|
+
/**
|
|
12909
|
+
* The un-serialized body of {@link _readMessagesCached}. Reads the
|
|
12910
|
+
* message file with the mtime-bounded cache + incremental-tail
|
|
12911
|
+
* optimization. Must only be called from `_readMessagesCached` so the
|
|
12912
|
+
* cache mutations here don't race a sibling read.
|
|
12653
12913
|
*/
|
|
12654
|
-
async
|
|
12914
|
+
async _readMessagesCachedWork() {
|
|
12655
12915
|
try {
|
|
12656
12916
|
const st = await fsp7.stat(this.messagePath);
|
|
12657
12917
|
if (this._messageCache !== null && this._messageCacheMtime === st.mtimeMs && this._messageCacheSize === st.size) {
|
|
@@ -12680,9 +12940,19 @@ var GlobalMailbox = class {
|
|
|
12680
12940
|
}
|
|
12681
12941
|
}
|
|
12682
12942
|
/**
|
|
12683
|
-
* Replace the in-memory cache
|
|
12684
|
-
*
|
|
12685
|
-
*
|
|
12943
|
+
* Replace the in-memory cache, setting the mtime/size trackers
|
|
12944
|
+
* synchronously in the same step. Callers MUST pass the stat of the
|
|
12945
|
+
* on-disk file state that produced `messages`.
|
|
12946
|
+
*
|
|
12947
|
+
* Why both are required (not fire-and-forget): `_readMessagesCached()`
|
|
12948
|
+
* validates the cache against a fresh stat using these two trackers.
|
|
12949
|
+
* If the cache array is updated but the trackers lag behind (e.g. via
|
|
12950
|
+
* a deferred `stat().then(...)`), a concurrent reader landing in that
|
|
12951
|
+
* window sees a mismatched mtime and falls into the incremental
|
|
12952
|
+
* "file only grew" branch — parsing rewritten bytes as an appended
|
|
12953
|
+
* tail and corrupting the cache with duplicates/garbage. So both
|
|
12954
|
+
* values are captured under the file lock and applied synchronously
|
|
12955
|
+
* here, matching the fix already shipped in DefaultMailbox.
|
|
12686
12956
|
*/
|
|
12687
12957
|
_setMessageCache(messages, mtime, size) {
|
|
12688
12958
|
if (messages.length > MESSAGE_CACHE_MAX_ENTRIES) {
|
|
@@ -12692,25 +12962,27 @@ var GlobalMailbox = class {
|
|
|
12692
12962
|
return;
|
|
12693
12963
|
}
|
|
12694
12964
|
this._messageCache = messages;
|
|
12695
|
-
|
|
12696
|
-
|
|
12697
|
-
this._messageCacheSize = size;
|
|
12698
|
-
} else {
|
|
12699
|
-
void fsp7.stat(this.messagePath).then((st) => {
|
|
12700
|
-
this._messageCacheMtime = st.mtimeMs;
|
|
12701
|
-
this._messageCacheSize = st.size;
|
|
12702
|
-
}).catch(() => {
|
|
12703
|
-
});
|
|
12704
|
-
}
|
|
12965
|
+
this._messageCacheMtime = mtime;
|
|
12966
|
+
this._messageCacheSize = size;
|
|
12705
12967
|
}
|
|
12706
12968
|
/**
|
|
12707
12969
|
* Append a single just-sent message to the in-memory cache without
|
|
12708
12970
|
* re-reading the file. The caller must hold the file lock (or have
|
|
12709
|
-
* just released it after a successful append)
|
|
12710
|
-
*
|
|
12971
|
+
* just released it after a successful append) and MUST pass the
|
|
12972
|
+
* post-append stat so the mtime/size trackers advance in lock-step
|
|
12973
|
+
* with the pushed content.
|
|
12974
|
+
*
|
|
12975
|
+
* Why the stat is required here: without it, `_messageCacheSize`
|
|
12976
|
+
* stays at the pre-append value. A concurrent `_readMessagesCached()`
|
|
12977
|
+
* then sees `st.size > _messageCacheSize` (the file did grow), takes
|
|
12978
|
+
* the incremental branch, and re-reads the just-appended tail —
|
|
12979
|
+
* pushing the same message onto the cache a second time. Setting the
|
|
12980
|
+
* trackers here closes that window for the local-process append path.
|
|
12711
12981
|
*/
|
|
12712
|
-
_pushToCache(msg) {
|
|
12713
|
-
if (this._messageCache === null)
|
|
12982
|
+
_pushToCache(msg, mtime, size) {
|
|
12983
|
+
if (this._messageCache === null) {
|
|
12984
|
+
return;
|
|
12985
|
+
}
|
|
12714
12986
|
if (this._messageCache.length >= MESSAGE_CACHE_MAX_ENTRIES) {
|
|
12715
12987
|
this._messageCache = null;
|
|
12716
12988
|
this._messageCacheMtime = -1;
|
|
@@ -12718,6 +12990,8 @@ var GlobalMailbox = class {
|
|
|
12718
12990
|
return;
|
|
12719
12991
|
}
|
|
12720
12992
|
this._messageCache.push(msg);
|
|
12993
|
+
this._messageCacheMtime = mtime;
|
|
12994
|
+
this._messageCacheSize = size;
|
|
12721
12995
|
}
|
|
12722
12996
|
async _ensureRegistry() {
|
|
12723
12997
|
await fsp7.mkdir(path10.dirname(this.registryPath), { recursive: true });
|