taskplane 0.22.10 → 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 +205 -4
- package/extensions/task-runner.ts +10 -1
- 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/execution.ts +5 -5
- package/extensions/taskplane/extension.ts +118 -4
- package/extensions/taskplane/mailbox.ts +341 -0
- package/extensions/taskplane/merge.ts +11 -2
- package/extensions/taskplane/persistence.ts +166 -5
- package/extensions/taskplane/resume.ts +3 -1
- package/extensions/taskplane/supervisor-primer.md +3 -3
- package/extensions/taskplane/supervisor.ts +2 -2
- package/extensions/taskplane/types.ts +247 -9
- 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 +40 -23
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
|
/**
|
|
@@ -640,11 +811,31 @@ function querySessionStats() {
|
|
|
640
811
|
|
|
641
812
|
// ── Route RPC events ─────────────────────────────────────────────────
|
|
642
813
|
|
|
814
|
+
// Event types worth persisting to the sidecar JSONL.
|
|
815
|
+
// Streaming deltas (content_block_delta, content_block_start/stop, message_start,
|
|
816
|
+
// input_json_delta, etc.) are omitted — they're high-volume, large, and not used
|
|
817
|
+
// by the dashboard or telemetry consumers. A single merge agent can produce 42MB+
|
|
818
|
+
// of sidecar data from streaming deltas alone.
|
|
819
|
+
const SIDECAR_EVENT_TYPES = new Set([
|
|
820
|
+
"agent_start",
|
|
821
|
+
"agent_end",
|
|
822
|
+
"message_end",
|
|
823
|
+
"tool_execution_start",
|
|
824
|
+
"tool_execution_end",
|
|
825
|
+
"tool_execution_update",
|
|
826
|
+
"auto_retry_start",
|
|
827
|
+
"auto_retry_end",
|
|
828
|
+
"auto_compaction_start",
|
|
829
|
+
"response",
|
|
830
|
+
]);
|
|
831
|
+
|
|
643
832
|
function handleEvent(event) {
|
|
644
833
|
if (!event || !event.type) return;
|
|
645
834
|
|
|
646
|
-
// Write
|
|
647
|
-
|
|
835
|
+
// Write only telemetry-relevant events to sidecar (redacted)
|
|
836
|
+
if (SIDECAR_EVENT_TYPES.has(event.type)) {
|
|
837
|
+
writeSidecarEvent(args.sidecarPath, event);
|
|
838
|
+
}
|
|
648
839
|
|
|
649
840
|
// Delegate state mutation to the extracted (testable) accumulator
|
|
650
841
|
applyEvent(state, event);
|
|
@@ -657,6 +848,16 @@ function handleEvent(event) {
|
|
|
657
848
|
// Falls back gracefully: older pi versions ignore the command
|
|
658
849
|
// or return a response without contextUsage — state.contextUsage stays null.
|
|
659
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
|
+
}
|
|
660
861
|
break;
|
|
661
862
|
|
|
662
863
|
case "tool_execution_start":
|
|
@@ -1121,7 +1121,7 @@ function extractVerdict(reviewContent: string): string {
|
|
|
1121
1121
|
// TP-068: Tolerate non-standard verdict formats from models that don't
|
|
1122
1122
|
// follow the exact template (e.g., "Changes requested", "Needs revision").
|
|
1123
1123
|
const lower = reviewContent.toLowerCase();
|
|
1124
|
-
if (/\b(changes?\s+requested|needs?\s+revision|please\s+revise|must\s+revise)\b/.test(lower)) {
|
|
1124
|
+
if (/\b(request\s+changes?|changes?\s+requested|needs?\s+revision|please\s+revise|must\s+revise)\b/.test(lower)) {
|
|
1125
1125
|
return "REVISE";
|
|
1126
1126
|
}
|
|
1127
1127
|
if (/\b(looks?\s+good|no\s+issues?\s+found|approved?)\b/.test(lower)) {
|
|
@@ -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,
|
|
@@ -915,10 +915,8 @@ export function spawnLaneSession(
|
|
|
915
915
|
|
|
916
916
|
// Build env vars
|
|
917
917
|
const envVars = buildLaneEnvVars(lane, task.task.promptPath, repoRoot, workspaceRoot);
|
|
918
|
-
//
|
|
919
|
-
|
|
920
|
-
envVars.ORCH_BATCH_ID = config.orchestrator.batchId;
|
|
921
|
-
}
|
|
918
|
+
// ORCH_BATCH_ID is passed via extraEnvVars from executeWave → executeLane → spawnLaneSession.
|
|
919
|
+
// The task-runner reads it to include batchId in lane-state JSON for dashboard filtering.
|
|
922
920
|
if (extraEnvVars) {
|
|
923
921
|
Object.assign(envVars, extraEnvVars);
|
|
924
922
|
}
|
|
@@ -2371,7 +2369,9 @@ export async function executeWave(
|
|
|
2371
2369
|
const wsRoot = workspaceConfig ? dirname(dirname(workspaceConfig.configPath)) : undefined;
|
|
2372
2370
|
const isWsMode = !!workspaceConfig;
|
|
2373
2371
|
const lanePromises = lanes.map(lane =>
|
|
2374
|
-
executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode
|
|
2372
|
+
executeLane(lane, config, repoRoot, wavePauseSignal, wsRoot, isWsMode, {
|
|
2373
|
+
ORCH_BATCH_ID: batchId,
|
|
2374
|
+
}),
|
|
2375
2375
|
);
|
|
2376
2376
|
|
|
2377
2377
|
// Start monitoring as a sibling async loop
|
|
@@ -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", {
|