taskplane 0.19.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/taskplane.mjs +1 -1
- package/dashboard/public/app.js +41 -0
- package/dashboard/public/index.html +5 -2
- package/dashboard/public/style.css +194 -58
- package/dashboard/public/taskplane-word-color.svg +22 -0
- package/dashboard/public/taskplane-word-white.svg +22 -0
- package/dashboard/server.cjs +65 -0
- package/extensions/task-runner.ts +90 -69
- package/extensions/taskplane/engine-worker.ts +289 -0
- package/extensions/taskplane/execution.ts +298 -25
- package/extensions/taskplane/extension.ts +330 -94
- package/extensions/taskplane/merge.ts +242 -19
- package/extensions/taskplane/supervisor-primer.md +1 -1
- package/extensions/taskplane/supervisor.ts +161 -42
- package/package.json +1 -1
- package/skills/create-taskplane-task/references/prompt-template.md +0 -3
- package/templates/agents/task-worker.md +16 -1
- package/templates/tasks/EXAMPLE-001-hello-world/PROMPT.md +98 -99
- package/templates/tasks/EXAMPLE-001-hello-world/STATUS.md +73 -73
- package/templates/tasks/EXAMPLE-002-parallel-smoke/PROMPT.md +97 -98
- package/templates/tasks/EXAMPLE-002-parallel-smoke/STATUS.md +73 -73
|
@@ -3,10 +3,11 @@
|
|
|
3
3
|
* @module orch/merge
|
|
4
4
|
*/
|
|
5
5
|
import { readFileSync, writeFileSync, existsSync, unlinkSync, copyFileSync, mkdirSync, rmSync } from "fs";
|
|
6
|
+
import { readFile as fsReadFile } from "fs/promises";
|
|
6
7
|
import { execSync, spawnSync } from "child_process";
|
|
7
8
|
import { join, dirname, resolve, relative } from "path";
|
|
8
9
|
|
|
9
|
-
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, resolveTelemOpId, tmuxHasSession, tmuxKillSession, toTmuxPath } from "./execution.ts";
|
|
10
|
+
import { buildLaneEnvVars, buildTmuxSpawnArgs, execLog, generateTelemetryPaths, resolveRpcWrapperPath, resolveTelemOpId, tmuxHasSession, tmuxHasSessionAsync, tmuxKillSession, tmuxKillSessionAsync, tmuxAsync, toTmuxPath } from "./execution.ts";
|
|
10
11
|
import { resolveOperatorId } from "./naming.ts";
|
|
11
12
|
import { MERGE_POLL_INTERVAL_MS, MERGE_RESULT_GRACE_MS, MERGE_RESULT_READ_RETRIES, MERGE_RESULT_READ_RETRY_DELAY_MS, MERGE_SPAWN_RETRY_MAX, MERGE_TIMEOUT_MAX_RETRIES, MERGE_TIMEOUT_MS, MERGE_HEALTH_POLL_INTERVAL_MS, MERGE_HEALTH_WARNING_THRESHOLD_MS, MERGE_HEALTH_STUCK_THRESHOLD_MS, MERGE_HEALTH_CAPTURE_LINES, MergeError, VALID_MERGE_STATUSES, buildEngineEventBase } from "./types.ts";
|
|
12
13
|
import type { AllocatedLane, LaneExecutionResult, MergeLaneResult, MergeResult, MergeResultStatus, MergeWaveResult, OrchestratorConfig, RepoMergeOutcome, TaskRunnerConfig, TransactionRecord, TransactionStatus, VerificationBaselineResult, WaveExecutionResult, WorkspaceConfig, MergeHealthStatus, MergeHealthEventType, MergeSessionSnapshot, MergeSessionHealthState, EngineEvent, OrchBatchPhase } from "./types.ts";
|
|
@@ -250,6 +251,186 @@ export function parseMergeResult(resultPath: string): MergeResult {
|
|
|
250
251
|
);
|
|
251
252
|
}
|
|
252
253
|
|
|
254
|
+
/**
|
|
255
|
+
* Async version of parseMergeResult — reads and validates a merge result
|
|
256
|
+
* JSON file without blocking the event loop.
|
|
257
|
+
*
|
|
258
|
+
* Uses `fs/promises.readFile` instead of `readFileSync` and `sleepAsync`
|
|
259
|
+
* instead of `sleepSync` for retry delays. Validation semantics and error
|
|
260
|
+
* codes are identical to the sync version.
|
|
261
|
+
*
|
|
262
|
+
* @param resultPath - Path to the merge result JSON file
|
|
263
|
+
* @returns Promise resolving to a validated MergeResult
|
|
264
|
+
* @throws MergeError on missing/invalid/unparseable result
|
|
265
|
+
*
|
|
266
|
+
* @since TP-070
|
|
267
|
+
*/
|
|
268
|
+
export async function parseMergeResultAsync(resultPath: string): Promise<MergeResult> {
|
|
269
|
+
if (!existsSync(resultPath)) {
|
|
270
|
+
throw new MergeError(
|
|
271
|
+
"MERGE_RESULT_INVALID",
|
|
272
|
+
`Merge result file not found: ${resultPath}`,
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
const pickString = (obj: Record<string, unknown>, ...keys: string[]): string | null => {
|
|
277
|
+
for (const key of keys) {
|
|
278
|
+
const value = obj[key];
|
|
279
|
+
if (typeof value === "string" && value.trim().length > 0) {
|
|
280
|
+
return value;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return null;
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
const hasFlatVerification = (obj: Record<string, unknown>): boolean =>
|
|
287
|
+
typeof obj.verification_passed === "boolean"
|
|
288
|
+
|| Array.isArray(obj.verification_commands)
|
|
289
|
+
|| typeof obj.verification_output === "string"
|
|
290
|
+
|| typeof obj.verification_exit_code === "number";
|
|
291
|
+
|
|
292
|
+
const normalizeVerification = (obj: Record<string, unknown>): MergeResult["verification"] | null => {
|
|
293
|
+
const nested = (obj.verification && typeof obj.verification === "object")
|
|
294
|
+
? obj.verification as Record<string, unknown>
|
|
295
|
+
: null;
|
|
296
|
+
|
|
297
|
+
if (!nested && !hasFlatVerification(obj)) {
|
|
298
|
+
return null;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
const passedFromBool =
|
|
302
|
+
(nested && typeof nested.passed === "boolean" ? nested.passed : undefined)
|
|
303
|
+
?? (nested && typeof nested.all_passed === "boolean" ? nested.all_passed : undefined)
|
|
304
|
+
?? (typeof obj.verification_passed === "boolean" ? obj.verification_passed : undefined);
|
|
305
|
+
|
|
306
|
+
const exitCode =
|
|
307
|
+
(nested && typeof nested.exitCode === "number" ? nested.exitCode : undefined)
|
|
308
|
+
?? (nested && typeof nested.exit_code === "number" ? nested.exit_code : undefined)
|
|
309
|
+
?? (typeof obj.verification_exit_code === "number" ? obj.verification_exit_code : undefined);
|
|
310
|
+
|
|
311
|
+
const passed = typeof passedFromBool === "boolean"
|
|
312
|
+
? passedFromBool
|
|
313
|
+
: (typeof exitCode === "number" ? exitCode === 0 : false);
|
|
314
|
+
|
|
315
|
+
const ran = (nested && typeof nested.ran === "boolean")
|
|
316
|
+
? nested.ran
|
|
317
|
+
: (
|
|
318
|
+
typeof passedFromBool === "boolean"
|
|
319
|
+
|| typeof exitCode === "number"
|
|
320
|
+
|| (nested && typeof nested.command === "string")
|
|
321
|
+
|| (nested && typeof nested.summary === "string")
|
|
322
|
+
|| typeof obj.verification_output === "string"
|
|
323
|
+
|| Array.isArray(obj.verification_commands)
|
|
324
|
+
);
|
|
325
|
+
|
|
326
|
+
const output = (
|
|
327
|
+
(nested && typeof nested.output === "string" ? nested.output : undefined)
|
|
328
|
+
?? (nested && typeof nested.summary === "string" ? nested.summary : undefined)
|
|
329
|
+
?? (nested && typeof nested.notes === "string" ? nested.notes : undefined)
|
|
330
|
+
?? (typeof obj.verification_output === "string" ? obj.verification_output : "")
|
|
331
|
+
).slice(0, 2000);
|
|
332
|
+
|
|
333
|
+
return { ran, passed, output };
|
|
334
|
+
};
|
|
335
|
+
|
|
336
|
+
// Retry-read loop for partially-written files — async version
|
|
337
|
+
let lastParseError = "";
|
|
338
|
+
for (let attempt = 1; attempt <= MERGE_RESULT_READ_RETRIES; attempt++) {
|
|
339
|
+
try {
|
|
340
|
+
const raw = (await fsReadFile(resultPath, "utf-8")).trim();
|
|
341
|
+
if (!raw) {
|
|
342
|
+
lastParseError = "File is empty";
|
|
343
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
344
|
+
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
345
|
+
continue;
|
|
346
|
+
}
|
|
347
|
+
throw new MergeError(
|
|
348
|
+
"MERGE_RESULT_INVALID",
|
|
349
|
+
`Merge result file is empty after ${MERGE_RESULT_READ_RETRIES} attempts: ${resultPath}`,
|
|
350
|
+
);
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
354
|
+
|
|
355
|
+
// Validate required fields
|
|
356
|
+
if (typeof parsed.status !== "string") {
|
|
357
|
+
throw new MergeError(
|
|
358
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
359
|
+
`Merge result missing required field "status": ${resultPath}`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
const sourceBranch = pickString(parsed, "source_branch", "sourceBranch", "source");
|
|
364
|
+
if (!sourceBranch) {
|
|
365
|
+
throw new MergeError(
|
|
366
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
367
|
+
`Merge result missing required field "source_branch" (accepted aliases: sourceBranch, source): ${resultPath}`,
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const verification = normalizeVerification(parsed);
|
|
372
|
+
if (!verification) {
|
|
373
|
+
throw new MergeError(
|
|
374
|
+
"MERGE_RESULT_MISSING_FIELDS",
|
|
375
|
+
`Merge result missing required field "verification": ${resultPath}`,
|
|
376
|
+
);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// Normalize status to uppercase
|
|
380
|
+
parsed.status = String(parsed.status).toUpperCase();
|
|
381
|
+
|
|
382
|
+
if (!VALID_MERGE_STATUSES.has(parsed.status)) {
|
|
383
|
+
execLog("merge", "parse", `unknown merge status "${parsed.status}" — treating as BUILD_FAILURE`, {
|
|
384
|
+
resultPath,
|
|
385
|
+
});
|
|
386
|
+
parsed.status = "BUILD_FAILURE";
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
const targetBranch = pickString(parsed, "target_branch", "targetBranch", "target") ?? "";
|
|
390
|
+
const mergeCommit = pickString(parsed, "merge_commit", "mergeCommit") ?? "";
|
|
391
|
+
const conflicts = Array.isArray(parsed.conflicts)
|
|
392
|
+
? parsed.conflicts
|
|
393
|
+
.filter((c): c is { file: string; type: string; resolved: boolean; resolution?: string } => (
|
|
394
|
+
typeof c === "object"
|
|
395
|
+
&& c !== null
|
|
396
|
+
&& typeof (c as { file?: unknown }).file === "string"
|
|
397
|
+
&& typeof (c as { type?: unknown }).type === "string"
|
|
398
|
+
&& typeof (c as { resolved?: unknown }).resolved === "boolean"
|
|
399
|
+
))
|
|
400
|
+
.map(c => ({
|
|
401
|
+
file: c.file,
|
|
402
|
+
type: c.type,
|
|
403
|
+
resolved: c.resolved,
|
|
404
|
+
...(typeof c.resolution === "string" ? { resolution: c.resolution } : {}),
|
|
405
|
+
}))
|
|
406
|
+
: [];
|
|
407
|
+
|
|
408
|
+
return {
|
|
409
|
+
status: parsed.status as MergeResultStatus,
|
|
410
|
+
source_branch: sourceBranch,
|
|
411
|
+
target_branch: targetBranch,
|
|
412
|
+
merge_commit: mergeCommit,
|
|
413
|
+
conflicts,
|
|
414
|
+
verification,
|
|
415
|
+
};
|
|
416
|
+
} catch (err: unknown) {
|
|
417
|
+
if (err instanceof MergeError) throw err;
|
|
418
|
+
|
|
419
|
+
lastParseError = err instanceof Error ? err.message : String(err);
|
|
420
|
+
if (attempt < MERGE_RESULT_READ_RETRIES) {
|
|
421
|
+
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
422
|
+
continue;
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
throw new MergeError(
|
|
428
|
+
"MERGE_RESULT_INVALID",
|
|
429
|
+
`Failed to parse merge result JSON after ${MERGE_RESULT_READ_RETRIES} attempts. ` +
|
|
430
|
+
`Last error: ${lastParseError}. File: ${resultPath}`,
|
|
431
|
+
);
|
|
432
|
+
}
|
|
433
|
+
|
|
253
434
|
/**
|
|
254
435
|
* Determine merge order for completed lanes.
|
|
255
436
|
*
|
|
@@ -580,7 +761,7 @@ export async function waitForMergeResult(
|
|
|
580
761
|
// just pushed past the timeout. Accept successful results without killing.
|
|
581
762
|
if (existsSync(resultPath)) {
|
|
582
763
|
try {
|
|
583
|
-
const lateResult =
|
|
764
|
+
const lateResult = await parseMergeResultAsync(resultPath);
|
|
584
765
|
if (SUCCESSFUL_MERGE_STATUSES.has(lateResult.status)) {
|
|
585
766
|
execLog("merge", sessionName, "merge agent slow but succeeded — accepting result at timeout", {
|
|
586
767
|
status: lateResult.status,
|
|
@@ -588,8 +769,8 @@ export async function waitForMergeResult(
|
|
|
588
769
|
timeoutMs,
|
|
589
770
|
});
|
|
590
771
|
// Clean up session (agent may still be running post-write)
|
|
591
|
-
if (
|
|
592
|
-
|
|
772
|
+
if (await tmuxHasSessionAsync(sessionName)) {
|
|
773
|
+
await tmuxKillSessionAsync(sessionName);
|
|
593
774
|
}
|
|
594
775
|
return lateResult;
|
|
595
776
|
}
|
|
@@ -608,7 +789,7 @@ export async function waitForMergeResult(
|
|
|
608
789
|
elapsed,
|
|
609
790
|
timeoutMs,
|
|
610
791
|
});
|
|
611
|
-
|
|
792
|
+
await tmuxKillSessionAsync(sessionName);
|
|
612
793
|
|
|
613
794
|
throw new MergeError(
|
|
614
795
|
"MERGE_TIMEOUT",
|
|
@@ -621,25 +802,25 @@ export async function waitForMergeResult(
|
|
|
621
802
|
// Check if result file exists
|
|
622
803
|
if (existsSync(resultPath)) {
|
|
623
804
|
try {
|
|
624
|
-
const result =
|
|
805
|
+
const result = await parseMergeResultAsync(resultPath);
|
|
625
806
|
execLog("merge", sessionName, "merge result received", {
|
|
626
807
|
status: result.status,
|
|
627
808
|
elapsed,
|
|
628
809
|
});
|
|
629
810
|
// Kill session if still alive (agent should exit, but ensure cleanup)
|
|
630
|
-
if (
|
|
631
|
-
|
|
811
|
+
if (await tmuxHasSessionAsync(sessionName)) {
|
|
812
|
+
await tmuxKillSessionAsync(sessionName);
|
|
632
813
|
}
|
|
633
814
|
return result;
|
|
634
815
|
} catch (err: unknown) {
|
|
635
816
|
// File exists but invalid — might be partially written.
|
|
636
|
-
//
|
|
817
|
+
// parseMergeResultAsync already retries, so if it throws, it's final.
|
|
637
818
|
if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
|
|
638
819
|
// Wait a bit and try once more (file might still be in flight)
|
|
639
820
|
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
640
821
|
if (existsSync(resultPath)) {
|
|
641
822
|
try {
|
|
642
|
-
return
|
|
823
|
+
return await parseMergeResultAsync(resultPath);
|
|
643
824
|
} catch {
|
|
644
825
|
// Give up on this file
|
|
645
826
|
}
|
|
@@ -649,8 +830,8 @@ export async function waitForMergeResult(
|
|
|
649
830
|
}
|
|
650
831
|
}
|
|
651
832
|
|
|
652
|
-
// Check session liveness
|
|
653
|
-
const sessionAlive =
|
|
833
|
+
// Check session liveness — async to avoid blocking
|
|
834
|
+
const sessionAlive = await tmuxHasSessionAsync(sessionName);
|
|
654
835
|
|
|
655
836
|
if (!sessionAlive) {
|
|
656
837
|
if (sessionDiedAt === null) {
|
|
@@ -664,7 +845,7 @@ export async function waitForMergeResult(
|
|
|
664
845
|
// One final check
|
|
665
846
|
if (existsSync(resultPath)) {
|
|
666
847
|
try {
|
|
667
|
-
return
|
|
848
|
+
return await parseMergeResultAsync(resultPath);
|
|
668
849
|
} catch {
|
|
669
850
|
// Fall through to session died error
|
|
670
851
|
}
|
|
@@ -2310,6 +2491,38 @@ export function captureMergePaneOutput(
|
|
|
2310
2491
|
}
|
|
2311
2492
|
}
|
|
2312
2493
|
|
|
2494
|
+
/**
|
|
2495
|
+
* Async version of captureMergePaneOutput — captures pane output
|
|
2496
|
+
* without blocking the event loop.
|
|
2497
|
+
*
|
|
2498
|
+
* @param sessionName - TMUX session name
|
|
2499
|
+
* @param lines - Number of lines to capture from the bottom
|
|
2500
|
+
* @returns Promise resolving to captured text, or null on failure
|
|
2501
|
+
*
|
|
2502
|
+
* @since TP-070
|
|
2503
|
+
*/
|
|
2504
|
+
export async function captureMergePaneOutputAsync(
|
|
2505
|
+
sessionName: string,
|
|
2506
|
+
lines: number = MERGE_HEALTH_CAPTURE_LINES,
|
|
2507
|
+
): Promise<string | null> {
|
|
2508
|
+
try {
|
|
2509
|
+
const result = await tmuxAsync([
|
|
2510
|
+
"capture-pane",
|
|
2511
|
+
"-t", sessionName,
|
|
2512
|
+
"-p",
|
|
2513
|
+
"-S", `-${lines}`,
|
|
2514
|
+
], 5_000);
|
|
2515
|
+
|
|
2516
|
+
if (result.status !== 0) {
|
|
2517
|
+
return null;
|
|
2518
|
+
}
|
|
2519
|
+
|
|
2520
|
+
return result.stdout || null;
|
|
2521
|
+
} catch {
|
|
2522
|
+
return null;
|
|
2523
|
+
}
|
|
2524
|
+
}
|
|
2525
|
+
|
|
2313
2526
|
/**
|
|
2314
2527
|
* Classify the health of a merge session based on session liveness
|
|
2315
2528
|
* and pane output activity.
|
|
@@ -2458,6 +2671,9 @@ export class MergeHealthMonitor {
|
|
|
2458
2671
|
this._resultPaths.delete(sessionName);
|
|
2459
2672
|
}
|
|
2460
2673
|
|
|
2674
|
+
/** Overlap guard for async poll (TP-070) */
|
|
2675
|
+
private _polling = false;
|
|
2676
|
+
|
|
2461
2677
|
/**
|
|
2462
2678
|
* Start the health monitoring polling loop.
|
|
2463
2679
|
*/
|
|
@@ -2470,8 +2686,14 @@ export class MergeHealthMonitor {
|
|
|
2470
2686
|
pollIntervalMs: this.pollIntervalMs,
|
|
2471
2687
|
});
|
|
2472
2688
|
|
|
2473
|
-
this.pollTimer = setInterval(() => {
|
|
2474
|
-
this.
|
|
2689
|
+
this.pollTimer = setInterval(async () => {
|
|
2690
|
+
if (this._polling) return; // Overlap guard (TP-070)
|
|
2691
|
+
this._polling = true;
|
|
2692
|
+
try {
|
|
2693
|
+
await this.poll();
|
|
2694
|
+
} finally {
|
|
2695
|
+
this._polling = false;
|
|
2696
|
+
}
|
|
2475
2697
|
}, this.pollIntervalMs);
|
|
2476
2698
|
}
|
|
2477
2699
|
|
|
@@ -2499,18 +2721,19 @@ export class MergeHealthMonitor {
|
|
|
2499
2721
|
* Run a single poll cycle across all monitored sessions.
|
|
2500
2722
|
*
|
|
2501
2723
|
* Exposed as public for testing — normally called by the interval timer.
|
|
2724
|
+
* Async (TP-070) — uses non-blocking tmux calls to avoid event loop stalls.
|
|
2502
2725
|
*/
|
|
2503
|
-
poll(): void {
|
|
2726
|
+
async poll(): Promise<void> {
|
|
2504
2727
|
const now = Date.now();
|
|
2505
2728
|
|
|
2506
2729
|
for (const [sessionName, state] of this.sessions) {
|
|
2507
|
-
const sessionAlive =
|
|
2730
|
+
const sessionAlive = await tmuxHasSessionAsync(sessionName);
|
|
2508
2731
|
const resultPath = this._resultPaths.get(sessionName) ?? "";
|
|
2509
2732
|
const hasResultFile = resultPath ? existsSync(resultPath) : false;
|
|
2510
2733
|
|
|
2511
|
-
// Capture pane output for activity detection
|
|
2734
|
+
// Capture pane output for activity detection — async to avoid blocking
|
|
2512
2735
|
const currentOutput = sessionAlive
|
|
2513
|
-
?
|
|
2736
|
+
? await captureMergePaneOutputAsync(sessionName)
|
|
2514
2737
|
: null;
|
|
2515
2738
|
|
|
2516
2739
|
// Classify health
|
|
@@ -38,7 +38,7 @@ guardrails) is injected at runtime.
|
|
|
38
38
|
```
|
|
39
39
|
You (supervisor) ← operator talks to you
|
|
40
40
|
│
|
|
41
|
-
├── Engine (deterministic TypeScript code)
|
|
41
|
+
├── Engine (deterministic TypeScript code, runs in worker_thread)
|
|
42
42
|
│ ├── Discovers tasks, builds dependency DAG
|
|
43
43
|
│ ├── Computes waves (topological sort)
|
|
44
44
|
│ ├── Assigns tasks to lanes (parallel execution slots)
|
|
@@ -29,6 +29,7 @@
|
|
|
29
29
|
import { join, dirname } from "path";
|
|
30
30
|
import { fileURLToPath } from "url";
|
|
31
31
|
import { existsSync, readFileSync, writeFileSync, unlinkSync, mkdirSync, renameSync, statSync, openSync, readSync, closeSync, appendFileSync } from "fs";
|
|
32
|
+
import { stat as fsStat, open as fsOpen, readFile as fsReadFile, writeFile as fsWriteFile, rename as fsRename } from "fs/promises";
|
|
32
33
|
import { execFileSync } from "child_process";
|
|
33
34
|
import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-agent";
|
|
34
35
|
import type { Model, Api } from "@mariozechner/pi-ai";
|
|
@@ -2587,7 +2588,7 @@ export interface SupervisorRoutingContext {
|
|
|
2587
2588
|
/**
|
|
2588
2589
|
* Activate the supervisor agent in the current pi session.
|
|
2589
2590
|
*
|
|
2590
|
-
* This is called after `
|
|
2591
|
+
* This is called after `startBatchInWorker()` in the `/orch` command handler,
|
|
2591
2592
|
* or directly by the `/orch` no-args routing logic (TP-042).
|
|
2592
2593
|
*
|
|
2593
2594
|
* It:
|
|
@@ -2705,7 +2706,9 @@ export async function activateSupervisor(
|
|
|
2705
2706
|
// Initializes byte offset to current file size so we skip stale events.
|
|
2706
2707
|
// Idempotent — safe even if called from takeover paths that may have
|
|
2707
2708
|
// started a tailer previously (stopEventTailer is called in deactivate).
|
|
2708
|
-
startEventTailer(pi, state.eventTailer, state)
|
|
2709
|
+
startEventTailer(pi, state.eventTailer, state, (key, text) => {
|
|
2710
|
+
try { ctx.ui.setStatus(key, text); } catch { /* non-fatal */ }
|
|
2711
|
+
});
|
|
2709
2712
|
|
|
2710
2713
|
// Send activation message to trigger the supervisor's first turn.
|
|
2711
2714
|
// The content is generic — specific counts may not be available yet
|
|
@@ -3071,6 +3074,62 @@ export function writeLockfile(stateRoot: string, lock: SupervisorLockfile): void
|
|
|
3071
3074
|
renameSync(tmpPath, finalPath);
|
|
3072
3075
|
}
|
|
3073
3076
|
|
|
3077
|
+
/**
|
|
3078
|
+
* Async version of readLockfile — reads lockfile without blocking the event loop.
|
|
3079
|
+
*
|
|
3080
|
+
* @param stateRoot - Root path for .pi/ state directory
|
|
3081
|
+
* @returns Parsed lockfile or null
|
|
3082
|
+
*
|
|
3083
|
+
* @since TP-070
|
|
3084
|
+
*/
|
|
3085
|
+
export async function readLockfileAsync(stateRoot: string): Promise<SupervisorLockfile | null> {
|
|
3086
|
+
const path = lockfilePath(stateRoot);
|
|
3087
|
+
|
|
3088
|
+
try {
|
|
3089
|
+
const raw = await fsReadFile(path, "utf-8");
|
|
3090
|
+
const parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
3091
|
+
|
|
3092
|
+
if (
|
|
3093
|
+
typeof parsed.pid !== "number" ||
|
|
3094
|
+
typeof parsed.sessionId !== "string" ||
|
|
3095
|
+
typeof parsed.batchId !== "string" ||
|
|
3096
|
+
typeof parsed.startedAt !== "string" ||
|
|
3097
|
+
typeof parsed.heartbeat !== "string"
|
|
3098
|
+
) {
|
|
3099
|
+
return null;
|
|
3100
|
+
}
|
|
3101
|
+
|
|
3102
|
+
return parsed as unknown as SupervisorLockfile;
|
|
3103
|
+
} catch {
|
|
3104
|
+
return null;
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
|
|
3108
|
+
/**
|
|
3109
|
+
* Async version of writeLockfile — writes lockfile without blocking the event loop.
|
|
3110
|
+
*
|
|
3111
|
+
* Creates the `.pi/supervisor/` directory if it doesn't exist.
|
|
3112
|
+
* Uses temp+rename for atomicity.
|
|
3113
|
+
*
|
|
3114
|
+
* @param stateRoot - Root path for .pi/ state directory
|
|
3115
|
+
* @param lock - Lockfile data to write
|
|
3116
|
+
*
|
|
3117
|
+
* @since TP-070
|
|
3118
|
+
*/
|
|
3119
|
+
export async function writeLockfileAsync(stateRoot: string, lock: SupervisorLockfile): Promise<void> {
|
|
3120
|
+
const dir = join(stateRoot, ".pi", "supervisor");
|
|
3121
|
+
if (!existsSync(dir)) {
|
|
3122
|
+
mkdirSync(dir, { recursive: true });
|
|
3123
|
+
}
|
|
3124
|
+
|
|
3125
|
+
const finalPath = lockfilePath(stateRoot);
|
|
3126
|
+
const tmpPath = finalPath + ".tmp";
|
|
3127
|
+
const json = JSON.stringify(lock, null, 2) + "\n";
|
|
3128
|
+
|
|
3129
|
+
await fsWriteFile(tmpPath, json, "utf-8");
|
|
3130
|
+
await fsRename(tmpPath, finalPath);
|
|
3131
|
+
}
|
|
3132
|
+
|
|
3074
3133
|
/**
|
|
3075
3134
|
* Remove the supervisor lockfile.
|
|
3076
3135
|
*
|
|
@@ -3301,47 +3360,55 @@ export function startHeartbeat(
|
|
|
3301
3360
|
pi: ExtensionAPI,
|
|
3302
3361
|
): ReturnType<typeof setInterval> {
|
|
3303
3362
|
const sessionId = state.lockSessionId;
|
|
3363
|
+
let heartbeatInProgress = false; // Overlap guard (TP-070)
|
|
3304
3364
|
|
|
3305
|
-
const timer = setInterval(() => {
|
|
3365
|
+
const timer = setInterval(async () => {
|
|
3306
3366
|
if (!state.active) {
|
|
3307
3367
|
clearInterval(timer);
|
|
3308
3368
|
return;
|
|
3309
3369
|
}
|
|
3310
3370
|
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
if (currentLock && currentLock.sessionId !== sessionId) {
|
|
3314
|
-
// Another session has taken over — yield gracefully
|
|
3315
|
-
clearInterval(timer);
|
|
3316
|
-
pi.sendMessage(
|
|
3317
|
-
{
|
|
3318
|
-
customType: "supervisor-yield",
|
|
3319
|
-
content: [{
|
|
3320
|
-
type: "text",
|
|
3321
|
-
text: "⚡ Another session has taken over supervisor duties. Yielding.",
|
|
3322
|
-
}],
|
|
3323
|
-
display: "Supervisor yielded to another session",
|
|
3324
|
-
},
|
|
3325
|
-
{ triggerTurn: false },
|
|
3326
|
-
);
|
|
3327
|
-
deactivateSupervisor(pi, state);
|
|
3328
|
-
return;
|
|
3329
|
-
}
|
|
3371
|
+
if (heartbeatInProgress) return; // Overlap guard (TP-070)
|
|
3372
|
+
heartbeatInProgress = true;
|
|
3330
3373
|
|
|
3331
|
-
// Update heartbeat (and refresh batchId if it was initially unknown)
|
|
3332
3374
|
try {
|
|
3333
|
-
|
|
3334
|
-
|
|
3335
|
-
|
|
3336
|
-
//
|
|
3337
|
-
|
|
3338
|
-
|
|
3339
|
-
|
|
3375
|
+
// Read current lockfile to detect force takeover — async (TP-070)
|
|
3376
|
+
const currentLock = await readLockfileAsync(stateRoot);
|
|
3377
|
+
if (currentLock && currentLock.sessionId !== sessionId) {
|
|
3378
|
+
// Another session has taken over — yield gracefully
|
|
3379
|
+
clearInterval(timer);
|
|
3380
|
+
pi.sendMessage(
|
|
3381
|
+
{
|
|
3382
|
+
customType: "supervisor-yield",
|
|
3383
|
+
content: [{
|
|
3384
|
+
type: "text",
|
|
3385
|
+
text: "⚡ Another session has taken over supervisor duties. Yielding.",
|
|
3386
|
+
}],
|
|
3387
|
+
display: "Supervisor yielded to another session",
|
|
3388
|
+
},
|
|
3389
|
+
{ triggerTurn: false },
|
|
3390
|
+
);
|
|
3391
|
+
deactivateSupervisor(pi, state);
|
|
3392
|
+
return;
|
|
3393
|
+
}
|
|
3394
|
+
|
|
3395
|
+
// Update heartbeat (and refresh batchId if it was initially unknown)
|
|
3396
|
+
try {
|
|
3397
|
+
const lock = await readLockfileAsync(stateRoot);
|
|
3398
|
+
if (lock && lock.sessionId === sessionId) {
|
|
3399
|
+
lock.heartbeat = new Date().toISOString();
|
|
3400
|
+
// TP-130: batchId may have been "(initializing)" at lock creation
|
|
3401
|
+
// because the batch hadn't started yet. Refresh from live state ref.
|
|
3402
|
+
if (state.batchStateRef?.batchId && lock.batchId !== state.batchStateRef.batchId) {
|
|
3403
|
+
lock.batchId = state.batchStateRef.batchId;
|
|
3404
|
+
}
|
|
3405
|
+
await writeLockfileAsync(stateRoot, lock);
|
|
3340
3406
|
}
|
|
3341
|
-
|
|
3407
|
+
} catch {
|
|
3408
|
+
// Best-effort heartbeat — don't crash the supervisor
|
|
3342
3409
|
}
|
|
3343
|
-
}
|
|
3344
|
-
|
|
3410
|
+
} finally {
|
|
3411
|
+
heartbeatInProgress = false;
|
|
3345
3412
|
}
|
|
3346
3413
|
}, HEARTBEAT_INTERVAL_MS);
|
|
3347
3414
|
|
|
@@ -3602,6 +3669,39 @@ export function readNewBytes(eventsPath: string, byteOffset: number): [string, n
|
|
|
3602
3669
|
return [buffer.toString("utf-8"), fileSize];
|
|
3603
3670
|
}
|
|
3604
3671
|
|
|
3672
|
+
/**
|
|
3673
|
+
* Async version of readNewBytes — reads new bytes without blocking the event loop.
|
|
3674
|
+
*
|
|
3675
|
+
* Uses `fs/promises` for non-blocking stat and read operations.
|
|
3676
|
+
*
|
|
3677
|
+
* @param eventsPath - Full path to events.jsonl
|
|
3678
|
+
* @param byteOffset - Start reading from this byte offset
|
|
3679
|
+
* @returns [newData, newByteOffset]
|
|
3680
|
+
*
|
|
3681
|
+
* @since TP-070
|
|
3682
|
+
*/
|
|
3683
|
+
export async function readNewBytesAsync(eventsPath: string, byteOffset: number): Promise<[string, number]> {
|
|
3684
|
+
try {
|
|
3685
|
+
const stats = await fsStat(eventsPath);
|
|
3686
|
+
const fileSize = stats.size;
|
|
3687
|
+
if (fileSize <= byteOffset) return ["", byteOffset];
|
|
3688
|
+
|
|
3689
|
+
const bytesToRead = fileSize - byteOffset;
|
|
3690
|
+
const buffer = Buffer.alloc(bytesToRead);
|
|
3691
|
+
|
|
3692
|
+
const fh = await fsOpen(eventsPath, "r");
|
|
3693
|
+
try {
|
|
3694
|
+
await fh.read(buffer, 0, bytesToRead, byteOffset);
|
|
3695
|
+
} finally {
|
|
3696
|
+
await fh.close();
|
|
3697
|
+
}
|
|
3698
|
+
|
|
3699
|
+
return [buffer.toString("utf-8"), fileSize];
|
|
3700
|
+
} catch {
|
|
3701
|
+
return ["", byteOffset];
|
|
3702
|
+
}
|
|
3703
|
+
}
|
|
3704
|
+
|
|
3605
3705
|
/**
|
|
3606
3706
|
* Parse JSONL lines from raw data, handling partial lines.
|
|
3607
3707
|
*
|
|
@@ -3940,6 +4040,8 @@ export function startEventTailer(
|
|
|
3940
4040
|
pi: ExtensionAPI,
|
|
3941
4041
|
tailer: EventTailerState,
|
|
3942
4042
|
supervisorState: SupervisorState,
|
|
4043
|
+
/** Optional callback to update footer status immediately (bypasses sendMessage queue). @since TP-068/214 */
|
|
4044
|
+
setStatus?: (key: string, text: string) => void,
|
|
3943
4045
|
): void {
|
|
3944
4046
|
if (tailer.running) return; // Idempotent guard (R005-2)
|
|
3945
4047
|
|
|
@@ -3967,6 +4069,15 @@ export function startEventTailer(
|
|
|
3967
4069
|
// Notification callback — sends as a supervisor event message
|
|
3968
4070
|
const notify = (text: string) => {
|
|
3969
4071
|
if (!supervisorState.active) return; // Guard: don't notify after deactivation
|
|
4072
|
+
|
|
4073
|
+
// TP-068/214: Update footer status immediately for visibility.
|
|
4074
|
+
// setStatus renders in the TUI footer without waiting for user input,
|
|
4075
|
+
// unlike sendMessage which queues until next turn.
|
|
4076
|
+
if (setStatus) {
|
|
4077
|
+
const statusText = text.replace(/\*\*/g, "").replace(/\n.*/s, "").substring(0, 120);
|
|
4078
|
+
setStatus("supervisor", `🔀 ${statusText}`);
|
|
4079
|
+
}
|
|
4080
|
+
|
|
3970
4081
|
pi.sendMessage(
|
|
3971
4082
|
{
|
|
3972
4083
|
customType: "supervisor-event",
|
|
@@ -3978,27 +4089,35 @@ export function startEventTailer(
|
|
|
3978
4089
|
};
|
|
3979
4090
|
|
|
3980
4091
|
// ── TP-043: Integration is triggered by triggerSupervisorIntegration() ──
|
|
3981
|
-
// called from the onTerminal callback in
|
|
4092
|
+
// called from the onTerminal callback in startBatchInWorker (extension.ts),
|
|
3982
4093
|
// gated on phase === "completed" (R002-1). For auto mode, integration is
|
|
3983
4094
|
// executed programmatically via the executor callback (R002-2). The event
|
|
3984
4095
|
// tailer does NOT duplicate the integration trigger — batch_complete events
|
|
3985
4096
|
// are handled via the normal notification path (formatEventNotification).
|
|
3986
4097
|
|
|
3987
|
-
// ── Poll timer
|
|
3988
|
-
|
|
4098
|
+
// ── Poll timer (async, TP-070) ───────────────────────────────
|
|
4099
|
+
let tailerPollInProgress = false; // Overlap guard (TP-070)
|
|
4100
|
+
tailer.pollTimer = setInterval(async () => {
|
|
3989
4101
|
if (!supervisorState.active || !tailer.running) {
|
|
3990
4102
|
stopEventTailer(tailer);
|
|
3991
4103
|
return;
|
|
3992
4104
|
}
|
|
3993
4105
|
|
|
3994
|
-
|
|
3995
|
-
|
|
4106
|
+
if (tailerPollInProgress) return; // Overlap guard (TP-070)
|
|
4107
|
+
tailerPollInProgress = true;
|
|
3996
4108
|
|
|
3997
|
-
|
|
3998
|
-
|
|
3999
|
-
|
|
4109
|
+
try {
|
|
4110
|
+
const [newData, newOffset] = await readNewBytesAsync(eventsPath, tailer.byteOffset);
|
|
4111
|
+
if (!newData) return; // No new data
|
|
4000
4112
|
|
|
4001
|
-
|
|
4113
|
+
tailer.byteOffset = newOffset;
|
|
4114
|
+
const [events, remaining] = parseJsonlLines(newData, tailer.partialLine);
|
|
4115
|
+
tailer.partialLine = remaining;
|
|
4116
|
+
|
|
4117
|
+
processEvents(events, tailer, autonomy, notify);
|
|
4118
|
+
} finally {
|
|
4119
|
+
tailerPollInProgress = false;
|
|
4120
|
+
}
|
|
4002
4121
|
}, EVENT_POLL_INTERVAL_MS);
|
|
4003
4122
|
|
|
4004
4123
|
// ── Digest flush timer ───────────────────────────────────────
|