taskplane 0.22.12 → 0.22.13
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/dashboard/public/app.js +108 -55
- package/dashboard/public/style.css +18 -0
- package/dashboard/server.cjs +29 -3
- package/extensions/task-runner.ts +170 -21
- package/extensions/taskplane/cleanup.ts +30 -9
- package/extensions/taskplane/engine.ts +11 -12
- package/extensions/taskplane/execution.ts +20 -3
- package/extensions/taskplane/extension.ts +499 -8
- package/extensions/taskplane/supervisor-primer.md +6 -0
- package/package.json +1 -1
|
@@ -2,7 +2,7 @@ import type { ExtensionAPI, ExtensionContext } from "@mariozechner/pi-coding-age
|
|
|
2
2
|
import { Type } from "@mariozechner/pi-ai";
|
|
3
3
|
|
|
4
4
|
import { execSync, execFileSync } from "child_process";
|
|
5
|
-
import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync } from "fs";
|
|
5
|
+
import { writeFileSync, unlinkSync, mkdirSync, existsSync, readdirSync, readFileSync, statSync } from "fs";
|
|
6
6
|
import { join, dirname } from "path";
|
|
7
7
|
import { fileURLToPath } from "url";
|
|
8
8
|
import { fork, type ChildProcess } from "child_process";
|
|
@@ -17,7 +17,7 @@ import { computeWaveAssignments } from "./waves.ts";
|
|
|
17
17
|
import { createOrchWidget, formatDependencyGraph, formatWavePlan } from "./formatting.ts";
|
|
18
18
|
import { deleteBatchState, loadBatchState, saveBatchState, detectOrphanSessions, parseOrchSessionNames } from "./persistence.ts";
|
|
19
19
|
import { deleteStaleBranches, listWorktrees, resolveWorktreeBasePath, formatPreflightResults, runPreflight } from "./worktree.ts";
|
|
20
|
-
import { computeTransitiveDependents, executeLane } from "./execution.ts";
|
|
20
|
+
import { computeTransitiveDependents, executeLane, resolveCanonicalTaskPaths, tmuxHasSession } from "./execution.ts";
|
|
21
21
|
import { executeOrchBatch } from "./engine.ts";
|
|
22
22
|
import { formatDiscoveryResults, runDiscovery } from "./discovery.ts";
|
|
23
23
|
import { formatOrchSessions, listOrchSessions } from "./sessions.ts";
|
|
@@ -1144,7 +1144,7 @@ export function startBatchInWorker(
|
|
|
1144
1144
|
*
|
|
1145
1145
|
* @since TP-043 R002
|
|
1146
1146
|
*/
|
|
1147
|
-
export function buildIntegrationExecutor(repoRoot: string, opId?: string): IntegrationExecutor {
|
|
1147
|
+
export function buildIntegrationExecutor(repoRoot: string, opId?: string, stateRoot?: string): IntegrationExecutor {
|
|
1148
1148
|
return (mode, context) => {
|
|
1149
1149
|
// Ensure we're on the base branch before integrating
|
|
1150
1150
|
const currentBranch = getCurrentBranch(repoRoot);
|
|
@@ -1204,7 +1204,7 @@ export function buildIntegrationExecutor(repoRoot: string, opId?: string): Integ
|
|
|
1204
1204
|
// TP-065: Post-integrate artifact cleanup (Layer 1).
|
|
1205
1205
|
// Also runs on the supervisor auto-integration path.
|
|
1206
1206
|
try {
|
|
1207
|
-
cleanupPostIntegrate(repoRoot, context.batchId);
|
|
1207
|
+
cleanupPostIntegrate(stateRoot ?? repoRoot, context.batchId);
|
|
1208
1208
|
} catch { /* best effort — don't fail integration for cleanup errors */ }
|
|
1209
1209
|
}
|
|
1210
1210
|
|
|
@@ -1907,7 +1907,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
1907
1907
|
orchBatchState,
|
|
1908
1908
|
mode,
|
|
1909
1909
|
repoRoot,
|
|
1910
|
-
buildIntegrationExecutor(repoRoot, opId),
|
|
1910
|
+
buildIntegrationExecutor(repoRoot, opId, execCtx!.workspaceRoot),
|
|
1911
1911
|
buildCiDeps(repoRoot),
|
|
1912
1912
|
sDeps,
|
|
1913
1913
|
);
|
|
@@ -2127,7 +2127,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2127
2127
|
orchBatchState,
|
|
2128
2128
|
mode,
|
|
2129
2129
|
execCtx!.repoRoot,
|
|
2130
|
-
buildIntegrationExecutor(execCtx!.repoRoot, opId),
|
|
2130
|
+
buildIntegrationExecutor(execCtx!.repoRoot, opId, execCtx!.workspaceRoot),
|
|
2131
2131
|
buildCiDeps(execCtx!.repoRoot),
|
|
2132
2132
|
sDeps,
|
|
2133
2133
|
);
|
|
@@ -2809,6 +2809,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2809
2809
|
|
|
2810
2810
|
// Resolve integration context
|
|
2811
2811
|
const { repoRoot } = execCtx!;
|
|
2812
|
+
const stateRoot = execCtx!.workspaceRoot;
|
|
2812
2813
|
const resolution = resolveIntegrationContext(parsed, {
|
|
2813
2814
|
loadBatchState: () => loadBatchState(repoRoot),
|
|
2814
2815
|
getCurrentBranch: () => getCurrentBranch(repoRoot),
|
|
@@ -2993,7 +2994,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
2993
2994
|
// Non-fatal — failures warn but don't block integration.
|
|
2994
2995
|
if (batchId) {
|
|
2995
2996
|
try {
|
|
2996
|
-
const artifactCleanup = cleanupPostIntegrate(
|
|
2997
|
+
const artifactCleanup = cleanupPostIntegrate(stateRoot, batchId);
|
|
2997
2998
|
const totalCleaned = artifactCleanup.telemetryFilesDeleted + artifactCleanup.mergeFilesDeleted + artifactCleanup.promptFilesDeleted + artifactCleanup.mailboxDirsDeleted;
|
|
2998
2999
|
if (totalCleaned > 0) {
|
|
2999
3000
|
const cleanupParts = [
|
|
@@ -3697,7 +3698,7 @@ export default function (pi: ExtensionAPI) {
|
|
|
3697
3698
|
* @since TP-089
|
|
3698
3699
|
*/
|
|
3699
3700
|
function doSendAgentMessage(to: string, content: string, messageType: string, ctx: ExtensionContext): string {
|
|
3700
|
-
const stateRoot =
|
|
3701
|
+
const stateRoot = resolveToolStateRoot(ctx);
|
|
3701
3702
|
|
|
3702
3703
|
// Validate message type (outbound allowlist: steer, query, abort, info)
|
|
3703
3704
|
const validOutboundTypes = new Set(["steer", "query", "abort", "info"]);
|
|
@@ -3716,6 +3717,11 @@ export default function (pi: ExtensionAPI) {
|
|
|
3716
3717
|
return "❌ No batch state found. There is no active or recent batch.";
|
|
3717
3718
|
}
|
|
3718
3719
|
|
|
3720
|
+
// Guard: terminal batches have no running agent sessions to receive messages.
|
|
3721
|
+
if (isBatchTerminal(state.phase)) {
|
|
3722
|
+
return `❌ Batch ${state.batchId} is in terminal phase (${state.phase}). Start or resume a batch before sending messages.`;
|
|
3723
|
+
}
|
|
3724
|
+
|
|
3719
3725
|
// Build the set of valid agent session names from batch state
|
|
3720
3726
|
const validSessions = new Set<string>();
|
|
3721
3727
|
const orchConfig = execCtx?.orchestratorConfig;
|
|
@@ -3736,6 +3742,13 @@ export default function (pi: ExtensionAPI) {
|
|
|
3736
3742
|
return `❌ Unknown session "${to}" in batch ${state.batchId}.\nValid targets: ${examples}${validSessions.size > 5 ? ` (${validSessions.size} total)` : ""}`;
|
|
3737
3743
|
}
|
|
3738
3744
|
|
|
3745
|
+
// Guard: ensure the target tmux session is currently alive.
|
|
3746
|
+
// Prevents false-positive "message sent" confirmations when the
|
|
3747
|
+
// batch is paused/stopped or the agent session has already exited.
|
|
3748
|
+
if (!tmuxHasSession(to)) {
|
|
3749
|
+
return `❌ Session "${to}" is not currently running. Use orch_status() or orch_resume() before sending messages.`;
|
|
3750
|
+
}
|
|
3751
|
+
|
|
3739
3752
|
// Write message to inbox
|
|
3740
3753
|
try {
|
|
3741
3754
|
const msg = writeMailboxMessage(stateRoot, state.batchId, to, {
|
|
@@ -3753,6 +3766,484 @@ export default function (pi: ExtensionAPI) {
|
|
|
3753
3766
|
}
|
|
3754
3767
|
}
|
|
3755
3768
|
|
|
3769
|
+
function resolveToolStateRoot(context: ExtensionContext): string {
|
|
3770
|
+
return execCtx?.workspaceRoot ?? execCtx?.repoRoot ?? context.cwd;
|
|
3771
|
+
}
|
|
3772
|
+
|
|
3773
|
+
function resolveLaneRepoRootForTools(laneRec: PersistedBatchState["lanes"][number], stateRoot: string): string {
|
|
3774
|
+
if (execCtx?.workspaceConfig && laneRec.repoId) {
|
|
3775
|
+
const repo = execCtx.workspaceConfig.repos[laneRec.repoId];
|
|
3776
|
+
if (repo?.path) return repo.path;
|
|
3777
|
+
}
|
|
3778
|
+
return execCtx?.repoRoot ?? stateRoot;
|
|
3779
|
+
}
|
|
3780
|
+
|
|
3781
|
+
// ── TP-096: Supervisor Recovery Tools ─────────────────────────────────
|
|
3782
|
+
|
|
3783
|
+
pi.registerTool({
|
|
3784
|
+
name: "read_agent_status",
|
|
3785
|
+
label: "Read Agent Status",
|
|
3786
|
+
description:
|
|
3787
|
+
"Read STATUS.md and telemetry for a running agent's lane. " +
|
|
3788
|
+
"Returns current step, checkbox progress, context %, cost, tool count, and elapsed time. " +
|
|
3789
|
+
"If lane is omitted, returns status for all active lanes.",
|
|
3790
|
+
promptSnippet: "read_agent_status(lane?) — read STATUS.md + context % + cost from a running agent",
|
|
3791
|
+
promptGuidelines: [
|
|
3792
|
+
"Call read_agent_status to check on a specific lane's worker progress.",
|
|
3793
|
+
"Omit lane to get a summary of all active lanes.",
|
|
3794
|
+
"Returns: current step, checked/total items, context %, cost, elapsed.",
|
|
3795
|
+
],
|
|
3796
|
+
parameters: Type.Object({
|
|
3797
|
+
lane: Type.Optional(Type.Number({
|
|
3798
|
+
description: "Lane number to check (omit for all lanes)",
|
|
3799
|
+
})),
|
|
3800
|
+
}),
|
|
3801
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
3802
|
+
try {
|
|
3803
|
+
const result = doReadAgentStatus(params.lane, ctx);
|
|
3804
|
+
return { content: [{ type: "text" as const, text: result }], details: undefined };
|
|
3805
|
+
} catch (err) {
|
|
3806
|
+
return {
|
|
3807
|
+
content: [{ type: "text" as const, text: `Error reading agent status: ${err instanceof Error ? err.message : String(err)}` }],
|
|
3808
|
+
details: undefined,
|
|
3809
|
+
};
|
|
3810
|
+
}
|
|
3811
|
+
},
|
|
3812
|
+
});
|
|
3813
|
+
|
|
3814
|
+
/**
|
|
3815
|
+
* Read agent status from STATUS.md and lane-state sidecar.
|
|
3816
|
+
* @since TP-096
|
|
3817
|
+
*/
|
|
3818
|
+
function doReadAgentStatus(lane: number | undefined, ctx: ExtensionContext): string {
|
|
3819
|
+
const stateRoot = resolveToolStateRoot(ctx);
|
|
3820
|
+
|
|
3821
|
+
// Load batch state
|
|
3822
|
+
const state = loadBatchState(stateRoot);
|
|
3823
|
+
if (!state) return "❌ No batch state found.";
|
|
3824
|
+
|
|
3825
|
+
const targetLanes = lane != null
|
|
3826
|
+
? state.lanes.filter(l => l.laneNumber === lane)
|
|
3827
|
+
: state.lanes;
|
|
3828
|
+
|
|
3829
|
+
if (targetLanes.length === 0) {
|
|
3830
|
+
return lane != null
|
|
3831
|
+
? `❌ Lane ${lane} not found in batch ${state.batchId}.`
|
|
3832
|
+
: "❌ No lanes in current batch.";
|
|
3833
|
+
}
|
|
3834
|
+
|
|
3835
|
+
const lines: string[] = [];
|
|
3836
|
+
lines.push(`📊 **Agent Status** — batch ${state.batchId}\n`);
|
|
3837
|
+
|
|
3838
|
+
for (const laneRec of targetLanes) {
|
|
3839
|
+
// Find current task for this lane
|
|
3840
|
+
const laneTasks = state.tasks.filter(t => t.laneNumber === laneRec.laneNumber);
|
|
3841
|
+
const runningTask = laneTasks.find(t => t.status === "running");
|
|
3842
|
+
const currentTask = runningTask || laneTasks[laneTasks.length - 1];
|
|
3843
|
+
|
|
3844
|
+
lines.push(`### Lane ${laneRec.laneNumber} — ${laneRec.tmuxSessionName}`);
|
|
3845
|
+
lines.push(`**Branch:** ${laneRec.branch}`);
|
|
3846
|
+
|
|
3847
|
+
if (currentTask) {
|
|
3848
|
+
lines.push(`**Task:** ${currentTask.taskId} (${currentTask.status})`);
|
|
3849
|
+
|
|
3850
|
+
// Read STATUS.md from canonical task paths (workspace-safe, cross-repo-safe)
|
|
3851
|
+
try {
|
|
3852
|
+
const taskFolderAbs = currentTask.taskFolder;
|
|
3853
|
+
const worktreePath = laneRec.worktreePath;
|
|
3854
|
+
if (taskFolderAbs && worktreePath) {
|
|
3855
|
+
const repoRootForLane = resolveLaneRepoRootForTools(laneRec, stateRoot);
|
|
3856
|
+
const resolved = resolveCanonicalTaskPaths(
|
|
3857
|
+
taskFolderAbs,
|
|
3858
|
+
worktreePath,
|
|
3859
|
+
repoRootForLane,
|
|
3860
|
+
!!execCtx?.workspaceConfig,
|
|
3861
|
+
);
|
|
3862
|
+
if (existsSync(resolved.statusPath)) {
|
|
3863
|
+
const content = readFileSync(resolved.statusPath, "utf-8");
|
|
3864
|
+
const stepMatch = content.match(/\*\*Current Step:\*\*\s*(.+)/);
|
|
3865
|
+
const statusMatch = content.match(/\*\*Status:\*\*\s*(.+)/);
|
|
3866
|
+
const iterMatch = content.match(/\*\*Iteration:\*\*\s*(\d+)/);
|
|
3867
|
+
const reviewMatch = content.match(/\*\*Review Counter:\*\*\s*(\d+)/);
|
|
3868
|
+
const checked = (content.match(/- \[x\]/gi) || []).length;
|
|
3869
|
+
const unchecked = (content.match(/- \[ \]/g) || []).length;
|
|
3870
|
+
const total = checked + unchecked;
|
|
3871
|
+
|
|
3872
|
+
if (stepMatch) lines.push(`**Step:** ${stepMatch[1].trim()}`);
|
|
3873
|
+
if (statusMatch) lines.push(`**Step Status:** ${statusMatch[1].trim()}`);
|
|
3874
|
+
if (total > 0) lines.push(`**Progress:** ${checked}/${total} (${Math.round((checked / total) * 100)}%)`);
|
|
3875
|
+
if (iterMatch) lines.push(`**Iteration:** ${iterMatch[1]}`);
|
|
3876
|
+
if (reviewMatch && Number.parseInt(reviewMatch[1], 10) > 0) lines.push(`**Reviews:** ${reviewMatch[1]}`);
|
|
3877
|
+
}
|
|
3878
|
+
}
|
|
3879
|
+
} catch {
|
|
3880
|
+
// STATUS.md not available in worktree — degrade gracefully
|
|
3881
|
+
}
|
|
3882
|
+
} else {
|
|
3883
|
+
lines.push("**Task:** none assigned");
|
|
3884
|
+
}
|
|
3885
|
+
|
|
3886
|
+
// Read lane-state sidecar
|
|
3887
|
+
try {
|
|
3888
|
+
const lsPath = join(stateRoot, ".pi", `lane-state-${laneRec.tmuxSessionName}.json`);
|
|
3889
|
+
if (existsSync(lsPath)) {
|
|
3890
|
+
const ls = JSON.parse(readFileSync(lsPath, "utf-8"));
|
|
3891
|
+
const parts: string[] = [];
|
|
3892
|
+
if (ls.workerContextPct) parts.push(`context: ${Math.round(ls.workerContextPct)}%`);
|
|
3893
|
+
if (ls.workerCostUsd) parts.push(`cost: $${ls.workerCostUsd.toFixed(3)}`);
|
|
3894
|
+
if (ls.workerToolCount) parts.push(`tools: ${ls.workerToolCount}`);
|
|
3895
|
+
if (ls.workerElapsed) parts.push(`elapsed: ${Math.round(ls.workerElapsed / 1000)}s`);
|
|
3896
|
+
if (ls.workerStatus) parts.push(`worker: ${ls.workerStatus}`);
|
|
3897
|
+
if (ls.reviewerStatus && ls.reviewerStatus !== "idle") parts.push(`reviewer: ${ls.reviewerStatus}`);
|
|
3898
|
+
if (parts.length > 0) lines.push(`**Telemetry:** ${parts.join(" · ")}`);
|
|
3899
|
+
}
|
|
3900
|
+
} catch {
|
|
3901
|
+
// Lane state not available — degrade gracefully
|
|
3902
|
+
}
|
|
3903
|
+
|
|
3904
|
+
lines.push("");
|
|
3905
|
+
}
|
|
3906
|
+
|
|
3907
|
+
return lines.join("\n");
|
|
3908
|
+
}
|
|
3909
|
+
|
|
3910
|
+
pi.registerTool({
|
|
3911
|
+
name: "trigger_wrap_up",
|
|
3912
|
+
label: "Trigger Wrap Up",
|
|
3913
|
+
description:
|
|
3914
|
+
"Write the .task-wrap-up signal file for a specific lane, telling the worker to finish its current step and exit gracefully.",
|
|
3915
|
+
promptSnippet: "trigger_wrap_up(lane) — write .task-wrap-up signal file for a lane",
|
|
3916
|
+
promptGuidelines: [
|
|
3917
|
+
"Call trigger_wrap_up to gracefully stop a worker on a specific lane.",
|
|
3918
|
+
"The worker will finish its current step and exit.",
|
|
3919
|
+
"Validates the lane exists and has a running worker.",
|
|
3920
|
+
],
|
|
3921
|
+
parameters: Type.Object({
|
|
3922
|
+
lane: Type.Number({
|
|
3923
|
+
description: "Lane number to send wrap-up signal to",
|
|
3924
|
+
}),
|
|
3925
|
+
}),
|
|
3926
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
3927
|
+
try {
|
|
3928
|
+
const result = doTriggerWrapUp(params.lane, ctx);
|
|
3929
|
+
return { content: [{ type: "text" as const, text: result }], details: undefined };
|
|
3930
|
+
} catch (err) {
|
|
3931
|
+
return {
|
|
3932
|
+
content: [{ type: "text" as const, text: `Error triggering wrap-up: ${err instanceof Error ? err.message : String(err)}` }],
|
|
3933
|
+
details: undefined,
|
|
3934
|
+
};
|
|
3935
|
+
}
|
|
3936
|
+
},
|
|
3937
|
+
});
|
|
3938
|
+
|
|
3939
|
+
/**
|
|
3940
|
+
* Write .task-wrap-up signal file for a lane's current task.
|
|
3941
|
+
* @since TP-096
|
|
3942
|
+
*/
|
|
3943
|
+
function doTriggerWrapUp(lane: number, ctx: ExtensionContext): string {
|
|
3944
|
+
const stateRoot = resolveToolStateRoot(ctx);
|
|
3945
|
+
|
|
3946
|
+
const state = loadBatchState(stateRoot);
|
|
3947
|
+
if (!state) return "❌ No batch state found.";
|
|
3948
|
+
|
|
3949
|
+
const laneRec = state.lanes.find(l => l.laneNumber === lane);
|
|
3950
|
+
if (!laneRec) return `❌ Lane ${lane} not found in batch ${state.batchId}.`;
|
|
3951
|
+
|
|
3952
|
+
// Find running task for this lane
|
|
3953
|
+
const runningTask = state.tasks.find(t => t.laneNumber === lane && t.status === "running");
|
|
3954
|
+
if (!runningTask) return `❌ No running task on lane ${lane}.`;
|
|
3955
|
+
|
|
3956
|
+
// Resolve task folder in the worktree using canonical path resolver
|
|
3957
|
+
const taskFolderAbs = runningTask.taskFolder;
|
|
3958
|
+
const worktreePath = laneRec.worktreePath;
|
|
3959
|
+
if (!taskFolderAbs || !worktreePath) {
|
|
3960
|
+
return `❌ Cannot resolve task folder for lane ${lane}.`;
|
|
3961
|
+
}
|
|
3962
|
+
|
|
3963
|
+
const repoRootForLane = resolveLaneRepoRootForTools(laneRec, stateRoot);
|
|
3964
|
+
const resolved = resolveCanonicalTaskPaths(
|
|
3965
|
+
taskFolderAbs,
|
|
3966
|
+
worktreePath,
|
|
3967
|
+
repoRootForLane,
|
|
3968
|
+
!!execCtx?.workspaceConfig,
|
|
3969
|
+
);
|
|
3970
|
+
const wrapUpPath = join(resolved.taskFolderResolved, ".task-wrap-up");
|
|
3971
|
+
|
|
3972
|
+
try {
|
|
3973
|
+
const dir = dirname(wrapUpPath);
|
|
3974
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
3975
|
+
writeFileSync(wrapUpPath, `wrap-up signal for ${runningTask.taskId}\n`, "utf-8");
|
|
3976
|
+
return `✅ Wrap-up signal written for **${runningTask.taskId}** on lane ${lane}.\n` +
|
|
3977
|
+
`Path: \`${wrapUpPath}\`\n` +
|
|
3978
|
+
`The worker will finish its current step and exit gracefully.`;
|
|
3979
|
+
} catch (err) {
|
|
3980
|
+
return `❌ Failed to write wrap-up file: ${err instanceof Error ? err.message : String(err)}`;
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
|
|
3984
|
+
pi.registerTool({
|
|
3985
|
+
name: "read_lane_logs",
|
|
3986
|
+
label: "Read Lane Logs",
|
|
3987
|
+
description:
|
|
3988
|
+
"Read stderr/crash logs for a specific lane from .pi/telemetry/ directory.",
|
|
3989
|
+
promptSnippet: "read_lane_logs(lane) — read stderr/crash logs for a lane",
|
|
3990
|
+
promptGuidelines: [
|
|
3991
|
+
"Call read_lane_logs to read crash/error logs from a lane's stderr capture.",
|
|
3992
|
+
"Falls back gracefully when logs don't exist (older batches).",
|
|
3993
|
+
],
|
|
3994
|
+
parameters: Type.Object({
|
|
3995
|
+
lane: Type.Number({
|
|
3996
|
+
description: "Lane number to read logs for",
|
|
3997
|
+
}),
|
|
3998
|
+
}),
|
|
3999
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
4000
|
+
try {
|
|
4001
|
+
const result = doReadLaneLogs(params.lane, ctx);
|
|
4002
|
+
return { content: [{ type: "text" as const, text: result }], details: undefined };
|
|
4003
|
+
} catch (err) {
|
|
4004
|
+
return {
|
|
4005
|
+
content: [{ type: "text" as const, text: `Error reading lane logs: ${err instanceof Error ? err.message : String(err)}` }],
|
|
4006
|
+
details: undefined,
|
|
4007
|
+
};
|
|
4008
|
+
}
|
|
4009
|
+
},
|
|
4010
|
+
});
|
|
4011
|
+
|
|
4012
|
+
/**
|
|
4013
|
+
* Read stderr/crash logs for a lane.
|
|
4014
|
+
* @since TP-096
|
|
4015
|
+
*/
|
|
4016
|
+
function doReadLaneLogs(lane: number, ctx: ExtensionContext): string {
|
|
4017
|
+
const stateRoot = resolveToolStateRoot(ctx);
|
|
4018
|
+
|
|
4019
|
+
const state = loadBatchState(stateRoot);
|
|
4020
|
+
if (!state) return "❌ No batch state found.";
|
|
4021
|
+
|
|
4022
|
+
const laneRec = state.lanes.find(l => l.laneNumber === lane);
|
|
4023
|
+
if (!laneRec) return `❌ Lane ${lane} not found in batch ${state.batchId}.`;
|
|
4024
|
+
|
|
4025
|
+
const telemetryDir = join(stateRoot, ".pi", "telemetry");
|
|
4026
|
+
let stderrFile: string | null = null;
|
|
4027
|
+
|
|
4028
|
+
// Discover stderr logs by actual telemetry naming:
|
|
4029
|
+
// {opId}-{batchId}-{repoId}[-{taskId}]-lane-{N}-worker-stderr.log
|
|
4030
|
+
try {
|
|
4031
|
+
if (existsSync(telemetryDir)) {
|
|
4032
|
+
const allStderr = readdirSync(telemetryDir)
|
|
4033
|
+
.filter(f => f.endsWith("-stderr.log"))
|
|
4034
|
+
.filter(f => f.includes(`-lane-${lane}-worker`));
|
|
4035
|
+
const batchScoped = allStderr.filter(f => f.includes(`-${state.batchId}-`));
|
|
4036
|
+
const candidates = (batchScoped.length > 0 ? batchScoped : allStderr)
|
|
4037
|
+
.map(name => {
|
|
4038
|
+
const absPath = join(telemetryDir, name);
|
|
4039
|
+
let mtime = 0;
|
|
4040
|
+
try { mtime = statSync(absPath).mtimeMs; } catch {}
|
|
4041
|
+
return { name, mtime };
|
|
4042
|
+
})
|
|
4043
|
+
.sort((a, b) => b.mtime - a.mtime);
|
|
4044
|
+
stderrFile = candidates[0]?.name ?? null;
|
|
4045
|
+
}
|
|
4046
|
+
} catch {
|
|
4047
|
+
// Directory not readable — handled below
|
|
4048
|
+
}
|
|
4049
|
+
|
|
4050
|
+
// Legacy fallback from older conventions
|
|
4051
|
+
if (!stderrFile) {
|
|
4052
|
+
const legacy = `${state.batchId}-lane-${lane}-stderr.log`;
|
|
4053
|
+
if (existsSync(join(telemetryDir, legacy))) {
|
|
4054
|
+
stderrFile = legacy;
|
|
4055
|
+
}
|
|
4056
|
+
}
|
|
4057
|
+
|
|
4058
|
+
const stderrPath = stderrFile ? join(telemetryDir, stderrFile) : null;
|
|
4059
|
+
|
|
4060
|
+
// Also try to find worker-exit JSON files for crash diagnostics
|
|
4061
|
+
const exitFiles: string[] = [];
|
|
4062
|
+
try {
|
|
4063
|
+
if (existsSync(telemetryDir)) {
|
|
4064
|
+
const files = readdirSync(telemetryDir)
|
|
4065
|
+
.filter(f => f.endsWith("-worker-exit.json"))
|
|
4066
|
+
.filter(f => f.includes(`-lane-${lane}-`));
|
|
4067
|
+
const batchScoped = files.filter(f => f.includes(`-${state.batchId}-`));
|
|
4068
|
+
exitFiles.push(...(batchScoped.length > 0 ? batchScoped : files));
|
|
4069
|
+
}
|
|
4070
|
+
} catch { /* directory not readable */ }
|
|
4071
|
+
|
|
4072
|
+
const lines: string[] = [];
|
|
4073
|
+
lines.push(`📜 **Lane ${lane} Logs** — batch ${state.batchId}\n`);
|
|
4074
|
+
|
|
4075
|
+
// Read stderr log
|
|
4076
|
+
if (stderrPath && existsSync(stderrPath)) {
|
|
4077
|
+
try {
|
|
4078
|
+
const content = readFileSync(stderrPath, "utf-8");
|
|
4079
|
+
const truncated = content.length > 5000
|
|
4080
|
+
? "...\n" + content.slice(-5000)
|
|
4081
|
+
: content;
|
|
4082
|
+
lines.push("### Stderr Log");
|
|
4083
|
+
lines.push("```");
|
|
4084
|
+
lines.push(truncated.trim());
|
|
4085
|
+
lines.push("```");
|
|
4086
|
+
lines.push("");
|
|
4087
|
+
} catch {
|
|
4088
|
+
lines.push("Stderr log found but unreadable.");
|
|
4089
|
+
}
|
|
4090
|
+
} else {
|
|
4091
|
+
lines.push(`No stderr log found for lane ${lane} (pattern: \`*-lane-${lane}-worker-stderr.log\`).`);
|
|
4092
|
+
}
|
|
4093
|
+
|
|
4094
|
+
// Read most recent exit diagnostic
|
|
4095
|
+
if (exitFiles.length > 0) {
|
|
4096
|
+
const latestExit = exitFiles
|
|
4097
|
+
.map(name => {
|
|
4098
|
+
const absPath = join(telemetryDir, name);
|
|
4099
|
+
let mtime = 0;
|
|
4100
|
+
try { mtime = statSync(absPath).mtimeMs; } catch {}
|
|
4101
|
+
return { name, mtime };
|
|
4102
|
+
})
|
|
4103
|
+
.sort((a, b) => b.mtime - a.mtime)[0]?.name;
|
|
4104
|
+
if (latestExit) {
|
|
4105
|
+
try {
|
|
4106
|
+
const exitData = JSON.parse(readFileSync(join(telemetryDir, latestExit), "utf-8"));
|
|
4107
|
+
lines.push("### Latest Exit Diagnostic");
|
|
4108
|
+
if (exitData.classification) lines.push(`**Classification:** ${exitData.classification}`);
|
|
4109
|
+
if (exitData.exitCode != null) lines.push(`**Exit Code:** ${exitData.exitCode}`);
|
|
4110
|
+
if (exitData.errorMessage) lines.push(`**Error:** ${exitData.errorMessage}`);
|
|
4111
|
+
if (exitData.durationSec) lines.push(`**Duration:** ${exitData.durationSec}s`);
|
|
4112
|
+
lines.push("");
|
|
4113
|
+
} catch { /* skip malformed exit file */ }
|
|
4114
|
+
}
|
|
4115
|
+
}
|
|
4116
|
+
|
|
4117
|
+
return lines.join("\n");
|
|
4118
|
+
}
|
|
4119
|
+
|
|
4120
|
+
pi.registerTool({
|
|
4121
|
+
name: "list_active_agents",
|
|
4122
|
+
label: "List Active Agents",
|
|
4123
|
+
description:
|
|
4124
|
+
"List all tmux sessions with their role, lane, task, context %, and elapsed time.",
|
|
4125
|
+
promptSnippet: "list_active_agents() — show all tmux sessions with role, lane, task, context %, elapsed",
|
|
4126
|
+
promptGuidelines: [
|
|
4127
|
+
"Call list_active_agents to see all running agent sessions.",
|
|
4128
|
+
"Shows: session name, role (worker/reviewer/merger/supervisor), lane, task, context %, elapsed.",
|
|
4129
|
+
],
|
|
4130
|
+
parameters: Type.Object({}),
|
|
4131
|
+
async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
|
|
4132
|
+
try {
|
|
4133
|
+
const result = doListActiveAgents(ctx);
|
|
4134
|
+
return { content: [{ type: "text" as const, text: result }], details: undefined };
|
|
4135
|
+
} catch (err) {
|
|
4136
|
+
return {
|
|
4137
|
+
content: [{ type: "text" as const, text: `Error listing agents: ${err instanceof Error ? err.message : String(err)}` }],
|
|
4138
|
+
details: undefined,
|
|
4139
|
+
};
|
|
4140
|
+
}
|
|
4141
|
+
},
|
|
4142
|
+
});
|
|
4143
|
+
|
|
4144
|
+
/**
|
|
4145
|
+
* List all active tmux sessions with agent metadata.
|
|
4146
|
+
* @since TP-096
|
|
4147
|
+
*/
|
|
4148
|
+
function doListActiveAgents(ctx: ExtensionContext): string {
|
|
4149
|
+
const stateRoot = resolveToolStateRoot(ctx);
|
|
4150
|
+
|
|
4151
|
+
// Get tmux sessions
|
|
4152
|
+
let sessions: string[] = [];
|
|
4153
|
+
try {
|
|
4154
|
+
const output = execSync('tmux list-sessions -F "#{session_name}"', {
|
|
4155
|
+
encoding: "utf-8",
|
|
4156
|
+
timeout: 5000,
|
|
4157
|
+
stdio: ["ignore", "pipe", "ignore"],
|
|
4158
|
+
}).trim();
|
|
4159
|
+
sessions = output ? output.split("\n").map(s => s.trim()).filter(Boolean) : [];
|
|
4160
|
+
} catch {
|
|
4161
|
+
return "❌ tmux not available or no sessions running.";
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
if (sessions.length === 0) return "❌ No tmux sessions found.";
|
|
4165
|
+
|
|
4166
|
+
// Load batch state for task/lane mapping
|
|
4167
|
+
const state = loadBatchState(stateRoot);
|
|
4168
|
+
|
|
4169
|
+
// Build a map of session name → lane-state data
|
|
4170
|
+
const laneStates: Record<string, any> = {};
|
|
4171
|
+
try {
|
|
4172
|
+
const piDir = join(stateRoot, ".pi");
|
|
4173
|
+
if (existsSync(piDir)) {
|
|
4174
|
+
const files = readdirSync(piDir).filter(f => f.startsWith("lane-state-") && f.endsWith(".json"));
|
|
4175
|
+
for (const file of files) {
|
|
4176
|
+
try {
|
|
4177
|
+
const data = JSON.parse(readFileSync(join(piDir, file), "utf-8"));
|
|
4178
|
+
if (data.prefix) laneStates[data.prefix] = data;
|
|
4179
|
+
} catch { continue; }
|
|
4180
|
+
}
|
|
4181
|
+
}
|
|
4182
|
+
} catch { /* .pi dir missing */ }
|
|
4183
|
+
|
|
4184
|
+
const lines: string[] = [];
|
|
4185
|
+
lines.push(`👥 **Active Agents** (${sessions.length} sessions)\n`);
|
|
4186
|
+
|
|
4187
|
+
// Parse each session name to extract role, lane, etc.
|
|
4188
|
+
for (const sess of sessions) {
|
|
4189
|
+
let role = "unknown";
|
|
4190
|
+
let laneNum = "";
|
|
4191
|
+
let taskId = "";
|
|
4192
|
+
let contextPct = "";
|
|
4193
|
+
let elapsed = "";
|
|
4194
|
+
let costStr = "";
|
|
4195
|
+
|
|
4196
|
+
// Parse session name pattern:
|
|
4197
|
+
// Workers/reviewers: orch-{opId}-lane-{N} (or -worker/-reviewer suffix)
|
|
4198
|
+
// Mergers: orch-{opId}-merge-{N}
|
|
4199
|
+
// Supervisor: pi-supervisor-{...}
|
|
4200
|
+
const laneMatch = sess.match(/-lane-(\d+)/);
|
|
4201
|
+
const mergeMatch = sess.match(/-merge-(\d+)/);
|
|
4202
|
+
|
|
4203
|
+
if (mergeMatch) {
|
|
4204
|
+
role = "merger";
|
|
4205
|
+
laneNum = mergeMatch[1];
|
|
4206
|
+
} else if (laneMatch) {
|
|
4207
|
+
if (sess.includes("-reviewer")) {
|
|
4208
|
+
role = "reviewer";
|
|
4209
|
+
} else {
|
|
4210
|
+
role = "worker";
|
|
4211
|
+
}
|
|
4212
|
+
laneNum = laneMatch[1];
|
|
4213
|
+
} else if (sess.includes("supervisor")) {
|
|
4214
|
+
role = "supervisor";
|
|
4215
|
+
}
|
|
4216
|
+
|
|
4217
|
+
// Find matching task and lane-state
|
|
4218
|
+
if (state && laneNum) {
|
|
4219
|
+
const ln = parseInt(laneNum);
|
|
4220
|
+
const task = state.tasks.find(t => t.laneNumber === ln && t.status === "running");
|
|
4221
|
+
if (task) taskId = task.taskId;
|
|
4222
|
+
|
|
4223
|
+
// Find lane-state prefix (may be the session name or a prefix of it)
|
|
4224
|
+
const laneRec = state.lanes.find(l => l.laneNumber === ln);
|
|
4225
|
+
const prefix = laneRec?.tmuxSessionName || sess;
|
|
4226
|
+
const ls = laneStates[prefix];
|
|
4227
|
+
if (ls) {
|
|
4228
|
+
if (ls.workerContextPct) contextPct = `${Math.round(ls.workerContextPct)}%`;
|
|
4229
|
+
if (ls.workerElapsed) elapsed = `${Math.round(ls.workerElapsed / 1000)}s`;
|
|
4230
|
+
if (ls.workerCostUsd) costStr = `$${ls.workerCostUsd.toFixed(3)}`;
|
|
4231
|
+
}
|
|
4232
|
+
}
|
|
4233
|
+
|
|
4234
|
+
const parts: string[] = [`**${sess}**`];
|
|
4235
|
+
parts.push(`role: ${role}`);
|
|
4236
|
+
if (laneNum) parts.push(`lane: ${laneNum}`);
|
|
4237
|
+
if (taskId) parts.push(`task: ${taskId}`);
|
|
4238
|
+
if (contextPct) parts.push(`ctx: ${contextPct}`);
|
|
4239
|
+
if (elapsed) parts.push(`elapsed: ${elapsed}`);
|
|
4240
|
+
if (costStr) parts.push(`cost: ${costStr}`);
|
|
4241
|
+
lines.push(`- ${parts.join(" · ")}`);
|
|
4242
|
+
}
|
|
4243
|
+
|
|
4244
|
+
return lines.join("\n");
|
|
4245
|
+
}
|
|
4246
|
+
|
|
3756
4247
|
// ── Settings TUI ─────────────────────────────────────────────────
|
|
3757
4248
|
|
|
3758
4249
|
pi.registerCommand("taskplane-settings", {
|
|
@@ -770,6 +770,12 @@ You have these orchestrator tools available:
|
|
|
770
770
|
**Note:** `orch_retry_task`, `orch_skip_task`, and `orch_force_merge` require the batch to be paused/stopped first.
|
|
771
771
|
If the batch is actively running, call `orch_pause()` first.
|
|
772
772
|
|
|
773
|
+
**Diagnostic & Recovery Tools (TP-096):**
|
|
774
|
+
- `read_agent_status(lane?)` — Read STATUS.md + telemetry for a lane (step, progress, context %, cost, elapsed). Omit lane for all lanes.
|
|
775
|
+
- `trigger_wrap_up(lane)` — Write `.task-wrap-up` signal to gracefully stop a worker on a lane.
|
|
776
|
+
- `read_lane_logs(lane)` — Read stderr/crash logs and exit diagnostics for a lane.
|
|
777
|
+
- `list_active_agents()` — List all tmux sessions with role, lane, task, context %, elapsed, cost.
|
|
778
|
+
|
|
773
779
|
Plus general tools: `read`, `write`, `edit`, `bash`, `grep`, `find`, `ls`
|
|
774
780
|
for inspecting files, running git commands, and editing batch state.
|
|
775
781
|
|