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
|
@@ -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
|
}
|
|
@@ -9,7 +9,7 @@ import { join, dirname, basename } from "path";
|
|
|
9
9
|
import { execLog } from "./execution.ts";
|
|
10
10
|
import { BATCH_STATE_SCHEMA_VERSION, StateFileError, batchStatePath, BATCH_HISTORY_MAX_ENTRIES, defaultResilienceState, defaultBatchDiagnostics } from "./types.ts";
|
|
11
11
|
import type { BatchHistorySummary } from "./types.ts";
|
|
12
|
-
import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts";
|
|
12
|
+
import type { AllocatedLane, DiscoveryResult, EngineEvent, EscalationContext, LaneTaskOutcome, LaneTaskStatus, MonitorState, OrchBatchPhase, OrchBatchRuntimeState, PersistedBatchState, PersistedLaneRecord, PersistedMergeResult, PersistedSegmentRecord, PersistedTaskRecord, TaskMonitorSnapshot, Tier0RecoveryPattern, WorkspaceMode } from "./types.ts";
|
|
13
13
|
import { sleepSync } from "./worktree.ts";
|
|
14
14
|
import type { PreserveFailedLaneProgressResult } from "./worktree.ts";
|
|
15
15
|
|
|
@@ -379,6 +379,29 @@ export function upconvertV2toV3(obj: Record<string, unknown>): void {
|
|
|
379
379
|
if (!obj.diagnostics) obj.diagnostics = defaultBatchDiagnostics();
|
|
380
380
|
}
|
|
381
381
|
|
|
382
|
+
/**
|
|
383
|
+
* Upconvert a v3 state object to v4 by adding the `segments` array.
|
|
384
|
+
*
|
|
385
|
+
* Added fields:
|
|
386
|
+
* - `segments`: empty array (no segment records exist in pre-v4 state)
|
|
387
|
+
*
|
|
388
|
+
* Task-level segment fields (`packetRepoId`, `packetTaskPath`,
|
|
389
|
+
* `segmentIds`, `activeSegmentId`) are optional and default to
|
|
390
|
+
* `undefined` (omitted from JSON). They are NOT backfilled here
|
|
391
|
+
* because their values depend on runtime discovery, not on
|
|
392
|
+
* migration defaults.
|
|
393
|
+
*
|
|
394
|
+
* This function is idempotent: calling it on an already-v4 object is a no-op.
|
|
395
|
+
*
|
|
396
|
+
* @param obj - Parsed state object (mutated in-place)
|
|
397
|
+
*/
|
|
398
|
+
export function upconvertV3toV4(obj: Record<string, unknown>): void {
|
|
399
|
+
if ((obj.schemaVersion as number) >= 4) return;
|
|
400
|
+
obj.schemaVersion = 4;
|
|
401
|
+
// Backfill v4 segments with empty array only during genuine v3→v4 migration.
|
|
402
|
+
if (!obj.segments) obj.segments = [];
|
|
403
|
+
}
|
|
404
|
+
|
|
382
405
|
/**
|
|
383
406
|
* Validate a parsed JSON object as a PersistedBatchState.
|
|
384
407
|
*
|
|
@@ -410,9 +433,9 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
|
|
|
410
433
|
`Missing or invalid "schemaVersion" field (expected number, got ${typeof obj.schemaVersion})`,
|
|
411
434
|
);
|
|
412
435
|
}
|
|
413
|
-
// Accept v1 (auto-upconvert to v2→v3), v2 (upconvert to v3), and
|
|
436
|
+
// Accept v1 (auto-upconvert to v2→v3→v4), v2 (upconvert to v3→v4), v3 (upconvert to v4), and v4 (current).
|
|
414
437
|
// Reject anything else — including future versions from newer runtimes.
|
|
415
|
-
const ACCEPTED_VERSIONS = [1, 2, BATCH_STATE_SCHEMA_VERSION];
|
|
438
|
+
const ACCEPTED_VERSIONS = [1, 2, 3, BATCH_STATE_SCHEMA_VERSION];
|
|
416
439
|
if (!ACCEPTED_VERSIONS.includes(obj.schemaVersion as number)) {
|
|
417
440
|
throw new StateFileError(
|
|
418
441
|
"STATE_SCHEMA_INVALID",
|
|
@@ -754,12 +777,13 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
|
|
|
754
777
|
}
|
|
755
778
|
}
|
|
756
779
|
|
|
757
|
-
// ── v1→v2→v3 upconversion
|
|
780
|
+
// ── v1→v2→v3→v4 upconversion ─────────────────────────────────
|
|
758
781
|
// Apply defaults for fields that may be absent in older state files.
|
|
759
782
|
// The on-disk file is NOT rewritten; upconversion is in-memory only.
|
|
760
|
-
// Chain: v1→v2 then v2→v3 (each is idempotent / no-op if already at target).
|
|
783
|
+
// Chain: v1→v2 then v2→v3 then v3→v4 (each is idempotent / no-op if already at target).
|
|
761
784
|
upconvertV1toV2(obj);
|
|
762
785
|
upconvertV2toV3(obj);
|
|
786
|
+
upconvertV3toV4(obj);
|
|
763
787
|
|
|
764
788
|
// ── Validate v3 resilience section ───────────────────────────
|
|
765
789
|
// After upconversion, resilience must be a valid object with correct types.
|
|
@@ -928,6 +952,127 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
|
|
|
928
952
|
);
|
|
929
953
|
}
|
|
930
954
|
}
|
|
955
|
+
// v4 optional fields: packetRepoId, packetTaskPath (string | undefined)
|
|
956
|
+
if (t.packetRepoId !== undefined && typeof t.packetRepoId !== "string") {
|
|
957
|
+
throw new StateFileError(
|
|
958
|
+
"STATE_SCHEMA_INVALID",
|
|
959
|
+
`tasks[${i}].packetRepoId is not a string (got ${typeof t.packetRepoId})`,
|
|
960
|
+
);
|
|
961
|
+
}
|
|
962
|
+
if (t.packetTaskPath !== undefined && typeof t.packetTaskPath !== "string") {
|
|
963
|
+
throw new StateFileError(
|
|
964
|
+
"STATE_SCHEMA_INVALID",
|
|
965
|
+
`tasks[${i}].packetTaskPath is not a string (got ${typeof t.packetTaskPath})`,
|
|
966
|
+
);
|
|
967
|
+
}
|
|
968
|
+
// v4 optional field: segmentIds (string[] | undefined)
|
|
969
|
+
if (t.segmentIds !== undefined) {
|
|
970
|
+
if (!Array.isArray(t.segmentIds)) {
|
|
971
|
+
throw new StateFileError(
|
|
972
|
+
"STATE_SCHEMA_INVALID",
|
|
973
|
+
`tasks[${i}].segmentIds is not an array (got ${typeof t.segmentIds})`,
|
|
974
|
+
);
|
|
975
|
+
}
|
|
976
|
+
for (let j = 0; j < (t.segmentIds as unknown[]).length; j++) {
|
|
977
|
+
if (typeof (t.segmentIds as unknown[])[j] !== "string") {
|
|
978
|
+
throw new StateFileError(
|
|
979
|
+
"STATE_SCHEMA_INVALID",
|
|
980
|
+
`tasks[${i}].segmentIds[${j}] is not a string`,
|
|
981
|
+
);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
// v4 optional field: activeSegmentId (string | null | undefined)
|
|
986
|
+
if (t.activeSegmentId !== undefined && t.activeSegmentId !== null && typeof t.activeSegmentId !== "string") {
|
|
987
|
+
throw new StateFileError(
|
|
988
|
+
"STATE_SCHEMA_INVALID",
|
|
989
|
+
`tasks[${i}].activeSegmentId is not a string or null (got ${typeof t.activeSegmentId})`,
|
|
990
|
+
);
|
|
991
|
+
}
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// ── Validate v4 segments array ───────────────────────────────
|
|
995
|
+
if (!Array.isArray(obj.segments)) {
|
|
996
|
+
throw new StateFileError(
|
|
997
|
+
"STATE_SCHEMA_INVALID",
|
|
998
|
+
`Missing or invalid "segments" field (expected array, got ${typeof obj.segments})`,
|
|
999
|
+
);
|
|
1000
|
+
}
|
|
1001
|
+
const segments = obj.segments as unknown[];
|
|
1002
|
+
for (let i = 0; i < segments.length; i++) {
|
|
1003
|
+
const s = segments[i] as Record<string, unknown>;
|
|
1004
|
+
if (!s || typeof s !== "object") {
|
|
1005
|
+
throw new StateFileError(
|
|
1006
|
+
"STATE_SCHEMA_INVALID",
|
|
1007
|
+
`segments[${i}] is not an object`,
|
|
1008
|
+
);
|
|
1009
|
+
}
|
|
1010
|
+
// Required string fields
|
|
1011
|
+
for (const field of ["segmentId", "taskId", "repoId", "laneId", "sessionName", "worktreePath", "branch", "exitReason"] as const) {
|
|
1012
|
+
if (typeof s[field] !== "string") {
|
|
1013
|
+
throw new StateFileError(
|
|
1014
|
+
"STATE_SCHEMA_INVALID",
|
|
1015
|
+
`segments[${i}].${field} is missing or not a string (got ${typeof s[field]})`,
|
|
1016
|
+
);
|
|
1017
|
+
}
|
|
1018
|
+
}
|
|
1019
|
+
// Required status field (same valid values as task status)
|
|
1020
|
+
if (typeof s.status !== "string" || !VALID_TASK_STATUSES.has(s.status)) {
|
|
1021
|
+
throw new StateFileError(
|
|
1022
|
+
"STATE_SCHEMA_INVALID",
|
|
1023
|
+
`segments[${i}].status is invalid: "${s.status}" (expected one of: ${[...VALID_TASK_STATUSES].join(", ")})`,
|
|
1024
|
+
);
|
|
1025
|
+
}
|
|
1026
|
+
// Nullable number fields: startedAt, endedAt
|
|
1027
|
+
if (s.startedAt !== null && typeof s.startedAt !== "number") {
|
|
1028
|
+
throw new StateFileError(
|
|
1029
|
+
"STATE_SCHEMA_INVALID",
|
|
1030
|
+
`segments[${i}].startedAt is not a number or null (got ${typeof s.startedAt})`,
|
|
1031
|
+
);
|
|
1032
|
+
}
|
|
1033
|
+
if (s.endedAt !== null && typeof s.endedAt !== "number") {
|
|
1034
|
+
throw new StateFileError(
|
|
1035
|
+
"STATE_SCHEMA_INVALID",
|
|
1036
|
+
`segments[${i}].endedAt is not a number or null (got ${typeof s.endedAt})`,
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
// Required number: retries
|
|
1040
|
+
if (typeof s.retries !== "number") {
|
|
1041
|
+
throw new StateFileError(
|
|
1042
|
+
"STATE_SCHEMA_INVALID",
|
|
1043
|
+
`segments[${i}].retries is not a number (got ${typeof s.retries})`,
|
|
1044
|
+
);
|
|
1045
|
+
}
|
|
1046
|
+
// Required array: dependsOnSegmentIds
|
|
1047
|
+
if (!Array.isArray(s.dependsOnSegmentIds)) {
|
|
1048
|
+
throw new StateFileError(
|
|
1049
|
+
"STATE_SCHEMA_INVALID",
|
|
1050
|
+
`segments[${i}].dependsOnSegmentIds is not an array (got ${typeof s.dependsOnSegmentIds})`,
|
|
1051
|
+
);
|
|
1052
|
+
}
|
|
1053
|
+
for (let j = 0; j < (s.dependsOnSegmentIds as unknown[]).length; j++) {
|
|
1054
|
+
if (typeof (s.dependsOnSegmentIds as unknown[])[j] !== "string") {
|
|
1055
|
+
throw new StateFileError(
|
|
1056
|
+
"STATE_SCHEMA_INVALID",
|
|
1057
|
+
`segments[${i}].dependsOnSegmentIds[${j}] is not a string`,
|
|
1058
|
+
);
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
// Optional exitDiagnostic
|
|
1062
|
+
if (s.exitDiagnostic !== undefined) {
|
|
1063
|
+
if (!s.exitDiagnostic || typeof s.exitDiagnostic !== "object" || Array.isArray(s.exitDiagnostic)) {
|
|
1064
|
+
throw new StateFileError(
|
|
1065
|
+
"STATE_SCHEMA_INVALID",
|
|
1066
|
+
`segments[${i}].exitDiagnostic is not a plain object (got ${Array.isArray(s.exitDiagnostic) ? "array" : typeof s.exitDiagnostic})`,
|
|
1067
|
+
);
|
|
1068
|
+
}
|
|
1069
|
+
if (typeof (s.exitDiagnostic as Record<string, unknown>).classification !== "string") {
|
|
1070
|
+
throw new StateFileError(
|
|
1071
|
+
"STATE_SCHEMA_INVALID",
|
|
1072
|
+
`segments[${i}].exitDiagnostic.classification is not a string`,
|
|
1073
|
+
);
|
|
1074
|
+
}
|
|
1075
|
+
}
|
|
931
1076
|
}
|
|
932
1077
|
|
|
933
1078
|
// ── Capture unknown top-level fields for roundtrip preservation ──
|
|
@@ -941,6 +1086,7 @@ export function validatePersistedState(data: unknown): PersistedBatchState {
|
|
|
941
1086
|
"totalTasks", "succeededTasks", "failedTasks", "skippedTasks", "blockedTasks",
|
|
942
1087
|
"blockedTaskIds", "lastError", "errors",
|
|
943
1088
|
"resilience", "diagnostics",
|
|
1089
|
+
"segments",
|
|
944
1090
|
"_extraFields",
|
|
945
1091
|
]);
|
|
946
1092
|
const extraFields: Record<string, unknown> = {};
|
|
@@ -1049,6 +1195,20 @@ export function serializeBatchState(
|
|
|
1049
1195
|
record.exitDiagnostic = outcome.exitDiagnostic;
|
|
1050
1196
|
}
|
|
1051
1197
|
|
|
1198
|
+
// TP-081 v4: Serialize segment-level fields from ParsedTask or existing state
|
|
1199
|
+
if (allocated?.allocatedTask.task?.packetRepoId !== undefined) {
|
|
1200
|
+
(record as any).packetRepoId = allocated.allocatedTask.task.packetRepoId;
|
|
1201
|
+
}
|
|
1202
|
+
if (allocated?.allocatedTask.task?.packetTaskPath !== undefined) {
|
|
1203
|
+
(record as any).packetTaskPath = allocated.allocatedTask.task.packetTaskPath;
|
|
1204
|
+
}
|
|
1205
|
+
if (allocated?.allocatedTask.task?.segmentIds !== undefined) {
|
|
1206
|
+
(record as any).segmentIds = allocated.allocatedTask.task.segmentIds;
|
|
1207
|
+
}
|
|
1208
|
+
if (allocated?.allocatedTask.task?.activeSegmentId !== undefined) {
|
|
1209
|
+
(record as any).activeSegmentId = allocated.allocatedTask.task.activeSegmentId;
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1052
1212
|
return record;
|
|
1053
1213
|
});
|
|
1054
1214
|
|
|
@@ -1122,6 +1282,7 @@ export function serializeBatchState(
|
|
|
1122
1282
|
errors: [...state.errors],
|
|
1123
1283
|
resilience: state.resilience ?? defaultResilienceState(),
|
|
1124
1284
|
diagnostics: state.diagnostics ?? defaultBatchDiagnostics(),
|
|
1285
|
+
segments: state.segments ?? [],
|
|
1125
1286
|
};
|
|
1126
1287
|
|
|
1127
1288
|
// Merge unknown fields from loaded state to preserve roundtrip fidelity.
|
|
@@ -1167,7 +1167,9 @@ export async function resumeOrchBatch(
|
|
|
1167
1167
|
});
|
|
1168
1168
|
|
|
1169
1169
|
try {
|
|
1170
|
-
spawnLaneSession(lane, allocatedTask, orchConfig, reExecRepoRoot
|
|
1170
|
+
spawnLaneSession(lane, allocatedTask, orchConfig, reExecRepoRoot, undefined, {
|
|
1171
|
+
ORCH_BATCH_ID: batchState.batchId,
|
|
1172
|
+
});
|
|
1171
1173
|
const pollResult = await pollUntilTaskComplete(
|
|
1172
1174
|
lane,
|
|
1173
1175
|
allocatedTask,
|
|
@@ -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
|