taskplane 0.22.11 → 0.22.12
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/bin/rpc-wrapper.mjs +183 -2
- package/extensions/task-runner.ts +9 -0
- package/extensions/taskplane/cleanup.ts +55 -7
- package/extensions/taskplane/diagnostics.ts +1 -1
- package/extensions/taskplane/engine-worker.ts +1 -1
- package/extensions/taskplane/engine.ts +3 -1
- package/extensions/taskplane/extension.ts +118 -4
- package/extensions/taskplane/mailbox.ts +341 -0
- package/extensions/taskplane/merge.ts +11 -2
- package/extensions/taskplane/supervisor-primer.md +3 -3
- package/extensions/taskplane/supervisor.ts +2 -2
- package/extensions/taskplane/types.ts +95 -1
- package/extensions/taskplane/verification.ts +14 -9
- package/package.json +1 -1
- package/templates/agents/supervisor.md +1 -1
- package/templates/agents/task-worker.md +8 -14
package/bin/rpc-wrapper.mjs
CHANGED
|
@@ -28,8 +28,8 @@
|
|
|
28
28
|
*/
|
|
29
29
|
|
|
30
30
|
import { spawn } from "node:child_process";
|
|
31
|
-
import { readFileSync, writeFileSync, appendFileSync, mkdirSync } from "node:fs";
|
|
32
|
-
import { dirname, resolve } from "node:path";
|
|
31
|
+
import { readFileSync, writeFileSync, appendFileSync, mkdirSync, readdirSync, renameSync } from "node:fs";
|
|
32
|
+
import { dirname, resolve, join, basename } from "node:path";
|
|
33
33
|
import { StringDecoder } from "node:string_decoder";
|
|
34
34
|
|
|
35
35
|
// ── CLI Argument Parsing ─────────────────────────────────────────────
|
|
@@ -45,6 +45,7 @@ function parseArgs(argv) {
|
|
|
45
45
|
extensions: [],
|
|
46
46
|
passthrough: [],
|
|
47
47
|
help: false,
|
|
48
|
+
mailboxDir: null,
|
|
48
49
|
};
|
|
49
50
|
|
|
50
51
|
let i = 2; // skip "node" and script path
|
|
@@ -74,6 +75,9 @@ function parseArgs(argv) {
|
|
|
74
75
|
} else if (arg === "--extensions" && i + 1 < argv.length) {
|
|
75
76
|
args.extensions = argv[++i].split(",").map((e) => e.trim()).filter(Boolean);
|
|
76
77
|
i++;
|
|
78
|
+
} else if (arg === "--mailbox-dir" && i + 1 < argv.length) {
|
|
79
|
+
args.mailboxDir = argv[++i];
|
|
80
|
+
i++;
|
|
77
81
|
} else if (arg === "--") {
|
|
78
82
|
args.passthrough = argv.slice(i + 1);
|
|
79
83
|
break;
|
|
@@ -103,6 +107,7 @@ Optional:
|
|
|
103
107
|
--system-prompt-file <path> Path to system prompt file
|
|
104
108
|
--tools <t1,t2,...> Comma-separated tool names
|
|
105
109
|
--extensions <e1,e2,...> Comma-separated extension paths
|
|
110
|
+
--mailbox-dir <path> Mailbox directory for agent steering (TP-089)
|
|
106
111
|
-h, --help Show this help
|
|
107
112
|
`
|
|
108
113
|
);
|
|
@@ -487,6 +492,160 @@ function createSingleWriteGuard(writer) {
|
|
|
487
492
|
};
|
|
488
493
|
}
|
|
489
494
|
|
|
495
|
+
// ── Agent Mailbox Check (TP-089) ─────────────────────────────────────
|
|
496
|
+
|
|
497
|
+
/**
|
|
498
|
+
* Valid mailbox message types (must match MailboxMessageType in types.ts).
|
|
499
|
+
*/
|
|
500
|
+
const MAILBOX_MESSAGE_TYPES = new Set(["steer", "query", "abort", "info", "reply", "escalate"]);
|
|
501
|
+
|
|
502
|
+
/**
|
|
503
|
+
* Check the agent's mailbox inbox for pending messages and inject them
|
|
504
|
+
* into the pi process via the `steer` RPC command.
|
|
505
|
+
*
|
|
506
|
+
* Called on every `message_end` event when `--mailbox-dir` is provided.
|
|
507
|
+
* Messages are validated (batchId, to, shape), sorted deterministically,
|
|
508
|
+
* injected via steer, and moved from inbox/ to ack/.
|
|
509
|
+
*
|
|
510
|
+
* @param {string} mailboxDir - Session mailbox directory (e.g., .pi/mailbox/{batchId}/{session})
|
|
511
|
+
* @param {object} proc - The spawned pi process (must have writable stdin)
|
|
512
|
+
* @returns {{ delivered: number, skipped: number }} Delivery stats
|
|
513
|
+
*/
|
|
514
|
+
function checkMailboxAndSteer(mailboxDir, proc) {
|
|
515
|
+
const stats = { delivered: 0, skipped: 0 };
|
|
516
|
+
|
|
517
|
+
// Derive expected values from path structure:
|
|
518
|
+
// mailboxDir = .pi/mailbox/{batchId}/{sessionName}
|
|
519
|
+
const expectedSessionName = basename(mailboxDir);
|
|
520
|
+
const expectedBatchId = basename(dirname(mailboxDir));
|
|
521
|
+
|
|
522
|
+
const inboxDir = join(mailboxDir, "inbox");
|
|
523
|
+
|
|
524
|
+
// Read inbox — ENOENT is quiet no-op (inbox may not exist yet)
|
|
525
|
+
let entries;
|
|
526
|
+
try {
|
|
527
|
+
entries = readdirSync(inboxDir);
|
|
528
|
+
} catch (err) {
|
|
529
|
+
if (err.code === "ENOENT") return stats;
|
|
530
|
+
process.stderr.write(`\n[STEERING] WARNING: failed to read inbox: ${err.message}\n`);
|
|
531
|
+
return stats;
|
|
532
|
+
}
|
|
533
|
+
|
|
534
|
+
// Filter: only *.msg.json files (excludes .msg.json.tmp temp files)
|
|
535
|
+
const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
|
|
536
|
+
if (msgFiles.length === 0) return stats;
|
|
537
|
+
|
|
538
|
+
// Read and validate all messages
|
|
539
|
+
const validMessages = [];
|
|
540
|
+
|
|
541
|
+
for (const filename of msgFiles) {
|
|
542
|
+
const filePath = join(inboxDir, filename);
|
|
543
|
+
let raw;
|
|
544
|
+
try {
|
|
545
|
+
raw = readFileSync(filePath, "utf-8");
|
|
546
|
+
} catch (err) {
|
|
547
|
+
process.stderr.write(`\n[STEERING] WARNING: failed to read ${filename}: ${err.message}\n`);
|
|
548
|
+
stats.skipped++;
|
|
549
|
+
continue;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
let msg;
|
|
553
|
+
try {
|
|
554
|
+
msg = JSON.parse(raw);
|
|
555
|
+
} catch {
|
|
556
|
+
process.stderr.write(`\n[STEERING] WARNING: malformed JSON in ${filename}, skipping\n`);
|
|
557
|
+
stats.skipped++;
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
// Validate shape
|
|
562
|
+
if (!isValidMailboxMessageShape(msg)) {
|
|
563
|
+
process.stderr.write(`\n[STEERING] WARNING: invalid message shape in ${filename}, skipping\n`);
|
|
564
|
+
stats.skipped++;
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
// Validate batchId (derived from path, not message content)
|
|
569
|
+
if (msg.batchId !== expectedBatchId) {
|
|
570
|
+
process.stderr.write(`\n[STEERING] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`);
|
|
571
|
+
stats.skipped++;
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
|
|
575
|
+
// Validate to (no misdelivery)
|
|
576
|
+
if (msg.to !== expectedSessionName) {
|
|
577
|
+
process.stderr.write(`\n[STEERING] WARNING: misdelivery in ${filename} (to=${msg.to}, expected ${expectedSessionName}), skipping\n`);
|
|
578
|
+
stats.skipped++;
|
|
579
|
+
continue;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
validMessages.push({ filename, message: msg });
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
// Sort: primary by timestamp ascending, tie-break by filename lexical
|
|
586
|
+
validMessages.sort((a, b) => {
|
|
587
|
+
const tsDiff = a.message.timestamp - b.message.timestamp;
|
|
588
|
+
if (tsDiff !== 0) return tsDiff;
|
|
589
|
+
return a.filename.localeCompare(b.filename);
|
|
590
|
+
});
|
|
591
|
+
|
|
592
|
+
// Inject each message via steer RPC command and move to ack/
|
|
593
|
+
for (const { filename, message } of validMessages) {
|
|
594
|
+
try {
|
|
595
|
+
// Precondition: stdin must be available for injection.
|
|
596
|
+
// If stdin is closed/destroyed, keep message in inbox (no false ack).
|
|
597
|
+
if (!proc.stdin || proc.stdin.destroyed) {
|
|
598
|
+
stats.skipped++;
|
|
599
|
+
continue;
|
|
600
|
+
}
|
|
601
|
+
|
|
602
|
+
// Inject via steer RPC command
|
|
603
|
+
proc.stdin.write(JSON.stringify({ type: "steer", message: message.content }) + "\n");
|
|
604
|
+
|
|
605
|
+
// Move to ack/ (delivery proof)
|
|
606
|
+
const ackDir = join(mailboxDir, "ack");
|
|
607
|
+
try { mkdirSync(ackDir, { recursive: true }); } catch { /* exists */ }
|
|
608
|
+
try {
|
|
609
|
+
renameSync(join(inboxDir, filename), join(ackDir, filename));
|
|
610
|
+
} catch (err) {
|
|
611
|
+
// ENOENT race is harmless (another process acked it)
|
|
612
|
+
if (err.code !== "ENOENT") {
|
|
613
|
+
process.stderr.write(`\n[STEERING] WARNING: failed to ack ${filename}: ${err.message}\n`);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
|
|
617
|
+
stats.delivered++;
|
|
618
|
+
process.stderr.write(`\n[STEERING] Delivered message ${message.id}\n`);
|
|
619
|
+
} catch (err) {
|
|
620
|
+
process.stderr.write(`\n[STEERING] WARNING: failed to deliver ${filename}: ${err.message}\n`);
|
|
621
|
+
stats.skipped++;
|
|
622
|
+
}
|
|
623
|
+
}
|
|
624
|
+
|
|
625
|
+
return stats;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
/**
|
|
629
|
+
* Runtime validation for mailbox message shape in rpc-wrapper.
|
|
630
|
+
* Mirrors isValidMailboxMessage() from mailbox.ts but as a standalone
|
|
631
|
+
* function (rpc-wrapper.mjs is a plain .mjs module, not TypeScript).
|
|
632
|
+
*
|
|
633
|
+
* @param {any} obj - Parsed JSON value
|
|
634
|
+
* @returns {boolean} true if valid shape
|
|
635
|
+
*/
|
|
636
|
+
function isValidMailboxMessageShape(obj) {
|
|
637
|
+
if (!obj || typeof obj !== "object") return false;
|
|
638
|
+
return (
|
|
639
|
+
typeof obj.id === "string" &&
|
|
640
|
+
typeof obj.batchId === "string" &&
|
|
641
|
+
typeof obj.from === "string" &&
|
|
642
|
+
typeof obj.to === "string" &&
|
|
643
|
+
typeof obj.timestamp === "number" && Number.isFinite(obj.timestamp) &&
|
|
644
|
+
typeof obj.type === "string" && MAILBOX_MESSAGE_TYPES.has(obj.type) &&
|
|
645
|
+
typeof obj.content === "string"
|
|
646
|
+
);
|
|
647
|
+
}
|
|
648
|
+
|
|
490
649
|
// ── Exports for Testing ──────────────────────────────────────────────
|
|
491
650
|
|
|
492
651
|
// Export pure functions so tests can import them without triggering side effects.
|
|
@@ -503,6 +662,9 @@ export {
|
|
|
503
662
|
applyEvent,
|
|
504
663
|
buildExitSummary,
|
|
505
664
|
createSingleWriteGuard,
|
|
665
|
+
checkMailboxAndSteer,
|
|
666
|
+
isValidMailboxMessageShape,
|
|
667
|
+
MAILBOX_MESSAGE_TYPES,
|
|
506
668
|
};
|
|
507
669
|
|
|
508
670
|
// ── Main ─────────────────────────────────────────────────────────────
|
|
@@ -602,6 +764,15 @@ const proc = spawn("pi", piArgs, {
|
|
|
602
764
|
const promptCmd = { type: "prompt", message: promptContent };
|
|
603
765
|
proc.stdin.write(JSON.stringify(promptCmd) + "\n");
|
|
604
766
|
|
|
767
|
+
// ── Agent Mailbox Steering Setup (TP-089) ────────────────────────────
|
|
768
|
+
// When mailbox-dir is provided, set steering mode to "all" so queued
|
|
769
|
+
// steering messages are delivered together at the next turn boundary.
|
|
770
|
+
// Must be sent after prompt but before any agent processing begins.
|
|
771
|
+
if (args.mailboxDir) {
|
|
772
|
+
proc.stdin.write(JSON.stringify({ type: "set_steering_mode", mode: "all" }) + "\n");
|
|
773
|
+
process.stderr.write(`[rpc-wrapper] mailbox enabled: ${args.mailboxDir}\n`);
|
|
774
|
+
}
|
|
775
|
+
|
|
605
776
|
// ── Stdin Lifecycle ──────────────────────────────────────────────────
|
|
606
777
|
|
|
607
778
|
/**
|
|
@@ -677,6 +848,16 @@ function handleEvent(event) {
|
|
|
677
848
|
// Falls back gracefully: older pi versions ignore the command
|
|
678
849
|
// or return a response without contextUsage — state.contextUsage stays null.
|
|
679
850
|
querySessionStats();
|
|
851
|
+
// Check mailbox for pending steering messages (TP-089).
|
|
852
|
+
// Only active when --mailbox-dir is provided (backward compatible).
|
|
853
|
+
if (args.mailboxDir) {
|
|
854
|
+
try {
|
|
855
|
+
checkMailboxAndSteer(args.mailboxDir, proc);
|
|
856
|
+
} catch (err) {
|
|
857
|
+
// Never crash on mailbox I/O errors
|
|
858
|
+
process.stderr.write(`\n[STEERING] ERROR: ${err.message}\n`);
|
|
859
|
+
}
|
|
860
|
+
}
|
|
680
861
|
break;
|
|
681
862
|
|
|
682
863
|
case "tool_execution_start":
|
|
@@ -1835,6 +1835,15 @@ function spawnAgentTmux(opts: {
|
|
|
1835
1835
|
if (opts.extensions && opts.extensions.length > 0) {
|
|
1836
1836
|
wrapperArgs.push("--extensions", quoteArg(opts.extensions.join(",")));
|
|
1837
1837
|
}
|
|
1838
|
+
// TP-089: Agent mailbox steering — construct mailbox dir when in orchestrator mode.
|
|
1839
|
+
// ORCH_BATCH_ID is set by execution.ts for all lane spawns (including retries).
|
|
1840
|
+
// getSidecarDir() returns the .pi/ directory path (already includes .pi/).
|
|
1841
|
+
const orchBatchId = process.env.ORCH_BATCH_ID;
|
|
1842
|
+
if (orchBatchId) {
|
|
1843
|
+
const mailboxDir = join(getSidecarDir(), "mailbox", orchBatchId, opts.sessionName);
|
|
1844
|
+
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
1845
|
+
wrapperArgs.push("--mailbox-dir", quoteArg(mailboxDir));
|
|
1846
|
+
}
|
|
1838
1847
|
// Passthrough pi args: flags forwarded to the underlying pi --mode rpc process.
|
|
1839
1848
|
// Note: --no-session is NOT passed here — rpc-wrapper.mjs already injects it.
|
|
1840
1849
|
wrapperArgs.push("--");
|
|
@@ -19,8 +19,9 @@
|
|
|
19
19
|
* @module orch/cleanup
|
|
20
20
|
* @since TP-065
|
|
21
21
|
*/
|
|
22
|
-
import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync } from "fs";
|
|
22
|
+
import { existsSync, readdirSync, statSync, unlinkSync, renameSync, mkdirSync, rmSync } from "fs";
|
|
23
23
|
import { join } from "path";
|
|
24
|
+
import { MAILBOX_DIR_NAME } from "./types.ts";
|
|
24
25
|
|
|
25
26
|
// ── Layer 1: Post-Integrate Cleanup ─────────────────────────────────
|
|
26
27
|
|
|
@@ -34,6 +35,8 @@ export interface PostIntegrateCleanupResult {
|
|
|
34
35
|
mergeFilesDeleted: number;
|
|
35
36
|
/** Number of lane prompt files deleted */
|
|
36
37
|
promptFilesDeleted: number;
|
|
38
|
+
/** Number of mailbox batch directories deleted (0 or 1) */
|
|
39
|
+
mailboxDirsDeleted: number;
|
|
37
40
|
/** Warnings from non-fatal cleanup failures */
|
|
38
41
|
warnings: string[];
|
|
39
42
|
}
|
|
@@ -57,6 +60,7 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
|
|
|
57
60
|
telemetryFilesDeleted: 0,
|
|
58
61
|
mergeFilesDeleted: 0,
|
|
59
62
|
promptFilesDeleted: 0,
|
|
63
|
+
mailboxDirsDeleted: 0,
|
|
60
64
|
warnings: [],
|
|
61
65
|
};
|
|
62
66
|
|
|
@@ -119,6 +123,17 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
|
|
|
119
123
|
}
|
|
120
124
|
}
|
|
121
125
|
|
|
126
|
+
// ── Mailbox directory (.pi/mailbox/{batchId}/) ───────────
|
|
127
|
+
const mailboxBatchDir = join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
|
|
128
|
+
if (existsSync(mailboxBatchDir)) {
|
|
129
|
+
try {
|
|
130
|
+
rmSync(mailboxBatchDir, { recursive: true, force: true });
|
|
131
|
+
result.mailboxDirsDeleted = 1;
|
|
132
|
+
} catch (err: unknown) {
|
|
133
|
+
result.warnings.push(`Failed to delete mailbox directory ${mailboxBatchDir}: ${(err as Error).message}`);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
122
137
|
return result;
|
|
123
138
|
}
|
|
124
139
|
|
|
@@ -127,13 +142,14 @@ export function cleanupPostIntegrate(stateRoot: string, batchId: string): PostIn
|
|
|
127
142
|
*/
|
|
128
143
|
export function formatPostIntegrateCleanup(result: PostIntegrateCleanupResult): string {
|
|
129
144
|
const parts: string[] = [];
|
|
130
|
-
const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted;
|
|
145
|
+
const totalDeleted = result.telemetryFilesDeleted + result.mergeFilesDeleted + result.promptFilesDeleted + result.mailboxDirsDeleted;
|
|
131
146
|
|
|
132
147
|
if (totalDeleted > 0) {
|
|
133
148
|
const segments: string[] = [];
|
|
134
149
|
if (result.telemetryFilesDeleted > 0) segments.push(`${result.telemetryFilesDeleted} telemetry`);
|
|
135
150
|
if (result.mergeFilesDeleted > 0) segments.push(`${result.mergeFilesDeleted} merge`);
|
|
136
151
|
if (result.promptFilesDeleted > 0) segments.push(`${result.promptFilesDeleted} prompt`);
|
|
152
|
+
if (result.mailboxDirsDeleted > 0) segments.push(`${result.mailboxDirsDeleted} mailbox`);
|
|
137
153
|
parts.push(`🧹 Cleaned up ${totalDeleted} artifact file(s): ${segments.join(", ")}`);
|
|
138
154
|
}
|
|
139
155
|
|
|
@@ -155,6 +171,8 @@ export const STALE_ARTIFACT_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
|
|
155
171
|
export interface PreflightSweepResult {
|
|
156
172
|
/** Number of stale files deleted */
|
|
157
173
|
staleFilesDeleted: number;
|
|
174
|
+
/** Number of stale mailbox batch directories deleted */
|
|
175
|
+
staleDirsDeleted: number;
|
|
158
176
|
/** Whether the sweep was skipped (e.g., active batch) */
|
|
159
177
|
skipped: boolean;
|
|
160
178
|
/** Reason for skipping (if skipped) */
|
|
@@ -198,6 +216,7 @@ export function sweepStaleArtifacts(
|
|
|
198
216
|
): PreflightSweepResult {
|
|
199
217
|
const result: PreflightSweepResult = {
|
|
200
218
|
staleFilesDeleted: 0,
|
|
219
|
+
staleDirsDeleted: 0,
|
|
201
220
|
skipped: false,
|
|
202
221
|
warnings: [],
|
|
203
222
|
};
|
|
@@ -255,6 +274,29 @@ export function sweepStaleArtifacts(
|
|
|
255
274
|
(name.startsWith("merge-request-") && name.endsWith(".txt")),
|
|
256
275
|
);
|
|
257
276
|
|
|
277
|
+
// Sweep stale mailbox batch directories (.pi/mailbox/{batchId}/)
|
|
278
|
+
const mailboxBase = join(stateRoot, ".pi", MAILBOX_DIR_NAME);
|
|
279
|
+
if (existsSync(mailboxBase)) {
|
|
280
|
+
try {
|
|
281
|
+
const entries = readdirSync(mailboxBase);
|
|
282
|
+
for (const entry of entries) {
|
|
283
|
+
const entryPath = join(mailboxBase, entry);
|
|
284
|
+
try {
|
|
285
|
+
const stat = statSync(entryPath);
|
|
286
|
+
if (!stat.isDirectory()) continue;
|
|
287
|
+
if (stat.mtimeMs < cutoff) {
|
|
288
|
+
rmSync(entryPath, { recursive: true, force: true });
|
|
289
|
+
result.staleDirsDeleted++;
|
|
290
|
+
}
|
|
291
|
+
} catch (err: unknown) {
|
|
292
|
+
result.warnings.push(`Failed to process mailbox dir ${entry}: ${(err as Error).message}`);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
} catch (err: unknown) {
|
|
296
|
+
result.warnings.push(`Failed to read mailbox directory ${mailboxBase}: ${(err as Error).message}`);
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
258
300
|
return result;
|
|
259
301
|
}
|
|
260
302
|
|
|
@@ -265,12 +307,15 @@ export function formatPreflightSweep(result: PreflightSweepResult): string {
|
|
|
265
307
|
if (result.skipped) {
|
|
266
308
|
return `ℹ️ Preflight sweep skipped: ${result.skipReason}`;
|
|
267
309
|
}
|
|
268
|
-
if (result.staleFilesDeleted === 0 && result.warnings.length === 0) {
|
|
310
|
+
if (result.staleFilesDeleted === 0 && result.staleDirsDeleted === 0 && result.warnings.length === 0) {
|
|
269
311
|
return ""; // Nothing to report
|
|
270
312
|
}
|
|
271
313
|
const parts: string[] = [];
|
|
272
|
-
if (result.staleFilesDeleted > 0) {
|
|
273
|
-
|
|
314
|
+
if (result.staleFilesDeleted > 0 || result.staleDirsDeleted > 0) {
|
|
315
|
+
const segments: string[] = [];
|
|
316
|
+
if (result.staleFilesDeleted > 0) segments.push(`${result.staleFilesDeleted} stale artifact(s)`);
|
|
317
|
+
if (result.staleDirsDeleted > 0) segments.push(`${result.staleDirsDeleted} stale mailbox dir(s)`);
|
|
318
|
+
parts.push(`🧹 Preflight cleanup: removed ${segments.join(" and ")} (>7 days old)`);
|
|
274
319
|
}
|
|
275
320
|
for (const warning of result.warnings) {
|
|
276
321
|
parts.push(` ⚠️ ${warning}`);
|
|
@@ -396,8 +441,11 @@ export function formatPreflightCleanup(result: PreflightCleanupResult): string {
|
|
|
396
441
|
const parts: string[] = [];
|
|
397
442
|
|
|
398
443
|
// Layer 2: age-based sweep
|
|
399
|
-
if (!result.sweep.skipped && result.sweep.staleFilesDeleted > 0) {
|
|
400
|
-
|
|
444
|
+
if (!result.sweep.skipped && (result.sweep.staleFilesDeleted > 0 || result.sweep.staleDirsDeleted > 0)) {
|
|
445
|
+
const segments: string[] = [];
|
|
446
|
+
if (result.sweep.staleFilesDeleted > 0) segments.push(`${result.sweep.staleFilesDeleted} stale artifact(s)`);
|
|
447
|
+
if (result.sweep.staleDirsDeleted > 0) segments.push(`${result.sweep.staleDirsDeleted} stale mailbox dir(s)`);
|
|
448
|
+
parts.push(`removed ${segments.join(" and ")} (>7 days old)`);
|
|
401
449
|
}
|
|
402
450
|
|
|
403
451
|
// Layer 3: log rotation
|
|
@@ -139,7 +139,7 @@ export interface ExitSummary {
|
|
|
139
139
|
compactions: number;
|
|
140
140
|
/** Wall-clock duration of the session in seconds (always written, even on crash) */
|
|
141
141
|
durationSec: number;
|
|
142
|
-
/** Last tool call description (e.g., "bash:
|
|
142
|
+
/** Last tool call description (e.g., "bash: node --test tests/*.test.ts"), null if no tools were called */
|
|
143
143
|
lastToolCall: string | null;
|
|
144
144
|
/** Error message if the session ended with an error, null on clean exit */
|
|
145
145
|
error: string | null;
|
|
@@ -85,7 +85,7 @@ export interface SerializedWorkspaceConfig {
|
|
|
85
85
|
* workerData shape passed from the main thread.
|
|
86
86
|
*/
|
|
87
87
|
export interface EngineWorkerData {
|
|
88
|
-
/** Sentinel flag — distinguishes engine worker from
|
|
88
|
+
/** Sentinel flag — distinguishes engine worker from test-runner worker threads */
|
|
89
89
|
engineWorker: true;
|
|
90
90
|
/** "execute" for new batch, "resume" for resume */
|
|
91
91
|
mode: "execute" | "resume";
|
|
@@ -228,6 +228,7 @@ async function attemptWorkerCrashRetry(
|
|
|
228
228
|
retryPauseSignal,
|
|
229
229
|
wsRoot,
|
|
230
230
|
isWsMode,
|
|
231
|
+
{ ORCH_BATCH_ID: batchState.batchId }, // TP-089: ensure mailbox works for retries
|
|
231
232
|
);
|
|
232
233
|
|
|
233
234
|
const retryOutcome = retryResult.tasks[0];
|
|
@@ -484,7 +485,8 @@ async function attemptModelFallbackRetry(
|
|
|
484
485
|
const retryPauseSignal = { paused: false };
|
|
485
486
|
// Pass TASKPLANE_MODEL_FALLBACK=1 as extra env var to signal
|
|
486
487
|
// the task-runner to use the session model instead of configured model.
|
|
487
|
-
|
|
488
|
+
// TP-089: Also include ORCH_BATCH_ID so mailbox steering works for retries.
|
|
489
|
+
const modelFallbackEnv = { TASKPLANE_MODEL_FALLBACK: "1", ORCH_BATCH_ID: batchState.batchId };
|
|
488
490
|
const retryResult = await executeLane(
|
|
489
491
|
retryLane,
|
|
490
492
|
orchConfig,
|
|
@@ -32,6 +32,8 @@ import { runMigrations } from "./migrations.ts";
|
|
|
32
32
|
import { serializeWorkspaceConfig, applySerializedState, deserializeWorkspaceConfig } from "./engine-worker.ts";
|
|
33
33
|
import type { EngineWorkerData, WorkerToMainMessage } from "./engine-worker.ts";
|
|
34
34
|
import { cleanupPostIntegrate, formatPostIntegrateCleanup, sweepStaleArtifacts, formatPreflightSweep, rotateSupervisorLogs, formatLogRotation } from "./cleanup.ts";
|
|
35
|
+
import { writeMailboxMessage } from "./mailbox.ts";
|
|
36
|
+
import type { MailboxMessageType } from "./types.ts";
|
|
35
37
|
import {
|
|
36
38
|
activateSupervisor,
|
|
37
39
|
deactivateSupervisor,
|
|
@@ -2992,12 +2994,18 @@ export default function (pi: ExtensionAPI) {
|
|
|
2992
2994
|
if (batchId) {
|
|
2993
2995
|
try {
|
|
2994
2996
|
const artifactCleanup = cleanupPostIntegrate(repoRoot, batchId);
|
|
2995
|
-
const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted;
|
|
2997
|
+
const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted + artifactCleanup.mailboxDirsDeleted;
|
|
2996
2998
|
if (totalCleaned > 0) {
|
|
2999
|
+
const cleanupParts = [
|
|
3000
|
+
`${artifactCleanup.telemetryFilesDeleted} telemetry file(s)`,
|
|
3001
|
+
`${artifactCleanup.mergeFilesDeleted} merge result(s)`,
|
|
3002
|
+
`${artifactCleanup.promptFilesDeleted} prompt file(s)`,
|
|
3003
|
+
];
|
|
3004
|
+
if (artifactCleanup.mailboxDirsDeleted > 0) {
|
|
3005
|
+
cleanupParts.push(`${artifactCleanup.mailboxDirsDeleted} mailbox dir(s)`);
|
|
3006
|
+
}
|
|
2997
3007
|
outputLines.push(
|
|
2998
|
-
`🧹 Cleaned up ${
|
|
2999
|
-
`${artifactCleanup.mergeFilesDeleted} merge result(s), ` +
|
|
3000
|
-
`${artifactCleanup.promptFilesDeleted} prompt file(s) for batch ${batchId}`,
|
|
3008
|
+
`🧹 Cleaned up ${cleanupParts.join(", ")} for batch ${batchId}`,
|
|
3001
3009
|
);
|
|
3002
3010
|
}
|
|
3003
3011
|
if (artifactCleanup.warnings.length > 0) {
|
|
@@ -3639,6 +3647,112 @@ export default function (pi: ExtensionAPI) {
|
|
|
3639
3647
|
},
|
|
3640
3648
|
});
|
|
3641
3649
|
|
|
3650
|
+
// ── TP-089: Agent Mailbox Steering Tool ──────────────────────────
|
|
3651
|
+
|
|
3652
|
+
pi.registerTool({
|
|
3653
|
+
name: "send_agent_message",
|
|
3654
|
+
label: "Send Agent Message",
|
|
3655
|
+
description:
|
|
3656
|
+
"Send a steering message to a running agent (worker, reviewer, or merger). " +
|
|
3657
|
+
"The message is delivered into the agent's LLM context at the next turn boundary.",
|
|
3658
|
+
promptSnippet: "send_agent_message(to, content, type?) — send steering message to a running agent",
|
|
3659
|
+
promptGuidelines: [
|
|
3660
|
+
"Call send_agent_message to course-correct a running agent (worker, reviewer, or merger).",
|
|
3661
|
+
"The 'to' parameter must be a valid agent session name from the current batch.",
|
|
3662
|
+
"Use orch_status() to see active session names.",
|
|
3663
|
+
"Default type is 'steer' (course correction). Other types: 'query', 'abort', 'info'.",
|
|
3664
|
+
"Messages are limited to 4KB. For larger context, write to a file and reference by path.",
|
|
3665
|
+
],
|
|
3666
|
+
parameters: Type.Object({
|
|
3667
|
+
to: Type.String({
|
|
3668
|
+
description: "Target agent session name (e.g., 'orch-henrylach-lane-1-worker')",
|
|
3669
|
+
}),
|
|
3670
|
+
content: Type.String({
|
|
3671
|
+
description: "Message content (max 4KB). Concise directive for the agent.",
|
|
3672
|
+
}),
|
|
3673
|
+
type: Type.Optional(Type.Union(
|
|
3674
|
+
[Type.Literal("steer"), Type.Literal("query"), Type.Literal("abort"), Type.Literal("info")],
|
|
3675
|
+
{ description: 'Message type (default: "steer")' },
|
|
3676
|
+
)),
|
|
3677
|
+
}),
|
|
3678
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
3679
|
+
try {
|
|
3680
|
+
const result = doSendAgentMessage(params.to, params.content, params.type ?? "steer", ctx);
|
|
3681
|
+
return { content: [{ type: "text" as const, text: result }], details: undefined };
|
|
3682
|
+
} catch (err) {
|
|
3683
|
+
return {
|
|
3684
|
+
content: [{ type: "text" as const, text: `Error sending message: ${err instanceof Error ? err.message : String(err)}` }],
|
|
3685
|
+
details: undefined,
|
|
3686
|
+
};
|
|
3687
|
+
}
|
|
3688
|
+
},
|
|
3689
|
+
});
|
|
3690
|
+
|
|
3691
|
+
/**
|
|
3692
|
+
* Send a steering message to a running agent via the mailbox system.
|
|
3693
|
+
*
|
|
3694
|
+
* Resolves the target session from batch state, validates it exists,
|
|
3695
|
+
* and writes the message to the agent's inbox.
|
|
3696
|
+
*
|
|
3697
|
+
* @since TP-089
|
|
3698
|
+
*/
|
|
3699
|
+
function doSendAgentMessage(to: string, content: string, messageType: string, ctx: ExtensionContext): string {
|
|
3700
|
+
const stateRoot = execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? ctx.cwd;
|
|
3701
|
+
|
|
3702
|
+
// Validate message type (outbound allowlist: steer, query, abort, info)
|
|
3703
|
+
const validOutboundTypes = new Set(["steer", "query", "abort", "info"]);
|
|
3704
|
+
if (!validOutboundTypes.has(messageType)) {
|
|
3705
|
+
return `❌ Invalid message type "${messageType}". Valid types: steer, query, abort, info.`;
|
|
3706
|
+
}
|
|
3707
|
+
|
|
3708
|
+
// Load batch state
|
|
3709
|
+
let state: PersistedBatchState | null = null;
|
|
3710
|
+
try {
|
|
3711
|
+
state = loadBatchState(stateRoot);
|
|
3712
|
+
} catch (err) {
|
|
3713
|
+
return `❌ Failed to load batch state: ${err instanceof Error ? err.message : String(err)}`;
|
|
3714
|
+
}
|
|
3715
|
+
if (!state) {
|
|
3716
|
+
return "❌ No batch state found. There is no active or recent batch.";
|
|
3717
|
+
}
|
|
3718
|
+
|
|
3719
|
+
// Build the set of valid agent session names from batch state
|
|
3720
|
+
const validSessions = new Set<string>();
|
|
3721
|
+
const orchConfig = execCtx?.orchestratorConfig;
|
|
3722
|
+
const tmuxPrefix = orchConfig?.orchestrator?.tmux_prefix ?? "orch";
|
|
3723
|
+
const opId = orchConfig ? resolveOperatorId(orchConfig) : "op";
|
|
3724
|
+
|
|
3725
|
+
for (const lane of state.lanes) {
|
|
3726
|
+
// Worker and reviewer are derived from lane session name
|
|
3727
|
+
validSessions.add(`${lane.tmuxSessionName}-worker`);
|
|
3728
|
+
validSessions.add(`${lane.tmuxSessionName}-reviewer`);
|
|
3729
|
+
// Merger: {tmuxPrefix}-{opId}-merge-{laneNumber}
|
|
3730
|
+
validSessions.add(`${tmuxPrefix}-${opId}-merge-${lane.laneNumber}`);
|
|
3731
|
+
}
|
|
3732
|
+
|
|
3733
|
+
// Validate target session
|
|
3734
|
+
if (!validSessions.has(to)) {
|
|
3735
|
+
const examples = [...validSessions].slice(0, 5).join(", ");
|
|
3736
|
+
return `❌ Unknown session "${to}" in batch ${state.batchId}.\nValid targets: ${examples}${validSessions.size > 5 ? ` (${validSessions.size} total)` : ""}`;
|
|
3737
|
+
}
|
|
3738
|
+
|
|
3739
|
+
// Write message to inbox
|
|
3740
|
+
try {
|
|
3741
|
+
const msg = writeMailboxMessage(stateRoot, state.batchId, to, {
|
|
3742
|
+
from: "supervisor",
|
|
3743
|
+
type: messageType as MailboxMessageType,
|
|
3744
|
+
content,
|
|
3745
|
+
});
|
|
3746
|
+
return `✅ Message sent to \`${to}\` (batch ${state.batchId})\n` +
|
|
3747
|
+
`- **ID:** ${msg.id}\n` +
|
|
3748
|
+
`- **Type:** ${messageType}\n` +
|
|
3749
|
+
`- **Size:** ${Buffer.byteLength(content, "utf8")} bytes\n` +
|
|
3750
|
+
`Message will be delivered at the agent's next turn boundary.`;
|
|
3751
|
+
} catch (err) {
|
|
3752
|
+
return `❌ Failed to write message: ${err instanceof Error ? err.message : String(err)}`;
|
|
3753
|
+
}
|
|
3754
|
+
}
|
|
3755
|
+
|
|
3642
3756
|
// ── Settings TUI ─────────────────────────────────────────────────
|
|
3643
3757
|
|
|
3644
3758
|
pi.registerCommand("taskplane-settings", {
|
|
@@ -0,0 +1,341 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent Mailbox — file-based cross-agent messaging utilities.
|
|
3
|
+
*
|
|
4
|
+
* Provides the core mailbox operations for the agent-mailbox-steering
|
|
5
|
+
* protocol: write, read, and acknowledge messages in batch-scoped,
|
|
6
|
+
* session-scoped inbox directories.
|
|
7
|
+
*
|
|
8
|
+
* Directory structure:
|
|
9
|
+
* ```
|
|
10
|
+
* .pi/mailbox/{batchId}/
|
|
11
|
+
* ├── {sessionName}/
|
|
12
|
+
* │ ├── inbox/ ← pending messages
|
|
13
|
+
* │ └── ack/ ← processed messages (moved from inbox)
|
|
14
|
+
* └── _broadcast/
|
|
15
|
+
* └── inbox/ ← messages to all agents
|
|
16
|
+
* ```
|
|
17
|
+
*
|
|
18
|
+
* All file operations are synchronous (matching rpc-wrapper pattern).
|
|
19
|
+
* Write operations are atomic (temp file + rename in same directory).
|
|
20
|
+
* Read/ack operations are best-effort (log warnings, don't crash).
|
|
21
|
+
*
|
|
22
|
+
* @module orch/mailbox
|
|
23
|
+
* @since TP-089
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { join, dirname } from "path";
|
|
27
|
+
import { existsSync, mkdirSync, writeFileSync, readFileSync, readdirSync, renameSync, unlinkSync } from "fs";
|
|
28
|
+
import { randomBytes } from "crypto";
|
|
29
|
+
import type { MailboxMessage, MailboxMessageType, WriteMailboxMessageOpts } from "./types.ts";
|
|
30
|
+
import { MAILBOX_DIR_NAME, MAILBOX_MAX_CONTENT_BYTES, MAILBOX_MESSAGE_TYPES } from "./types.ts";
|
|
31
|
+
|
|
32
|
+
// ── Path Helpers ─────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Root directory for all mailboxes in a batch.
|
|
36
|
+
*
|
|
37
|
+
* @param stateRoot - Root directory containing .pi/ (workspace root or repo root)
|
|
38
|
+
* @param batchId - Batch ID for scoping
|
|
39
|
+
* @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/`
|
|
40
|
+
*
|
|
41
|
+
* @since TP-089
|
|
42
|
+
*/
|
|
43
|
+
export function mailboxRoot(stateRoot: string, batchId: string): string {
|
|
44
|
+
return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Inbox directory for a specific agent session.
|
|
49
|
+
*
|
|
50
|
+
* @param stateRoot - Root directory containing .pi/
|
|
51
|
+
* @param batchId - Batch ID
|
|
52
|
+
* @param sessionName - tmux session name (unique per batch)
|
|
53
|
+
* @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/inbox/`
|
|
54
|
+
*
|
|
55
|
+
* @since TP-089
|
|
56
|
+
*/
|
|
57
|
+
export function sessionInboxDir(stateRoot: string, batchId: string, sessionName: string): string {
|
|
58
|
+
return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "inbox");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Ack directory for a specific agent session.
|
|
63
|
+
*
|
|
64
|
+
* @param stateRoot - Root directory containing .pi/
|
|
65
|
+
* @param batchId - Batch ID
|
|
66
|
+
* @param sessionName - tmux session name
|
|
67
|
+
* @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/{sessionName}/ack/`
|
|
68
|
+
*
|
|
69
|
+
* @since TP-089
|
|
70
|
+
*/
|
|
71
|
+
export function sessionAckDir(stateRoot: string, batchId: string, sessionName: string): string {
|
|
72
|
+
return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, sessionName, "ack");
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Broadcast inbox directory (messages to all agents).
|
|
77
|
+
*
|
|
78
|
+
* @param stateRoot - Root directory containing .pi/
|
|
79
|
+
* @param batchId - Batch ID
|
|
80
|
+
* @returns Absolute path: `{stateRoot}/.pi/mailbox/{batchId}/_broadcast/inbox/`
|
|
81
|
+
*
|
|
82
|
+
* @since TP-089
|
|
83
|
+
*/
|
|
84
|
+
export function broadcastInboxDir(stateRoot: string, batchId: string): string {
|
|
85
|
+
return join(stateRoot, ".pi", MAILBOX_DIR_NAME, batchId, "_broadcast", "inbox");
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
// ── Write ────────────────────────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Write a message to a target agent's inbox.
|
|
93
|
+
*
|
|
94
|
+
* Generates a unique message ID and writes the message atomically
|
|
95
|
+
* (temp file + rename in the same directory). The temp file uses a
|
|
96
|
+
* `.msg.json.tmp` extension that is excluded by the inbox reader's
|
|
97
|
+
* `*.msg.json` filter.
|
|
98
|
+
*
|
|
99
|
+
* @param stateRoot - Root directory containing .pi/
|
|
100
|
+
* @param batchId - Current batch ID
|
|
101
|
+
* @param to - Target session name or `"_broadcast"`
|
|
102
|
+
* @param opts - Message content and metadata from the caller
|
|
103
|
+
* @returns The written MailboxMessage (including generated fields)
|
|
104
|
+
* @throws If content exceeds 4KB UTF-8 bytes or file I/O fails
|
|
105
|
+
*
|
|
106
|
+
* @since TP-089
|
|
107
|
+
*/
|
|
108
|
+
export function writeMailboxMessage(
|
|
109
|
+
stateRoot: string,
|
|
110
|
+
batchId: string,
|
|
111
|
+
to: string,
|
|
112
|
+
opts: WriteMailboxMessageOpts,
|
|
113
|
+
): MailboxMessage {
|
|
114
|
+
// Validate content size (UTF-8 bytes, not string length)
|
|
115
|
+
const contentBytes = Buffer.byteLength(opts.content, "utf8");
|
|
116
|
+
if (contentBytes > MAILBOX_MAX_CONTENT_BYTES) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`Mailbox message content exceeds ${MAILBOX_MAX_CONTENT_BYTES} byte limit ` +
|
|
119
|
+
`(${contentBytes} bytes). Steering messages should be concise directives. ` +
|
|
120
|
+
`Write larger context to a file and reference it by path.`,
|
|
121
|
+
);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Generate unique message ID
|
|
125
|
+
const timestamp = Date.now();
|
|
126
|
+
const nonce = randomBytes(3).toString("hex").slice(0, 5);
|
|
127
|
+
const id = `${timestamp}-${nonce}`;
|
|
128
|
+
|
|
129
|
+
// Build the full message
|
|
130
|
+
const message: MailboxMessage = {
|
|
131
|
+
id,
|
|
132
|
+
batchId,
|
|
133
|
+
from: opts.from,
|
|
134
|
+
to,
|
|
135
|
+
timestamp,
|
|
136
|
+
type: opts.type,
|
|
137
|
+
content: opts.content,
|
|
138
|
+
expectsReply: opts.expectsReply ?? false,
|
|
139
|
+
replyTo: opts.replyTo ?? null,
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
// Determine inbox directory
|
|
143
|
+
const inboxDir = to === "_broadcast"
|
|
144
|
+
? broadcastInboxDir(stateRoot, batchId)
|
|
145
|
+
: sessionInboxDir(stateRoot, batchId, to);
|
|
146
|
+
|
|
147
|
+
// Ensure inbox directory exists
|
|
148
|
+
mkdirSync(inboxDir, { recursive: true });
|
|
149
|
+
|
|
150
|
+
// Atomic write: temp file (.msg.json.tmp) then rename to final (.msg.json)
|
|
151
|
+
const finalFilename = `${id}.msg.json`;
|
|
152
|
+
const tempFilename = `${id}.msg.json.tmp`;
|
|
153
|
+
const tempPath = join(inboxDir, tempFilename);
|
|
154
|
+
const finalPath = join(inboxDir, finalFilename);
|
|
155
|
+
|
|
156
|
+
try {
|
|
157
|
+
writeFileSync(tempPath, JSON.stringify(message, null, 2) + "\n", "utf-8");
|
|
158
|
+
renameSync(tempPath, finalPath);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
// Attempt cleanup of temp file on failure
|
|
161
|
+
try {
|
|
162
|
+
if (existsSync(tempPath)) unlinkSync(tempPath);
|
|
163
|
+
} catch {
|
|
164
|
+
// Best effort cleanup
|
|
165
|
+
}
|
|
166
|
+
throw new Error(
|
|
167
|
+
`Failed to write mailbox message to ${finalPath}: ${err instanceof Error ? err.message : String(err)}`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return message;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
// ── Read ─────────────────────────────────────────────────────────────
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Read pending messages from an inbox directory.
|
|
179
|
+
*
|
|
180
|
+
* Returns messages sorted by timestamp (ascending), with filename
|
|
181
|
+
* lexical order as tie-breaker. Only reads files matching the
|
|
182
|
+
* `*.msg.json` pattern (excludes `.msg.json.tmp` temp files).
|
|
183
|
+
*
|
|
184
|
+
* Messages with invalid shape or mismatched batchId are logged as
|
|
185
|
+
* warnings and left in the inbox (no throw/crash).
|
|
186
|
+
*
|
|
187
|
+
* @param inboxDir - Absolute path to the inbox directory
|
|
188
|
+
* @param expectedBatchId - Expected batch ID for validation
|
|
189
|
+
* @returns Sorted array of `{ filename, message }` entries
|
|
190
|
+
*
|
|
191
|
+
* @since TP-089
|
|
192
|
+
*/
|
|
193
|
+
export function readInbox(
|
|
194
|
+
inboxDir: string,
|
|
195
|
+
expectedBatchId: string,
|
|
196
|
+
): Array<{ filename: string; message: MailboxMessage }> {
|
|
197
|
+
// Return empty if directory doesn't exist
|
|
198
|
+
if (!existsSync(inboxDir)) return [];
|
|
199
|
+
|
|
200
|
+
let entries: string[];
|
|
201
|
+
try {
|
|
202
|
+
entries = readdirSync(inboxDir);
|
|
203
|
+
} catch (err) {
|
|
204
|
+
process.stderr.write(
|
|
205
|
+
`[mailbox] WARNING: failed to read inbox ${inboxDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
206
|
+
);
|
|
207
|
+
return [];
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
// Filter: only *.msg.json files (excludes .msg.json.tmp, .tmp, etc.)
|
|
211
|
+
const msgFiles = entries.filter(f => f.endsWith(".msg.json") && !f.endsWith(".msg.json.tmp"));
|
|
212
|
+
|
|
213
|
+
const results: Array<{ filename: string; message: MailboxMessage }> = [];
|
|
214
|
+
|
|
215
|
+
for (const filename of msgFiles) {
|
|
216
|
+
const filePath = join(inboxDir, filename);
|
|
217
|
+
let raw: string;
|
|
218
|
+
try {
|
|
219
|
+
raw = readFileSync(filePath, "utf-8");
|
|
220
|
+
} catch (err) {
|
|
221
|
+
process.stderr.write(
|
|
222
|
+
`[mailbox] WARNING: failed to read ${filePath}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
223
|
+
);
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let parsed: unknown;
|
|
228
|
+
try {
|
|
229
|
+
parsed = JSON.parse(raw);
|
|
230
|
+
} catch {
|
|
231
|
+
process.stderr.write(
|
|
232
|
+
`[mailbox] WARNING: malformed JSON in ${filename}, skipping\n`,
|
|
233
|
+
);
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// Validate shape
|
|
238
|
+
if (!isValidMailboxMessage(parsed)) {
|
|
239
|
+
process.stderr.write(
|
|
240
|
+
`[mailbox] WARNING: invalid message shape in ${filename}, skipping\n`,
|
|
241
|
+
);
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
const msg = parsed as MailboxMessage;
|
|
246
|
+
|
|
247
|
+
// Validate batchId
|
|
248
|
+
if (msg.batchId !== expectedBatchId) {
|
|
249
|
+
process.stderr.write(
|
|
250
|
+
`[mailbox] WARNING: batchId mismatch in ${filename} (expected ${expectedBatchId}, got ${msg.batchId}), skipping\n`,
|
|
251
|
+
);
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
results.push({ filename, message: msg });
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
// Sort: primary by timestamp (ascending), tie-break by filename lexical
|
|
259
|
+
results.sort((a, b) => {
|
|
260
|
+
const tsDiff = a.message.timestamp - b.message.timestamp;
|
|
261
|
+
if (tsDiff !== 0) return tsDiff;
|
|
262
|
+
return a.filename.localeCompare(b.filename);
|
|
263
|
+
});
|
|
264
|
+
|
|
265
|
+
return results;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
// ── Acknowledge ──────────────────────────────────────────────────────
|
|
270
|
+
|
|
271
|
+
/**
|
|
272
|
+
* Move a message from inbox to ack directory.
|
|
273
|
+
*
|
|
274
|
+
* Atomic rename. If the file is already gone (another process acked it),
|
|
275
|
+
* returns false. The ack directory is derived structurally from the inbox
|
|
276
|
+
* directory: `dirname(inboxDir)/ack/`.
|
|
277
|
+
*
|
|
278
|
+
* @param inboxDir - Absolute path to the inbox directory
|
|
279
|
+
* @param filename - Message filename (e.g., `1774744971303-a7f2c.msg.json`)
|
|
280
|
+
* @returns true if acked successfully, false if already acked (ENOENT race)
|
|
281
|
+
*
|
|
282
|
+
* @since TP-089
|
|
283
|
+
*/
|
|
284
|
+
export function ackMessage(inboxDir: string, filename: string): boolean {
|
|
285
|
+
const ackDir = join(dirname(inboxDir), "ack");
|
|
286
|
+
|
|
287
|
+
try {
|
|
288
|
+
mkdirSync(ackDir, { recursive: true });
|
|
289
|
+
} catch (err) {
|
|
290
|
+
process.stderr.write(
|
|
291
|
+
`[mailbox] WARNING: failed to create ack dir ${ackDir}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
292
|
+
);
|
|
293
|
+
return false;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
const srcPath = join(inboxDir, filename);
|
|
297
|
+
const dstPath = join(ackDir, filename);
|
|
298
|
+
|
|
299
|
+
try {
|
|
300
|
+
renameSync(srcPath, dstPath);
|
|
301
|
+
return true;
|
|
302
|
+
} catch (err: unknown) {
|
|
303
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
304
|
+
if (code === "ENOENT") {
|
|
305
|
+
// Another process already acked this message — race is harmless
|
|
306
|
+
return false;
|
|
307
|
+
}
|
|
308
|
+
process.stderr.write(
|
|
309
|
+
`[mailbox] WARNING: failed to ack ${filename}: ${err instanceof Error ? err.message : String(err)}\n`,
|
|
310
|
+
);
|
|
311
|
+
return false;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
// ── Validation ───────────────────────────────────────────────────────
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Runtime validation for mailbox message shape.
|
|
320
|
+
*
|
|
321
|
+
* Checks that all required fields are present and correctly typed.
|
|
322
|
+
* Does not validate batchId match (caller's responsibility).
|
|
323
|
+
*
|
|
324
|
+
* @param obj - Parsed JSON value to validate
|
|
325
|
+
* @returns true if obj is a valid MailboxMessage shape
|
|
326
|
+
*
|
|
327
|
+
* @since TP-089
|
|
328
|
+
*/
|
|
329
|
+
export function isValidMailboxMessage(obj: unknown): obj is MailboxMessage {
|
|
330
|
+
if (!obj || typeof obj !== "object") return false;
|
|
331
|
+
const m = obj as Record<string, unknown>;
|
|
332
|
+
return (
|
|
333
|
+
typeof m.id === "string" &&
|
|
334
|
+
typeof m.batchId === "string" &&
|
|
335
|
+
typeof m.from === "string" &&
|
|
336
|
+
typeof m.to === "string" &&
|
|
337
|
+
typeof m.timestamp === "number" && Number.isFinite(m.timestamp) &&
|
|
338
|
+
typeof m.type === "string" && MAILBOX_MESSAGE_TYPES.has(m.type) &&
|
|
339
|
+
typeof m.content === "string"
|
|
340
|
+
);
|
|
341
|
+
}
|
|
@@ -585,6 +585,7 @@ export async function spawnMergeAgent(
|
|
|
585
585
|
config: OrchestratorConfig,
|
|
586
586
|
stateRoot?: string,
|
|
587
587
|
agentRoot?: string,
|
|
588
|
+
batchId?: string,
|
|
588
589
|
): Promise<void> {
|
|
589
590
|
execLog("merge", sessionName, "preparing to spawn merge agent", {
|
|
590
591
|
mergeWorkDir,
|
|
@@ -658,6 +659,14 @@ export async function spawnMergeAgent(
|
|
|
658
659
|
wrapperParts.push("--tools", shellQuote(config.merge.tools));
|
|
659
660
|
}
|
|
660
661
|
|
|
662
|
+
// TP-089: Agent mailbox steering — pass --mailbox-dir when batchId is available.
|
|
663
|
+
if (batchId) {
|
|
664
|
+
const mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
|
|
665
|
+
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
666
|
+
wrapperParts.push("--mailbox-dir", shellQuote(mailboxDir));
|
|
667
|
+
execLog("merge", sessionName, "mailbox enabled", { mailboxDir });
|
|
668
|
+
}
|
|
669
|
+
|
|
661
670
|
const piCommand = wrapperParts.filter(Boolean).join(" ");
|
|
662
671
|
|
|
663
672
|
const tmuxMergeDir = toTmuxPath(mergeWorkDir);
|
|
@@ -1511,12 +1520,12 @@ export async function mergeWave(
|
|
|
1511
1520
|
}
|
|
1512
1521
|
|
|
1513
1522
|
// Re-spawn merge agent for the retry
|
|
1514
|
-
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot);
|
|
1523
|
+
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1515
1524
|
// TP-056: Re-register with health monitor after respawn
|
|
1516
1525
|
if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1517
1526
|
} else {
|
|
1518
1527
|
// First attempt: spawn merge agent
|
|
1519
|
-
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot);
|
|
1528
|
+
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1520
1529
|
// TP-056: Register session with health monitor
|
|
1521
1530
|
if (healthMonitor) healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1522
1531
|
}
|
|
@@ -332,7 +332,7 @@ git log --oneline orch/{branch}..task/{lane-branch} # empty = already merged
|
|
|
332
332
|
4. After merge, run tests to verify:
|
|
333
333
|
```bash
|
|
334
334
|
git worktree add /tmp/verify orch/{orchBranch} --detach
|
|
335
|
-
cd /tmp/verify && cd extensions &&
|
|
335
|
+
cd /tmp/verify && cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts
|
|
336
336
|
```
|
|
337
337
|
5. Update batch state and advance.
|
|
338
338
|
|
|
@@ -545,7 +545,7 @@ git worktree remove .worktrees/{opId}-{batchId}/merge --force
|
|
|
545
545
|
### Verify orch branch integrity
|
|
546
546
|
```bash
|
|
547
547
|
git worktree add /tmp/tp-verify orch/{orchBranch} --detach
|
|
548
|
-
cd /tmp/tp-verify/extensions &&
|
|
548
|
+
cd /tmp/tp-verify/extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts
|
|
549
549
|
# Clean up: cd {repoRoot} && git worktree remove /tmp/tp-verify --force
|
|
550
550
|
```
|
|
551
551
|
|
|
@@ -1333,7 +1333,7 @@ before writing** — if files already exist (partial setup), read and merge.
|
|
|
1333
1333
|
**Customization notes:**
|
|
1334
1334
|
- `project.name`: Use the actual project name (from package.json, README, etc.)
|
|
1335
1335
|
- `paths.tasks` and `taskAreas`: Match what was agreed in the task area discussion
|
|
1336
|
-
- `testing.commands`: Use the detected test command as a named object (e.g., `{"test": "cd extensions &&
|
|
1336
|
+
- `testing.commands`: Use the detected test command as a named object (e.g., `{"test": "cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts"}`)
|
|
1337
1337
|
- `orchestrator.spawnMode`: Use `"tmux"` if tmux is available, `"subprocess"` otherwise
|
|
1338
1338
|
- `orchestrator.maxLanes`: Start with 2 for first-time users (safe default)
|
|
1339
1339
|
- `merge.verify`: Add the project's test command for post-merge verification
|
|
@@ -103,7 +103,7 @@ export const ACTION_CLASSIFICATION_EXAMPLES: Readonly<Record<RecoveryActionClass
|
|
|
103
103
|
diagnostic: [
|
|
104
104
|
"Reading batch-state.json, STATUS.md, events.jsonl, merge results",
|
|
105
105
|
"Running git status, git log, git diff",
|
|
106
|
-
"Running test suites (
|
|
106
|
+
"Running test suites (node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test ..., etc.)",
|
|
107
107
|
"Listing tmux sessions (tmux list-sessions)",
|
|
108
108
|
"Checking worktree health (git worktree list)",
|
|
109
109
|
"Reading any file for diagnostics",
|
|
@@ -2051,7 +2051,7 @@ Every action you take falls into one of three categories:
|
|
|
2051
2051
|
### Diagnostic (always allowed — no confirmation needed)
|
|
2052
2052
|
- Reading batch-state.json, STATUS.md, events.jsonl, merge results
|
|
2053
2053
|
- Running \`git status\`, \`git log\`, \`git diff\`
|
|
2054
|
-
- Running test suites (\`
|
|
2054
|
+
- Running test suites (\`node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test ...\`, etc.)
|
|
2055
2055
|
- Listing tmux sessions (\`tmux list-sessions\`)
|
|
2056
2056
|
- Checking worktree health (\`git worktree list\`)
|
|
2057
2057
|
- Reading any file for diagnostics
|
|
@@ -225,7 +225,7 @@ export interface TaskArea {
|
|
|
225
225
|
export interface TaskRunnerConfig {
|
|
226
226
|
task_areas: Record<string, TaskArea>;
|
|
227
227
|
reference_docs: Record<string, string>;
|
|
228
|
-
/** Named testing/verification commands (e.g., { test: "
|
|
228
|
+
/** Named testing/verification commands (e.g., { test: "node --test tests/*.test.ts" }). Used for baseline fingerprinting (TP-032). */
|
|
229
229
|
testing_commands?: Record<string, string>;
|
|
230
230
|
/**
|
|
231
231
|
* Model fallback behavior when a configured model becomes unavailable mid-batch.
|
|
@@ -3357,3 +3357,97 @@ export function createRepoModeContext(
|
|
|
3357
3357
|
};
|
|
3358
3358
|
}
|
|
3359
3359
|
|
|
3360
|
+
|
|
3361
|
+
// ── Agent Mailbox Types (TP-089) ─────────────────────────────────────
|
|
3362
|
+
|
|
3363
|
+
/**
|
|
3364
|
+
* Mailbox directory name under .pi/.
|
|
3365
|
+
* @since TP-089
|
|
3366
|
+
*/
|
|
3367
|
+
export const MAILBOX_DIR_NAME = "mailbox";
|
|
3368
|
+
|
|
3369
|
+
/**
|
|
3370
|
+
* Maximum content size in UTF-8 bytes.
|
|
3371
|
+
* Steering messages should be concise directives; larger context should be
|
|
3372
|
+
* written to a separate file and referenced by path.
|
|
3373
|
+
* @since TP-089
|
|
3374
|
+
*/
|
|
3375
|
+
export const MAILBOX_MAX_CONTENT_BYTES = 4096;
|
|
3376
|
+
|
|
3377
|
+
/**
|
|
3378
|
+
* Message types for the agent mailbox system.
|
|
3379
|
+
*
|
|
3380
|
+
* | Type | Direction | Purpose |
|
|
3381
|
+
* |------------|---------------------|--------------------------------------------|
|
|
3382
|
+
* | `steer` | supervisor → agent | Course correction. Agent must follow. |
|
|
3383
|
+
* | `query` | supervisor → agent | Request for status/info. Agent replies. |
|
|
3384
|
+
* | `abort` | supervisor → agent | Graceful stop. Agent wraps up and exits. |
|
|
3385
|
+
* | `info` | supervisor → agent | FYI context. No action required. |
|
|
3386
|
+
* | `reply` | agent → supervisor | Response to query or steer acknowledgment. |
|
|
3387
|
+
* | `escalate` | agent → supervisor | Agent-initiated: blocked or needs guidance. |
|
|
3388
|
+
*
|
|
3389
|
+
* @since TP-089
|
|
3390
|
+
*/
|
|
3391
|
+
export type MailboxMessageType = "steer" | "query" | "abort" | "info" | "reply" | "escalate";
|
|
3392
|
+
|
|
3393
|
+
/**
|
|
3394
|
+
* Set of valid mailbox message types for runtime validation.
|
|
3395
|
+
* @since TP-089
|
|
3396
|
+
*/
|
|
3397
|
+
export const MAILBOX_MESSAGE_TYPES: ReadonlySet<string> = new Set<MailboxMessageType>([
|
|
3398
|
+
"steer", "query", "abort", "info", "reply", "escalate",
|
|
3399
|
+
]);
|
|
3400
|
+
|
|
3401
|
+
/**
|
|
3402
|
+
* Message format for the file-based agent mailbox.
|
|
3403
|
+
*
|
|
3404
|
+
* Messages are written as JSON files in batch-scoped, session-scoped
|
|
3405
|
+
* directories. The rpc-wrapper checks the inbox on every `message_end`
|
|
3406
|
+
* event and injects pending messages into the agent's LLM context via
|
|
3407
|
+
* pi's `steer` RPC command.
|
|
3408
|
+
*
|
|
3409
|
+
* @see docs/specifications/taskplane/agent-mailbox-steering.md
|
|
3410
|
+
* @since TP-089
|
|
3411
|
+
*/
|
|
3412
|
+
export interface MailboxMessage {
|
|
3413
|
+
/** Unique message ID: `{timestamp}-{5char-hex-nonce}` */
|
|
3414
|
+
id: string;
|
|
3415
|
+
/** Batch ID — must match current batch for validation */
|
|
3416
|
+
batchId: string;
|
|
3417
|
+
/** Sender identifier: `"supervisor"` or session name */
|
|
3418
|
+
from: string;
|
|
3419
|
+
/** Target session name or `"_broadcast"` */
|
|
3420
|
+
to: string;
|
|
3421
|
+
/** Epoch milliseconds (Date.now()) */
|
|
3422
|
+
timestamp: number;
|
|
3423
|
+
/** Message type */
|
|
3424
|
+
type: MailboxMessageType;
|
|
3425
|
+
/** Message body (max 4KB UTF-8 bytes) */
|
|
3426
|
+
content: string;
|
|
3427
|
+
/** Whether the sender expects a reply (default: false) */
|
|
3428
|
+
expectsReply?: boolean;
|
|
3429
|
+
/** Reference to a previous message ID for threading (default: null) */
|
|
3430
|
+
replyTo?: string | null;
|
|
3431
|
+
}
|
|
3432
|
+
|
|
3433
|
+
/**
|
|
3434
|
+
* Input options for writeMailboxMessage.
|
|
3435
|
+
*
|
|
3436
|
+
* The caller provides these fields; the utility generates `id`, `batchId`,
|
|
3437
|
+
* `to`, and `timestamp` from its own arguments.
|
|
3438
|
+
*
|
|
3439
|
+
* @since TP-089
|
|
3440
|
+
*/
|
|
3441
|
+
export interface WriteMailboxMessageOpts {
|
|
3442
|
+
/** Sender identifier: `"supervisor"` or session name */
|
|
3443
|
+
from: string;
|
|
3444
|
+
/** Message type */
|
|
3445
|
+
type: MailboxMessageType;
|
|
3446
|
+
/** Message body (max 4KB UTF-8 bytes) */
|
|
3447
|
+
content: string;
|
|
3448
|
+
/** Whether the sender expects a reply (default: false) */
|
|
3449
|
+
expectsReply?: boolean;
|
|
3450
|
+
/** Reference to a previous message ID for threading (default: null) */
|
|
3451
|
+
replyTo?: string | null;
|
|
3452
|
+
}
|
|
3453
|
+
|
|
@@ -27,10 +27,15 @@
|
|
|
27
27
|
* 6. Truncate to 512 chars (bound fingerprint size)
|
|
28
28
|
*
|
|
29
29
|
* **Fallback for non-JSON output:**
|
|
30
|
-
* If
|
|
31
|
-
* single fingerprint with kind: "command_error" and the first 512 chars
|
|
30
|
+
* If legacy Vitest JSON parsing fails (truncated, missing, non-JSON), produce
|
|
31
|
+
* a single fingerprint with kind: "command_error" and the first 512 chars
|
|
32
32
|
* of stderr (or stdout) as messageNorm.
|
|
33
33
|
*
|
|
34
|
+
* **Compatibility note:**
|
|
35
|
+
* Taskplane's default tests use Node.js native `node:test`. The Vitest parser
|
|
36
|
+
* in this module is retained only for backward compatibility when projects
|
|
37
|
+
* provide custom `testing.commands` that still emit Vitest JSON.
|
|
38
|
+
*
|
|
34
39
|
* @module orch/verification
|
|
35
40
|
*/
|
|
36
41
|
import { spawnSync } from "child_process";
|
|
@@ -300,20 +305,20 @@ function classifyFailureKind(message: string): TestFingerprint["kind"] {
|
|
|
300
305
|
}
|
|
301
306
|
|
|
302
307
|
/**
|
|
303
|
-
* Parse
|
|
308
|
+
* Parse legacy Vitest JSON reporter output into test fingerprints.
|
|
304
309
|
*
|
|
305
|
-
* Expects
|
|
310
|
+
* Expects stdout to contain a JSON object matching Vitest's JSON reporter format.
|
|
306
311
|
* Only failed tests produce fingerprints (passed tests are irrelevant for baseline diffing).
|
|
307
312
|
*
|
|
308
313
|
* If JSON parsing fails or the structure is unexpected, returns null to signal
|
|
309
314
|
* that the caller should use fallback fingerprinting.
|
|
310
315
|
*
|
|
311
316
|
* @param commandId - The command that produced this output
|
|
312
|
-
* @param stdout - Raw stdout from the
|
|
317
|
+
* @param stdout - Raw stdout from the Vitest command (legacy compatibility path)
|
|
313
318
|
* @returns Array of fingerprints for failed tests, or null if parsing fails
|
|
314
319
|
*/
|
|
315
320
|
export function parseVitestOutput(commandId: string, stdout: string): TestFingerprint[] | null {
|
|
316
|
-
// Try to extract JSON from stdout (
|
|
321
|
+
// Try to extract JSON from stdout (Vitest may prepend/append non-JSON lines)
|
|
317
322
|
let json: VitestJsonResult;
|
|
318
323
|
try {
|
|
319
324
|
// First attempt: parse the whole stdout as JSON
|
|
@@ -363,7 +368,7 @@ export function parseVitestOutput(commandId: string, stdout: string): TestFinger
|
|
|
363
368
|
}
|
|
364
369
|
|
|
365
370
|
// Suite-level failures: testResults[].status === "failed" with no assertion-level details.
|
|
366
|
-
// This covers setup/import/runtime-at-file-load errors where
|
|
371
|
+
// This covers setup/import/runtime-at-file-load errors where Vitest marks the file as
|
|
367
372
|
// failed but produces no assertionResults (or only non-failed ones).
|
|
368
373
|
if (testFile.status === "failed") {
|
|
369
374
|
const hasFailedAssertions = hasAssertions && assertions!.some(a => a.status === "failed");
|
|
@@ -388,7 +393,7 @@ export function parseVitestOutput(commandId: string, stdout: string): TestFinger
|
|
|
388
393
|
* Parse test output into normalized fingerprints.
|
|
389
394
|
*
|
|
390
395
|
* Strategy:
|
|
391
|
-
* 1. Try
|
|
396
|
+
* 1. Try legacy Vitest JSON adapter
|
|
392
397
|
* 2. If parsing fails: produce a fallback command_error fingerprint
|
|
393
398
|
*
|
|
394
399
|
* The adapter pattern is extensible — future parsers for jest, pytest, etc.
|
|
@@ -416,7 +421,7 @@ export function parseTestOutput(commandResult: CommandResult): TestFingerprint[]
|
|
|
416
421
|
return [];
|
|
417
422
|
}
|
|
418
423
|
|
|
419
|
-
// Try
|
|
424
|
+
// Try legacy Vitest JSON adapter
|
|
420
425
|
const vitestFingerprints = parseVitestOutput(commandId, stdout);
|
|
421
426
|
if (vitestFingerprints !== null && vitestFingerprints.length > 0) {
|
|
422
427
|
return vitestFingerprints;
|
package/package.json
CHANGED
|
@@ -68,7 +68,7 @@ Every action you take falls into one of three categories:
|
|
|
68
68
|
### Diagnostic (always allowed — no confirmation needed)
|
|
69
69
|
- Reading batch-state.json, STATUS.md, events.jsonl, merge results
|
|
70
70
|
- Running `git status`, `git log`, `git diff`
|
|
71
|
-
- Running test suites (`
|
|
71
|
+
- Running test suites (`node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test ...`, etc.)
|
|
72
72
|
- Listing tmux sessions (`tmux list-sessions`)
|
|
73
73
|
- Checking worktree health (`git worktree list`)
|
|
74
74
|
- Reading any file for diagnostics
|
|
@@ -275,30 +275,24 @@ Run tests at two different scopes depending on where you are in the task:
|
|
|
275
275
|
|
|
276
276
|
### During implementation steps (targeted tests)
|
|
277
277
|
|
|
278
|
-
After implementing each step, run **targeted tests** for fast feedback
|
|
278
|
+
After implementing each step, run **targeted tests** for fast feedback.
|
|
279
|
+
Use file-targeted runs for the test files that cover your changes:
|
|
279
280
|
|
|
280
281
|
```bash
|
|
281
|
-
cd extensions &&
|
|
282
|
+
cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/some-specific.test.ts
|
|
282
283
|
```
|
|
283
284
|
|
|
284
|
-
-
|
|
285
|
-
|
|
286
|
-
-
|
|
287
|
-
|
|
288
|
-
- Alternatively, run specific test files that cover the code you modified:
|
|
289
|
-
`npx vitest run tests/some-specific.test.ts`
|
|
290
|
-
- **If `--changed` returns no tests:** That's fine — it means your changes don't
|
|
291
|
-
have directly related test files. The full suite in the Testing step will catch
|
|
292
|
-
any indirect regressions.
|
|
293
|
-
- **If targeted tests fail:** Fix the failure before proceeding. Don't accumulate
|
|
294
|
-
failures across steps.
|
|
285
|
+
- Node's native runner does not provide a reliable project-level `--changed`
|
|
286
|
+
equivalent; select targeted files explicitly.
|
|
287
|
+
- If multiple files are relevant, pass multiple `--test` paths.
|
|
288
|
+
- **If targeted tests fail:** fix them before proceeding. Don't accumulate failures.
|
|
295
289
|
|
|
296
290
|
### During the Testing & Verification step (full suite)
|
|
297
291
|
|
|
298
292
|
Run the **full test suite** as a quality gate:
|
|
299
293
|
|
|
300
294
|
```bash
|
|
301
|
-
cd extensions &&
|
|
295
|
+
cd extensions && node --experimental-strip-types --experimental-test-module-mocks --no-warnings --import ./tests/loader.mjs --test tests/*.test.ts
|
|
302
296
|
```
|
|
303
297
|
|
|
304
298
|
- ALL tests must pass — zero failures allowed.
|