taskplane 0.22.18 → 0.23.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/dashboard/public/app.js +365 -7
- package/dashboard/public/index.html +16 -0
- package/dashboard/public/style.css +105 -0
- package/dashboard/server.cjs +199 -0
- package/extensions/task-runner.ts +40 -286
- package/extensions/taskplane/abort.ts +11 -1
- package/extensions/taskplane/agent-bridge-extension.ts +159 -0
- package/extensions/taskplane/agent-host.ts +686 -0
- package/extensions/taskplane/engine.ts +75 -3
- package/extensions/taskplane/execution.ts +403 -9
- package/extensions/taskplane/extension.ts +322 -28
- package/extensions/taskplane/lane-runner.ts +567 -0
- package/extensions/taskplane/mailbox.ts +349 -1
- package/extensions/taskplane/merge.ts +208 -51
- package/extensions/taskplane/process-registry.ts +345 -0
- package/extensions/taskplane/resume.ts +185 -47
- package/extensions/taskplane/supervisor.ts +16 -12
- package/extensions/taskplane/task-executor-core.ts +553 -0
- package/extensions/taskplane/types.ts +517 -1
- package/package.json +1 -1
- package/skills/create-taskplane-task/SKILL.md +41 -33
- package/skills/create-taskplane-task/references/prompt-template.md +3 -3
|
@@ -18,6 +18,9 @@ import { ORCH_MESSAGES } from "./messages.ts";
|
|
|
18
18
|
import { emitEngineEvent } from "./persistence.ts";
|
|
19
19
|
import { loadOrchestratorConfig } from "./config.ts";
|
|
20
20
|
import { captureBaseline, diffFingerprints, runVerificationCommands, parseTestOutput, deduplicateFingerprints } from "./verification.ts";
|
|
21
|
+
import { spawnAgent } from "./agent-host.ts";
|
|
22
|
+
import type { AgentHostOptions, AgentHostResult } from "./agent-host.ts";
|
|
23
|
+
import type { RuntimeBackend } from "./execution.ts";
|
|
21
24
|
import type { VerificationBaseline, FingerprintDiff, TestFingerprint } from "./verification.ts";
|
|
22
25
|
|
|
23
26
|
// ── Merge Telemetry Helpers ───────────────────────────────────────────
|
|
@@ -702,6 +705,151 @@ export async function spawnMergeAgent(
|
|
|
702
705
|
);
|
|
703
706
|
}
|
|
704
707
|
|
|
708
|
+
|
|
709
|
+
/**
|
|
710
|
+
* Spawn a merge agent via Runtime V2 direct agent-host (no TMUX).
|
|
711
|
+
*
|
|
712
|
+
* Per Runtime V2 spec (02-runtime-process-model.md §8.3):
|
|
713
|
+
* "engine spawns merge host directly" — the merge agent runs as a direct
|
|
714
|
+
* child process via agent-host, with process registry tracking, normalized
|
|
715
|
+
* events, and deterministic exit classification.
|
|
716
|
+
*
|
|
717
|
+
* The merge agent receives the merge request as its prompt and writes
|
|
718
|
+
* a result JSON file. The caller polls for that result file (same contract
|
|
719
|
+
* as the legacy TMUX path via waitForMergeResult).
|
|
720
|
+
*
|
|
721
|
+
* @param sessionName - Stable agent ID (e.g., "orch-merge-1")
|
|
722
|
+
* @param repoRoot - Main repository root (merge happens here)
|
|
723
|
+
* @param mergeWorkDir - Working directory for the merge
|
|
724
|
+
* @param mergeRequestPath - Path to the merge request file
|
|
725
|
+
* @param config - Orchestrator config
|
|
726
|
+
* @param stateRoot - Root for state files / registry
|
|
727
|
+
* @param agentRoot - Root for agent prompts
|
|
728
|
+
* @param batchId - Current batch ID
|
|
729
|
+
* @returns Promise that resolves when the agent exits
|
|
730
|
+
*
|
|
731
|
+
* @since TP-108
|
|
732
|
+
*/
|
|
733
|
+
export async function spawnMergeAgentV2(
|
|
734
|
+
sessionName: string,
|
|
735
|
+
repoRoot: string,
|
|
736
|
+
mergeWorkDir: string,
|
|
737
|
+
mergeRequestPath: string,
|
|
738
|
+
config: OrchestratorConfig,
|
|
739
|
+
stateRoot?: string,
|
|
740
|
+
agentRoot?: string,
|
|
741
|
+
batchId?: string,
|
|
742
|
+
): Promise<AgentHostResult> {
|
|
743
|
+
execLog("merge", sessionName, "spawning merge agent via Runtime V2 (direct agent-host)", {
|
|
744
|
+
mergeWorkDir,
|
|
745
|
+
mergeRequestPath,
|
|
746
|
+
});
|
|
747
|
+
|
|
748
|
+
// Read the merge request as the agent prompt
|
|
749
|
+
const prompt = readFileSync(mergeRequestPath, "utf-8");
|
|
750
|
+
|
|
751
|
+
// Resolve merger system prompt
|
|
752
|
+
const systemPromptCandidates = [
|
|
753
|
+
agentRoot ? join(agentRoot, "task-merger.md") : "",
|
|
754
|
+
join(stateRoot ?? repoRoot, ".pi", "agents", "task-merger.md"),
|
|
755
|
+
].filter(Boolean);
|
|
756
|
+
const systemPromptPath = systemPromptCandidates.find(p => existsSync(p)) || "";
|
|
757
|
+
let systemPrompt: string | undefined;
|
|
758
|
+
if (systemPromptPath) {
|
|
759
|
+
try { systemPrompt = readFileSync(systemPromptPath, "utf-8"); } catch { /* use default */ }
|
|
760
|
+
}
|
|
761
|
+
|
|
762
|
+
// Resolve event/exit paths
|
|
763
|
+
const sidecarRoot = join(stateRoot ?? repoRoot, ".pi");
|
|
764
|
+
const bid = batchId || "unknown";
|
|
765
|
+
const eventsPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "events.jsonl");
|
|
766
|
+
const exitSummaryPath = join(sidecarRoot, "runtime", bid, "agents", sessionName, "exit-summary.json");
|
|
767
|
+
|
|
768
|
+
// Mailbox directory
|
|
769
|
+
let mailboxDir: string | null = null;
|
|
770
|
+
if (batchId) {
|
|
771
|
+
mailboxDir = join(sidecarRoot, "mailbox", batchId, sessionName);
|
|
772
|
+
mkdirSync(join(mailboxDir, "inbox"), { recursive: true });
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
const opts: AgentHostOptions = {
|
|
776
|
+
agentId: sessionName,
|
|
777
|
+
role: "merger",
|
|
778
|
+
batchId: bid,
|
|
779
|
+
laneNumber: null,
|
|
780
|
+
taskId: null,
|
|
781
|
+
repoId: "default",
|
|
782
|
+
cwd: mergeWorkDir,
|
|
783
|
+
prompt,
|
|
784
|
+
systemPrompt,
|
|
785
|
+
model: config.merge.model || undefined,
|
|
786
|
+
tools: config.merge.tools || undefined,
|
|
787
|
+
mailboxDir,
|
|
788
|
+
eventsPath,
|
|
789
|
+
exitSummaryPath,
|
|
790
|
+
timeoutMs: (config.merge.timeout_minutes ?? 10) * 60 * 1000,
|
|
791
|
+
stateRoot: stateRoot ?? repoRoot,
|
|
792
|
+
packet: null,
|
|
793
|
+
env: { ORCH_BATCH_ID: bid },
|
|
794
|
+
};
|
|
795
|
+
|
|
796
|
+
const { promise, kill } = spawnAgent(opts);
|
|
797
|
+
|
|
798
|
+
// Store the kill handle for external cleanup (pause/abort).
|
|
799
|
+
// The promise runs in background — caller uses waitForMergeResult()
|
|
800
|
+
// to poll for the result file, same contract as the TMUX path.
|
|
801
|
+
activeMergeAgents.set(sessionName, { promise, kill });
|
|
802
|
+
|
|
803
|
+
// Fire-and-forget: the background promise handles exit logging
|
|
804
|
+
promise.then(result => {
|
|
805
|
+
activeMergeAgents.delete(sessionName);
|
|
806
|
+
execLog("merge", sessionName, "merge agent exited (V2)", {
|
|
807
|
+
exitCode: result.exitCode,
|
|
808
|
+
durationMs: result.durationMs,
|
|
809
|
+
costUsd: result.costUsd,
|
|
810
|
+
killed: result.killed,
|
|
811
|
+
});
|
|
812
|
+
}).catch(err => {
|
|
813
|
+
activeMergeAgents.delete(sessionName);
|
|
814
|
+
execLog("merge", sessionName, `merge agent error (V2): ${err instanceof Error ? err.message : String(err)}`);
|
|
815
|
+
});
|
|
816
|
+
}
|
|
817
|
+
|
|
818
|
+
/** Active V2 merge agent handles for cleanup/abort. @since TP-108 */
|
|
819
|
+
const activeMergeAgents = new Map<string, { promise: Promise<AgentHostResult>; kill: () => void }>();
|
|
820
|
+
|
|
821
|
+
/**
|
|
822
|
+
* Kill a V2 merge agent if it's still running.
|
|
823
|
+
* Used by pause/abort/cleanup flows.
|
|
824
|
+
* @since TP-108
|
|
825
|
+
*/
|
|
826
|
+
export function killMergeAgentV2(sessionName: string): boolean {
|
|
827
|
+
const handle = activeMergeAgents.get(sessionName);
|
|
828
|
+
if (handle) {
|
|
829
|
+
handle.kill();
|
|
830
|
+
activeMergeAgents.delete(sessionName);
|
|
831
|
+
return true;
|
|
832
|
+
}
|
|
833
|
+
return false;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
/**
|
|
837
|
+
* Kill ALL active V2 merge agents. Used by abort flow to ensure
|
|
838
|
+
* no merge agents survive even when TMUX session list is empty.
|
|
839
|
+
* @returns Number of agents killed
|
|
840
|
+
* @since TP-108
|
|
841
|
+
*/
|
|
842
|
+
export function killAllMergeAgentsV2(): number {
|
|
843
|
+
let killed = 0;
|
|
844
|
+
for (const [name, handle] of activeMergeAgents) {
|
|
845
|
+
handle.kill();
|
|
846
|
+
execLog("merge", name, "V2 merge agent killed by bulk abort");
|
|
847
|
+
killed++;
|
|
848
|
+
}
|
|
849
|
+
activeMergeAgents.clear();
|
|
850
|
+
return killed;
|
|
851
|
+
}
|
|
852
|
+
|
|
705
853
|
/**
|
|
706
854
|
* Re-read merge timeout from config on disk.
|
|
707
855
|
*
|
|
@@ -751,13 +899,16 @@ export async function waitForMergeResult(
|
|
|
751
899
|
resultPath: string,
|
|
752
900
|
sessionName: string,
|
|
753
901
|
timeoutMs: number = MERGE_TIMEOUT_MS,
|
|
902
|
+
runtimeBackend?: RuntimeBackend,
|
|
754
903
|
): Promise<MergeResult> {
|
|
755
904
|
const startTime = Date.now();
|
|
756
905
|
let sessionDiedAt: number | null = null;
|
|
906
|
+
const isV2 = runtimeBackend === "v2";
|
|
757
907
|
|
|
758
908
|
execLog("merge", sessionName, "waiting for merge result", {
|
|
759
909
|
resultPath,
|
|
760
910
|
timeoutMs,
|
|
911
|
+
backend: isV2 ? "v2" : "legacy",
|
|
761
912
|
});
|
|
762
913
|
|
|
763
914
|
while (true) {
|
|
@@ -766,8 +917,6 @@ export async function waitForMergeResult(
|
|
|
766
917
|
// Check timeout
|
|
767
918
|
if (elapsed >= timeoutMs) {
|
|
768
919
|
// TP-038: Check result file BEFORE killing the session.
|
|
769
|
-
// The merge may have actually succeeded — the verification tests
|
|
770
|
-
// just pushed past the timeout. Accept successful results without killing.
|
|
771
920
|
if (existsSync(resultPath)) {
|
|
772
921
|
try {
|
|
773
922
|
const lateResult = await parseMergeResultAsync(resultPath);
|
|
@@ -777,33 +926,33 @@ export async function waitForMergeResult(
|
|
|
777
926
|
elapsed,
|
|
778
927
|
timeoutMs,
|
|
779
928
|
});
|
|
780
|
-
// Clean up
|
|
781
|
-
if (
|
|
929
|
+
// Clean up agent (may still be running post-write)
|
|
930
|
+
if (isV2) {
|
|
931
|
+
killMergeAgentV2(sessionName);
|
|
932
|
+
} else if (await tmuxHasSessionAsync(sessionName)) {
|
|
782
933
|
await tmuxKillSessionAsync(sessionName);
|
|
783
934
|
}
|
|
784
935
|
return lateResult;
|
|
785
936
|
}
|
|
786
|
-
|
|
787
|
-
execLog("merge", sessionName, "merge result exists at timeout but non-success — killing session", {
|
|
937
|
+
execLog("merge", sessionName, "merge result exists at timeout but non-success — killing", {
|
|
788
938
|
status: lateResult.status,
|
|
789
|
-
elapsed,
|
|
790
|
-
timeoutMs,
|
|
791
939
|
});
|
|
792
940
|
} catch {
|
|
793
941
|
// Result file unreadable — fall through to kill
|
|
794
942
|
}
|
|
795
943
|
}
|
|
796
944
|
|
|
797
|
-
execLog("merge", sessionName, "merge timeout — killing
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
}
|
|
801
|
-
|
|
945
|
+
execLog("merge", sessionName, "merge timeout — killing agent", { elapsed, timeoutMs });
|
|
946
|
+
if (isV2) {
|
|
947
|
+
killMergeAgentV2(sessionName);
|
|
948
|
+
} else {
|
|
949
|
+
await tmuxKillSessionAsync(sessionName);
|
|
950
|
+
}
|
|
802
951
|
|
|
803
952
|
throw new MergeError(
|
|
804
953
|
"MERGE_TIMEOUT",
|
|
805
954
|
`Merge agent '${sessionName}' did not produce a result within ` +
|
|
806
|
-
`${Math.round(timeoutMs / 1000)}s. The
|
|
955
|
+
`${Math.round(timeoutMs / 1000)}s. The agent has been killed. ` +
|
|
807
956
|
`Check the merge request and agent logs.`,
|
|
808
957
|
);
|
|
809
958
|
}
|
|
@@ -816,62 +965,54 @@ export async function waitForMergeResult(
|
|
|
816
965
|
status: result.status,
|
|
817
966
|
elapsed,
|
|
818
967
|
});
|
|
819
|
-
//
|
|
820
|
-
if (
|
|
968
|
+
// Clean up agent if still alive
|
|
969
|
+
if (isV2) {
|
|
970
|
+
killMergeAgentV2(sessionName);
|
|
971
|
+
} else if (await tmuxHasSessionAsync(sessionName)) {
|
|
821
972
|
await tmuxKillSessionAsync(sessionName);
|
|
822
973
|
}
|
|
823
974
|
return result;
|
|
824
975
|
} catch (err: unknown) {
|
|
825
|
-
// File exists but invalid — might be partially written.
|
|
826
|
-
// parseMergeResultAsync already retries, so if it throws, it's final.
|
|
827
976
|
if (err instanceof MergeError && err.code === "MERGE_RESULT_INVALID") {
|
|
828
|
-
// Wait a bit and try once more (file might still be in flight)
|
|
829
977
|
await sleepAsync(MERGE_RESULT_READ_RETRY_DELAY_MS);
|
|
830
978
|
if (existsSync(resultPath)) {
|
|
831
|
-
try {
|
|
832
|
-
return await parseMergeResultAsync(resultPath);
|
|
833
|
-
} catch {
|
|
834
|
-
// Give up on this file
|
|
835
|
-
}
|
|
979
|
+
try { return await parseMergeResultAsync(resultPath); } catch { /* give up */ }
|
|
836
980
|
}
|
|
837
981
|
}
|
|
838
|
-
// If still failing, continue polling (agent might rewrite)
|
|
839
982
|
}
|
|
840
983
|
}
|
|
841
984
|
|
|
842
|
-
// Check
|
|
843
|
-
|
|
985
|
+
// Check agent liveness — backend-aware
|
|
986
|
+
let agentAlive: boolean;
|
|
987
|
+
if (isV2) {
|
|
988
|
+
// V2: check activeMergeAgents map (process handle)
|
|
989
|
+
agentAlive = activeMergeAgents.has(sessionName);
|
|
990
|
+
} else {
|
|
991
|
+
// Legacy: check TMUX session
|
|
992
|
+
agentAlive = await tmuxHasSessionAsync(sessionName);
|
|
993
|
+
}
|
|
844
994
|
|
|
845
|
-
if (!
|
|
995
|
+
if (!agentAlive) {
|
|
846
996
|
if (sessionDiedAt === null) {
|
|
847
|
-
// First detection of session death — start grace period
|
|
848
997
|
sessionDiedAt = Date.now();
|
|
849
|
-
execLog("merge", sessionName, "
|
|
998
|
+
execLog("merge", sessionName, "agent exited — starting grace period", {
|
|
850
999
|
graceMs: MERGE_RESULT_GRACE_MS,
|
|
851
1000
|
});
|
|
852
1001
|
} else if (Date.now() - sessionDiedAt >= MERGE_RESULT_GRACE_MS) {
|
|
853
|
-
// Grace period expired —
|
|
854
|
-
// One final check
|
|
1002
|
+
// Grace period expired — one final check
|
|
855
1003
|
if (existsSync(resultPath)) {
|
|
856
|
-
try {
|
|
857
|
-
return await parseMergeResultAsync(resultPath);
|
|
858
|
-
} catch {
|
|
859
|
-
// Fall through to session died error
|
|
860
|
-
}
|
|
1004
|
+
try { return await parseMergeResultAsync(resultPath); } catch { /* fall through */ }
|
|
861
1005
|
}
|
|
862
1006
|
|
|
863
1007
|
throw new MergeError(
|
|
864
1008
|
"MERGE_SESSION_DIED",
|
|
865
|
-
`Merge agent
|
|
1009
|
+
`Merge agent '${sessionName}' exited without writing ` +
|
|
866
1010
|
`a result file to '${resultPath}'. The merge may have crashed. ` +
|
|
867
|
-
`Check
|
|
868
|
-
`after session exit.`,
|
|
1011
|
+
`Check agent logs for diagnostics.`,
|
|
869
1012
|
);
|
|
870
1013
|
}
|
|
871
|
-
// Within grace period — continue polling
|
|
872
1014
|
}
|
|
873
1015
|
|
|
874
|
-
// Poll interval
|
|
875
1016
|
await sleepAsync(MERGE_POLL_INTERVAL_MS);
|
|
876
1017
|
}
|
|
877
1018
|
}
|
|
@@ -1212,6 +1353,7 @@ export async function mergeWave(
|
|
|
1212
1353
|
repoId?: string,
|
|
1213
1354
|
healthMonitor?: MergeHealthMonitor | null,
|
|
1214
1355
|
forceMixedOutcome?: boolean,
|
|
1356
|
+
runtimeBackend?: RuntimeBackend,
|
|
1215
1357
|
): Promise<MergeWaveResult> {
|
|
1216
1358
|
const startTime = Date.now();
|
|
1217
1359
|
const tmuxPrefix = config.orchestrator.tmux_prefix;
|
|
@@ -1520,18 +1662,28 @@ export async function mergeWave(
|
|
|
1520
1662
|
}
|
|
1521
1663
|
|
|
1522
1664
|
// Re-spawn merge agent for the retry
|
|
1523
|
-
|
|
1524
|
-
|
|
1525
|
-
|
|
1665
|
+
// TP-108: Kill previous V2 agent to prevent orphan/duplicate
|
|
1666
|
+
if (runtimeBackend === "v2") {
|
|
1667
|
+
killMergeAgentV2(sessionName);
|
|
1668
|
+
await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1669
|
+
} else {
|
|
1670
|
+
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1671
|
+
}
|
|
1672
|
+
// TP-056: Re-register with health monitor after respawn (legacy only)
|
|
1673
|
+
if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1526
1674
|
} else {
|
|
1527
1675
|
// First attempt: spawn merge agent
|
|
1528
|
-
|
|
1529
|
-
|
|
1530
|
-
|
|
1676
|
+
if (runtimeBackend === "v2") {
|
|
1677
|
+
await spawnMergeAgentV2(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1678
|
+
} else {
|
|
1679
|
+
await spawnMergeAgent(sessionName, repoRoot, mergeWorkDir, requestFilePath, config, stateRoot, agentRoot, batchId);
|
|
1680
|
+
}
|
|
1681
|
+
// TP-056: Register session with health monitor (legacy only — V2 uses process handle)
|
|
1682
|
+
if (healthMonitor && runtimeBackend !== "v2") healthMonitor.addSession(sessionName, lane.laneNumber, resultFilePath);
|
|
1531
1683
|
}
|
|
1532
1684
|
|
|
1533
1685
|
try {
|
|
1534
|
-
mergeResult = await waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs);
|
|
1686
|
+
mergeResult = await waitForMergeResult(resultFilePath, sessionName, currentTimeoutMs, runtimeBackend);
|
|
1535
1687
|
// TP-056: Deregister session from health monitor on completion
|
|
1536
1688
|
if (healthMonitor) healthMonitor.removeSession(sessionName);
|
|
1537
1689
|
lastTimeoutError = null;
|
|
@@ -1790,8 +1942,10 @@ export async function mergeWave(
|
|
|
1790
1942
|
// Best effort
|
|
1791
1943
|
}
|
|
1792
1944
|
|
|
1793
|
-
// Kill merge
|
|
1794
|
-
if (
|
|
1945
|
+
// Kill merge agent if still alive (backend-aware)
|
|
1946
|
+
if (runtimeBackend === "v2") {
|
|
1947
|
+
killMergeAgentV2(sessionName);
|
|
1948
|
+
} else if (tmuxHasSession(sessionName)) {
|
|
1795
1949
|
tmuxKillSession(sessionName);
|
|
1796
1950
|
}
|
|
1797
1951
|
|
|
@@ -2229,6 +2383,7 @@ export async function mergeWaveByRepo(
|
|
|
2229
2383
|
testingCommands?: Record<string, string>,
|
|
2230
2384
|
healthMonitor?: MergeHealthMonitor | null,
|
|
2231
2385
|
forceMixedOutcome?: boolean,
|
|
2386
|
+
runtimeBackend?: RuntimeBackend,
|
|
2232
2387
|
): Promise<MergeWaveResult> {
|
|
2233
2388
|
const startTime = Date.now();
|
|
2234
2389
|
|
|
@@ -2289,6 +2444,7 @@ export async function mergeWaveByRepo(
|
|
|
2289
2444
|
undefined, // repoId
|
|
2290
2445
|
healthMonitor,
|
|
2291
2446
|
forceMixedOutcome,
|
|
2447
|
+
runtimeBackend,
|
|
2292
2448
|
);
|
|
2293
2449
|
// Attach empty repoResults for consistent shape
|
|
2294
2450
|
return { ...result, repoResults: [] };
|
|
@@ -2347,6 +2503,7 @@ export async function mergeWaveByRepo(
|
|
|
2347
2503
|
group.repoId,
|
|
2348
2504
|
healthMonitor,
|
|
2349
2505
|
forceMixedOutcome,
|
|
2506
|
+
runtimeBackend,
|
|
2350
2507
|
);
|
|
2351
2508
|
|
|
2352
2509
|
// Accumulate lane results
|