pi-orchestrator-config 4.3.11 → 4.3.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/README.md +66 -66
- package/extensions/coms/coms-p2p.ts +53 -16
- package/extensions/orchestrator/async-agents.ts +127 -42
- package/extensions/orchestrator/async-result-format.ts +58 -0
- package/extensions/orchestrator/async-runner.ts +20 -4
- package/extensions/orchestrator/cron-status-format.ts +6 -6
- package/extensions/orchestrator/cron-status-ui.ts +3 -3
- package/extensions/orchestrator/cron-store.ts +208 -0
- package/extensions/orchestrator/cron.ts +168 -699
- package/extensions/orchestrator/extended-autocomplete.ts +27 -27
- package/extensions/orchestrator/reviewer-output-archive.ts +69 -0
- package/extensions/orchestrator/status.ts +7 -2
- package/extensions/pidash/pidash-ui/dist/assets/{index-BoNVhD5K.js → index-Ehfwjr9d.js} +1 -1
- package/extensions/pidash/pidash-ui/dist/index.html +1 -1
- package/extensions/pidash/pidash-ui/src/components/InfoBar.tsx +3 -7
- package/extensions/pidash/pidash-ui/src/components/MessageList.tsx +0 -4
- package/extensions/pidash/pidash-ui/tsconfig.json +1 -0
- package/extensions/pidiff/pidiff-ui/package.json +1 -1
- package/extensions/pitasks/index.ts +2 -5
- package/extensions/pitasks/task-tools.ts +2 -2
- package/package.json +2 -1
- package/scripts/build-extension-uis.sh +6 -6
|
@@ -13,8 +13,7 @@ import { fileURLToPath } from "node:url";
|
|
|
13
13
|
const require = createRequire(import.meta.url);
|
|
14
14
|
|
|
15
15
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
|
-
import type { AgentConfig } from "./agents.js";
|
|
17
|
-
import { discoverAgents } from "./agents.js";
|
|
16
|
+
import type { AgentConfig, AgentDiscoveryResult } from "./agents.js";
|
|
18
17
|
import { resolveAgentModelProvider } from "./resolve-agent-model.js";
|
|
19
18
|
import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
|
|
20
19
|
import { addReviewerPending, recordReviewerResult, countFindings, readReviewState, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
|
|
@@ -22,14 +21,17 @@ import {
|
|
|
22
21
|
getMainBranch,
|
|
23
22
|
} from "./git-helpers.js";
|
|
24
23
|
import { waitForResultFiles } from "./async-wait.js";
|
|
25
|
-
import {
|
|
24
|
+
import { formatAsyncResultOutput, reviewerOutputArchivePath } from "./async-result-format.js";
|
|
25
|
+
import { cleanupReviewerOutputArchives } from "./reviewer-output-archive.js";
|
|
26
26
|
const log = createLogger("async_agents");
|
|
27
27
|
|
|
28
28
|
export { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
|
|
29
29
|
import { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
|
|
30
|
+
import { openAsyncStatusOverlay } from "./async-status-ui.js";
|
|
30
31
|
import { setSlot } from "./status-bar.js";
|
|
31
32
|
import { getSetting } from "./project-settings.js";
|
|
32
33
|
import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
|
|
34
|
+
import { isLiveExtensionCtx } from "../shared/live-ctx.js";
|
|
33
35
|
|
|
34
36
|
const SETTINGS_KEYS: Record<string, unknown> = JSON.parse(
|
|
35
37
|
fs.readFileSync(
|
|
@@ -117,6 +119,10 @@ export function formatDuration(ms: number): string {
|
|
|
117
119
|
export function registerAsyncAgents(
|
|
118
120
|
pi: ExtensionAPI,
|
|
119
121
|
terminalNotify: (title: string, body: string) => void,
|
|
122
|
+
runtime: {
|
|
123
|
+
spawnProcess?: typeof spawn;
|
|
124
|
+
discoverAgents?: (cwd: string, scope: "both") => Pick<AgentDiscoveryResult, "agents">;
|
|
125
|
+
} = {},
|
|
120
126
|
): {
|
|
121
127
|
spawnAsyncAgent: (agentName: string, task: string, cwd: string, agents: AgentConfig[], options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } }) => { id: string; error?: string; model?: string };
|
|
122
128
|
killAsyncAgent: (target: string) => { killed: string[]; errors: string[] };
|
|
@@ -125,6 +131,28 @@ export function registerAsyncAgents(
|
|
|
125
131
|
let PROJECT_TMP_DIR = path.join(process.cwd(), ".pi", "tmp"); // Computed only; created on session_start
|
|
126
132
|
let ASYNC_RESULTS_DIR = ""; // Set on session_start to project-scoped dir
|
|
127
133
|
|
|
134
|
+
function resultOutputPath(job: Pick<AsyncJob, "agent" | "id" | "workerDir">): string {
|
|
135
|
+
const workerOutputPath = path.join(job.workerDir, "output.log");
|
|
136
|
+
const outputPath = job.agent.startsWith("code-reviewer-")
|
|
137
|
+
? reviewerOutputArchivePath(PROJECT_TMP_DIR, job.id)
|
|
138
|
+
: workerOutputPath;
|
|
139
|
+
log.debug("async_result_output_path", { agent: job.agent, archived: outputPath !== workerOutputPath });
|
|
140
|
+
return outputPath;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function preserveReviewerOutput(job: Pick<AsyncJob, "agent" | "id">, output: string): void {
|
|
144
|
+
if (!job.agent.startsWith("code-reviewer-")) return;
|
|
145
|
+
const outputPath = reviewerOutputArchivePath(PROJECT_TMP_DIR, job.id);
|
|
146
|
+
try {
|
|
147
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true, mode: 0o700 });
|
|
148
|
+
fs.writeFileSync(outputPath, output, { mode: 0o600 });
|
|
149
|
+
fs.chmodSync(outputPath, 0o600);
|
|
150
|
+
cleanupReviewerOutputArchives(path.dirname(outputPath));
|
|
151
|
+
log.info("preserved_reviewer_output", { agent: job.agent, bytes: Buffer.byteLength(output, "utf8") });
|
|
152
|
+
} catch (e: any) {
|
|
153
|
+
log.error(`preserve reviewer output failed for ${job.id}: ${e?.message}`);
|
|
154
|
+
}
|
|
155
|
+
}
|
|
128
156
|
|
|
129
157
|
const asyncState: AsyncState = {
|
|
130
158
|
jobs: new Map(),
|
|
@@ -148,10 +176,20 @@ export function registerAsyncAgents(
|
|
|
148
176
|
}
|
|
149
177
|
|
|
150
178
|
let lastWidgetKey = "";
|
|
179
|
+
function getLiveAsyncCtx(): any | null {
|
|
180
|
+
const ctx = asyncState.lastCtx;
|
|
181
|
+
if (!isLiveExtensionCtx(ctx)) {
|
|
182
|
+
log.debug("async_context_stale", { hadContext: ctx !== null });
|
|
183
|
+
asyncState.lastCtx = null;
|
|
184
|
+
return null;
|
|
185
|
+
}
|
|
186
|
+
return ctx;
|
|
187
|
+
}
|
|
188
|
+
|
|
151
189
|
function updateAsyncWidget() {
|
|
152
|
-
if (!asyncState.lastCtx?.hasUI) return;
|
|
153
190
|
try {
|
|
154
|
-
const ctx =
|
|
191
|
+
const ctx = getLiveAsyncCtx();
|
|
192
|
+
if (!ctx?.hasUI) return;
|
|
155
193
|
const running = Array.from(asyncState.jobs.values()).filter(j => j.status === "running" || j.status === "queued");
|
|
156
194
|
const names = running.map(j => j.name || j.agent).join(", ");
|
|
157
195
|
const widgetKey = `${running.length}:${names}`;
|
|
@@ -181,10 +219,15 @@ export function registerAsyncAgents(
|
|
|
181
219
|
function ensureAsyncPoller() {
|
|
182
220
|
if (asyncState.poller) return;
|
|
183
221
|
asyncState.poller = setInterval(() => {
|
|
222
|
+
const liveCtx = getLiveAsyncCtx();
|
|
223
|
+
if (!liveCtx) {
|
|
224
|
+
// ctx is stale (session ended) — stop polling
|
|
225
|
+
if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
184
228
|
try {
|
|
185
|
-
if (!
|
|
229
|
+
if (!liveCtx.hasUI) return;
|
|
186
230
|
} catch {
|
|
187
|
-
// ctx is stale (session ended) — stop polling
|
|
188
231
|
if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
|
|
189
232
|
return;
|
|
190
233
|
}
|
|
@@ -375,7 +418,7 @@ export function registerAsyncAgents(
|
|
|
375
418
|
deliverGroupResults(groupJobs);
|
|
376
419
|
}
|
|
377
420
|
// Delivery: retry for non-grouped jobs (zombie-ingest path may skip delivery)
|
|
378
|
-
} else if (
|
|
421
|
+
} else if (getLiveAsyncCtx() && !job.fireAndForget) {
|
|
379
422
|
const displayName = job.name || job.agent;
|
|
380
423
|
const resultStatus = job.status === "complete" ? "✅ completed" : "❌ failed";
|
|
381
424
|
const duration = job.durationMs || (job.updatedAt ? job.updatedAt - job.startedAt : 0);
|
|
@@ -392,9 +435,14 @@ export function registerAsyncAgents(
|
|
|
392
435
|
log.error(`reconcile: auto-complete failed for task #${safeTaskId}: ${e?.message}`);
|
|
393
436
|
}
|
|
394
437
|
}
|
|
395
|
-
// Enforce same output-length budget as processResultFile
|
|
396
|
-
const maxOutput = 3000 - autoCompleteError.length;
|
|
397
|
-
const output = (
|
|
438
|
+
// Enforce same output-length budget as processResultFile.
|
|
439
|
+
const maxOutput = Math.max(3000 - autoCompleteError.length, 500);
|
|
440
|
+
const output = formatAsyncResultOutput(
|
|
441
|
+
job.agent,
|
|
442
|
+
job.output || "",
|
|
443
|
+
resultOutputPath(job),
|
|
444
|
+
maxOutput,
|
|
445
|
+
);
|
|
398
446
|
// Emit lifecycle events
|
|
399
447
|
if (job.status === "complete") {
|
|
400
448
|
pi.events.emit("subagents:completed", { id: job.id, result: job.output || "" });
|
|
@@ -406,7 +454,7 @@ export function registerAsyncAgents(
|
|
|
406
454
|
job.delivered = true;
|
|
407
455
|
log.debug(`reconcile: skipping already-delivered result for ${job.id}`);
|
|
408
456
|
} else {
|
|
409
|
-
log.debug("reconcile_deliver", job.id, "hasCtx",
|
|
457
|
+
log.debug("reconcile_deliver", job.id, "hasCtx", true);
|
|
410
458
|
try {
|
|
411
459
|
pi.sendMessage({
|
|
412
460
|
customType: "async-agent-result",
|
|
@@ -463,8 +511,6 @@ export function registerAsyncAgents(
|
|
|
463
511
|
if (gid && groupDeliveryInProgress.has(gid)) return;
|
|
464
512
|
if (gid) groupDeliveryInProgress.add(gid);
|
|
465
513
|
try {
|
|
466
|
-
if (!asyncState.lastCtx) return;
|
|
467
|
-
|
|
468
514
|
// Ingest any unprocessed result files — zombie/kill paths may trigger delivery
|
|
469
515
|
// before processResultFile() has read all group members' outputs.
|
|
470
516
|
// Wait up to 2s total (not per-job) for all missing result files.
|
|
@@ -481,6 +527,7 @@ export function registerAsyncAgents(
|
|
|
481
527
|
try {
|
|
482
528
|
const data = JSON.parse(fs.readFileSync(rp, "utf-8"));
|
|
483
529
|
j.output = data.output;
|
|
530
|
+
preserveReviewerOutput(j, typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? ""));
|
|
484
531
|
j.exitCode = data.exitCode;
|
|
485
532
|
j.durationMs = data.durationMs;
|
|
486
533
|
if (data.success !== undefined) j.status = data.success ? "complete" : "failed";
|
|
@@ -549,7 +596,11 @@ export function registerAsyncAgents(
|
|
|
549
596
|
if (j.fireAndForget) { j.delivered = true; continue; }
|
|
550
597
|
const resultStatus = j.status === "complete" ? "✅ completed" : "❌ failed";
|
|
551
598
|
const displayName = j.name || j.agent;
|
|
552
|
-
const output = (
|
|
599
|
+
const output = formatAsyncResultOutput(
|
|
600
|
+
j.agent,
|
|
601
|
+
j.output || "",
|
|
602
|
+
resultOutputPath(j),
|
|
603
|
+
);
|
|
553
604
|
let autoCompleteError = "";
|
|
554
605
|
// Auto-complete linked task directly in the store file (no AI involvement)
|
|
555
606
|
if (j.taskId && j.taskId !== "-1" && j.status === "complete" && j.cwd) {
|
|
@@ -567,6 +618,12 @@ export function registerAsyncAgents(
|
|
|
567
618
|
}
|
|
568
619
|
|
|
569
620
|
if (sections.length > 0) {
|
|
621
|
+
// A group may have waited for result files while its session was replaced.
|
|
622
|
+
// Keep persisted/in-memory results for reconciliation rather than sending into it.
|
|
623
|
+
if (!getLiveAsyncCtx()) {
|
|
624
|
+
log.debug("deliver_group_results_deferred", { groupId: groupJobs[0]?.groupId, jobs: groupJobs.length, deliverableJobs: deliverableJobs.length, status: groupJobs[0]?.status });
|
|
625
|
+
return;
|
|
626
|
+
}
|
|
570
627
|
try {
|
|
571
628
|
pi.sendMessage({
|
|
572
629
|
customType: "async-agent-result",
|
|
@@ -613,7 +670,7 @@ export function registerAsyncAgents(
|
|
|
613
670
|
try {
|
|
614
671
|
const data = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
615
672
|
const job = asyncState.jobs.get(data.id);
|
|
616
|
-
log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!
|
|
673
|
+
log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!getLiveAsyncCtx()} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
|
|
617
674
|
if (!job) {
|
|
618
675
|
// Orphan result file — job not in current session. Clean up to prevent re-delivery on reload.
|
|
619
676
|
try { fs.unlinkSync(resultPath); } catch (e: any) { log.debug(`orphan cleanup failed: ${resultPath}: ${e?.message}`); }
|
|
@@ -639,6 +696,7 @@ export function registerAsyncAgents(
|
|
|
639
696
|
job.exitCode = data.exitCode;
|
|
640
697
|
job.durationMs = data.durationMs;
|
|
641
698
|
job.updatedAt = Date.now();
|
|
699
|
+
preserveReviewerOutput(job, typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? ""));
|
|
642
700
|
|
|
643
701
|
// Notify terminal per-agent (lightweight, non-conversational)
|
|
644
702
|
const displayName = job.name || data.agent;
|
|
@@ -657,11 +715,11 @@ export function registerAsyncAgents(
|
|
|
657
715
|
// Use model context window from the agent's effective model.
|
|
658
716
|
// If agent uses a custom model, we pass its context window via the config.
|
|
659
717
|
// Fall back to orchestrator's model context window.
|
|
660
|
-
const contextWindow = data.contextWindow ||
|
|
718
|
+
const contextWindow = data.contextWindow || getLiveAsyncCtx()?.model?.contextWindow || 0;
|
|
661
719
|
if (data.lastUsage?.totalTokens && data.lastUsage.totalTokens > 0 && contextWindow > 0) {
|
|
662
720
|
const pct = (data.lastUsage.totalTokens / contextWindow) * 100;
|
|
663
721
|
if (pct > 80) {
|
|
664
|
-
const sessId = `async-${job.agent}-${djb2Hash(job.agent + ':' + (jobCwd(job)) + ':' + (
|
|
722
|
+
const sessId = `async-${job.agent}-${djb2Hash(job.agent + ':' + (jobCwd(job)) + ':' + (getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || '')).toString(36)}`;
|
|
665
723
|
try {
|
|
666
724
|
// Find and delete the session file to force fresh on next cycle
|
|
667
725
|
const sessDir = path.join(os.homedir(), '.pi', 'agent', 'sessions');
|
|
@@ -703,7 +761,11 @@ export function registerAsyncAgents(
|
|
|
703
761
|
try {
|
|
704
762
|
const statusPath = path.join(job.workerDir, "status.json");
|
|
705
763
|
const existing = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
706
|
-
existing.output = (
|
|
764
|
+
existing.output = formatAsyncResultOutput(
|
|
765
|
+
job.agent,
|
|
766
|
+
data.output || "",
|
|
767
|
+
resultOutputPath(job),
|
|
768
|
+
);
|
|
707
769
|
existing.state = job.status;
|
|
708
770
|
existing.exitCode = job.exitCode;
|
|
709
771
|
existing.durationMs = job.durationMs;
|
|
@@ -735,7 +797,7 @@ export function registerAsyncAgents(
|
|
|
735
797
|
|
|
736
798
|
// Non-grouped job: deliver immediately (existing behavior)
|
|
737
799
|
let sendSucceeded = false;
|
|
738
|
-
if (
|
|
800
|
+
if (getLiveAsyncCtx() && !job.fireAndForget) {
|
|
739
801
|
const resultStatus = data.success ? "✅ completed" : "❌ failed";
|
|
740
802
|
let autoCompleteError = "";
|
|
741
803
|
// Auto-complete linked task directly in the store file (no AI involvement)
|
|
@@ -748,8 +810,13 @@ export function registerAsyncAgents(
|
|
|
748
810
|
log.error(`auto-complete failed for task #${job.taskId}: ${e?.message}`);
|
|
749
811
|
}
|
|
750
812
|
}
|
|
751
|
-
const maxOutput = 3000 - autoCompleteError.length;
|
|
752
|
-
const output = (
|
|
813
|
+
const maxOutput = Math.max(3000 - autoCompleteError.length, 500);
|
|
814
|
+
const output = formatAsyncResultOutput(
|
|
815
|
+
job.agent,
|
|
816
|
+
data.output || "",
|
|
817
|
+
resultOutputPath(job),
|
|
818
|
+
maxOutput,
|
|
819
|
+
);
|
|
753
820
|
const pContent = `## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${data.task}\nDuration: ${formatDuration(data.durationMs)}\n\n${output}${autoCompleteError}`;
|
|
754
821
|
if (wasAlreadyDelivered(job.id)) {
|
|
755
822
|
// Already delivered in previous lifecycle — skip send AND onComplete
|
|
@@ -806,16 +873,17 @@ export function registerAsyncAgents(
|
|
|
806
873
|
watchedDir = ASYNC_RESULTS_DIR;
|
|
807
874
|
try {
|
|
808
875
|
fs.mkdirSync(ASYNC_RESULTS_DIR, { recursive: true, mode: 0o700 });
|
|
809
|
-
|
|
876
|
+
const resultsDir = ASYNC_RESULTS_DIR;
|
|
877
|
+
asyncState.watcher = fs.watch(resultsDir, (ev, file) => {
|
|
810
878
|
if (ev !== "rename" || !file) return;
|
|
811
879
|
const fileName = file.toString();
|
|
812
880
|
log.debug("watcher_event", ev, fileName);
|
|
813
881
|
if (!fileName.endsWith(".json")) return;
|
|
814
|
-
const resultPath = path.join(
|
|
882
|
+
const resultPath = path.join(resultsDir, fileName);
|
|
815
883
|
setTimeout(() => processResultFile(resultPath), 100);
|
|
816
884
|
});
|
|
817
885
|
if (asyncState.watcher.unref) asyncState.watcher.unref();
|
|
818
|
-
} catch (e: any) {
|
|
886
|
+
} catch (e: any) { log.error(`watcher setup failed: ${e?.message || e}`); }
|
|
819
887
|
}
|
|
820
888
|
|
|
821
889
|
function spawnAsyncAgent(
|
|
@@ -844,7 +912,7 @@ export function registerAsyncAgents(
|
|
|
844
912
|
} catch { /* retry */ }
|
|
845
913
|
}
|
|
846
914
|
if (!parentStartTime) {
|
|
847
|
-
|
|
915
|
+
log.warn("async-spawn: could not read /proc/self/stat starttime after 3 attempts");
|
|
848
916
|
}
|
|
849
917
|
const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd, options?.explicit);
|
|
850
918
|
fs.writeFileSync(path.join(workerDir, "session.json"), JSON.stringify({
|
|
@@ -854,8 +922,8 @@ export function registerAsyncAgents(
|
|
|
854
922
|
parentStartTime,
|
|
855
923
|
taskId: options?.taskId || null,
|
|
856
924
|
cwd,
|
|
857
|
-
projectCwd:
|
|
858
|
-
sessionId:
|
|
925
|
+
projectCwd: getLiveAsyncCtx()?.sessionManager?.getCwd?.() || null,
|
|
926
|
+
sessionId: getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || null,
|
|
859
927
|
}), { mode: 0o600 });
|
|
860
928
|
|
|
861
929
|
// Track reviewer spawn BEFORE building args — addReviewerPending sets status to
|
|
@@ -877,7 +945,7 @@ export function registerAsyncAgents(
|
|
|
877
945
|
// Deterministic session ID for provider cache affinity + optional session reuse.
|
|
878
946
|
// When persistSession is true, omits --no-session so the session persists to disk.
|
|
879
947
|
// Same agent + cwd gets the same session ID across calls.
|
|
880
|
-
const parentSessionId =
|
|
948
|
+
const parentSessionId = getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || '';
|
|
881
949
|
const sessionIdSource = persistSession
|
|
882
950
|
? agentName + ':' + cwd + ':' + parentSessionId
|
|
883
951
|
: agentName + ':' + task.slice(0, 100);
|
|
@@ -910,8 +978,11 @@ export function registerAsyncAgents(
|
|
|
910
978
|
task,
|
|
911
979
|
cwd,
|
|
912
980
|
model: effectiveModel,
|
|
913
|
-
contextWindow:
|
|
981
|
+
contextWindow: getLiveAsyncCtx()?.model?.contextWindow || 0,
|
|
914
982
|
resultPath,
|
|
983
|
+
reviewerOutputPath: agentName.startsWith("code-reviewer-")
|
|
984
|
+
? reviewerOutputArchivePath(PROJECT_TMP_DIR, id)
|
|
985
|
+
: undefined,
|
|
915
986
|
workerDir,
|
|
916
987
|
sessionId: `${process.pid}:${process.cwd()}`,
|
|
917
988
|
piCommand: inv.command,
|
|
@@ -982,7 +1053,7 @@ export function registerAsyncAgents(
|
|
|
982
1053
|
|
|
983
1054
|
// Fail fast with a self-explanatory error instead of spawning a child that
|
|
984
1055
|
// cannot load TypeScript (bare node dies on ".js"→".ts" specifiers).
|
|
985
|
-
if (!jitiCliPath) {
|
|
1056
|
+
if (!jitiCliPath && !runtime.spawnProcess) {
|
|
986
1057
|
log.error(`async-spawn: ${id} — no jiti-cli.mjs found. Probes:\n ${(jitiProbes.slice(-14)).join("\n ")}`);
|
|
987
1058
|
// Clean up the artifacts we already created — nothing will consume them.
|
|
988
1059
|
try { fs.rmSync(configPath, { force: true }); } catch {}
|
|
@@ -1045,8 +1116,8 @@ export function registerAsyncAgents(
|
|
|
1045
1116
|
groupId: options?.groupId,
|
|
1046
1117
|
taskId: options?.taskId,
|
|
1047
1118
|
cwd,
|
|
1048
|
-
projectCwd:
|
|
1049
|
-
sessionId:
|
|
1119
|
+
projectCwd: getLiveAsyncCtx()?.sessionManager?.getCwd?.(),
|
|
1120
|
+
sessionId: getLiveAsyncCtx()?.sessionManager?.getSessionId?.(),
|
|
1050
1121
|
model: effectiveModel,
|
|
1051
1122
|
};
|
|
1052
1123
|
asyncState.jobs.set(id, job);
|
|
@@ -1056,7 +1127,7 @@ export function registerAsyncAgents(
|
|
|
1056
1127
|
stderrLog.on("error", () => {}); // never let a log write failure crash the session
|
|
1057
1128
|
log.debug(`async-spawn: ${job.id} stderr → ${stderrLogPath}`);
|
|
1058
1129
|
|
|
1059
|
-
const proc = spawn(process.execPath, spawnArgs, {
|
|
1130
|
+
const proc = (runtime.spawnProcess ?? spawn)(process.execPath, spawnArgs, {
|
|
1060
1131
|
cwd,
|
|
1061
1132
|
stdio: ["ignore", "ignore", "pipe"],
|
|
1062
1133
|
windowsHide: true,
|
|
@@ -1136,7 +1207,7 @@ export function registerAsyncAgents(
|
|
|
1136
1207
|
|
|
1137
1208
|
// Auto-mark linked task as in_progress AFTER successful spawn
|
|
1138
1209
|
if (options?.taskId && options.taskId !== "-1") {
|
|
1139
|
-
autoMarkInProgress(options.taskId,
|
|
1210
|
+
autoMarkInProgress(options.taskId, getLiveAsyncCtx()?.sessionManager?.getCwd?.() || cwd, getLiveAsyncCtx()?.sessionManager?.getSessionId?.() || undefined)
|
|
1140
1211
|
.catch(() => {}); // best-effort, don't block spawn
|
|
1141
1212
|
}
|
|
1142
1213
|
|
|
@@ -1145,6 +1216,10 @@ export function registerAsyncAgents(
|
|
|
1145
1216
|
|
|
1146
1217
|
// Start result watcher on session start
|
|
1147
1218
|
pi.on("session_start", (_event, ctx) => {
|
|
1219
|
+
if (!isLiveExtensionCtx(ctx)) {
|
|
1220
|
+
log.warn("session_start received a stale ctx; preserving async results for a live session");
|
|
1221
|
+
return;
|
|
1222
|
+
}
|
|
1148
1223
|
asyncState.lastCtx = ctx;
|
|
1149
1224
|
deliveredResultIds.clear();
|
|
1150
1225
|
|
|
@@ -1153,6 +1228,8 @@ export function registerAsyncAgents(
|
|
|
1153
1228
|
// Export as env var so prompts/CLI commands can reference it
|
|
1154
1229
|
process.env.PROJECT_TMP_DIR = PROJECT_TMP_DIR;
|
|
1155
1230
|
|
|
1231
|
+
cleanupReviewerOutputArchives(path.join(PROJECT_TMP_DIR, "reviewer-results"));
|
|
1232
|
+
|
|
1156
1233
|
// Scan worker directories for jobs with delivered=true in status.json
|
|
1157
1234
|
// This is more reliable than content hashing — uses job IDs directly
|
|
1158
1235
|
try {
|
|
@@ -1205,7 +1282,7 @@ export function registerAsyncAgents(
|
|
|
1205
1282
|
try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
|
|
1206
1283
|
} catch {} // skip unreadable dirs
|
|
1207
1284
|
}
|
|
1208
|
-
} catch (e: any) {
|
|
1285
|
+
} catch (e: any) { log.error(`zombie cleanup failed: ${e?.message?.slice(0, 100)}`); }
|
|
1209
1286
|
|
|
1210
1287
|
// Clean up stale result directories from dead sessions
|
|
1211
1288
|
try {
|
|
@@ -1296,13 +1373,13 @@ export function registerAsyncAgents(
|
|
|
1296
1373
|
log.info(`restored job: ${id} state=${job.status}`);
|
|
1297
1374
|
}
|
|
1298
1375
|
}
|
|
1299
|
-
} catch (e: any) {
|
|
1376
|
+
} catch (e: any) { log.error(`job restore failed: ${e?.message || e}`); }
|
|
1300
1377
|
|
|
1301
1378
|
// Start poller if we have jobs or unprocessed result files
|
|
1302
1379
|
let hasResultFiles = false;
|
|
1303
1380
|
try {
|
|
1304
1381
|
hasResultFiles = fs.existsSync(ASYNC_RESULTS_DIR) && fs.readdirSync(ASYNC_RESULTS_DIR).some(f => f.endsWith(".json"));
|
|
1305
|
-
} catch (e: any) {
|
|
1382
|
+
} catch (e: any) { log.error(`result files check failed: ${e?.message || e}`); }
|
|
1306
1383
|
if (asyncState.jobs.size > 0 || hasResultFiles) {
|
|
1307
1384
|
ensureAsyncPoller();
|
|
1308
1385
|
}
|
|
@@ -1314,6 +1391,7 @@ export function registerAsyncAgents(
|
|
|
1314
1391
|
// Clean up on shutdown
|
|
1315
1392
|
pi.on("session_shutdown", () => {
|
|
1316
1393
|
log.info(`shutdown: jobs=${asyncState.jobs.size}`);
|
|
1394
|
+
asyncState.lastCtx = null;
|
|
1317
1395
|
if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
|
|
1318
1396
|
if (asyncState.watcher) { asyncState.watcher.close(); asyncState.watcher = null; }
|
|
1319
1397
|
});
|
|
@@ -1321,6 +1399,7 @@ export function registerAsyncAgents(
|
|
|
1321
1399
|
// /async-status — fullscreen overlay list → live output detail
|
|
1322
1400
|
async function handleAsyncStatus(ctx: any): Promise<void> {
|
|
1323
1401
|
if (!ctx.hasUI) return;
|
|
1402
|
+
log.debug("opening_async_status_overlay", { jobs: asyncState.jobs.size });
|
|
1324
1403
|
await openAsyncStatusOverlay(ctx, {
|
|
1325
1404
|
listJobs: () => Array.from(asyncState.jobs.values()),
|
|
1326
1405
|
killJob: (id) => {
|
|
@@ -1408,12 +1487,16 @@ export function registerAsyncAgents(
|
|
|
1408
1487
|
if (pending.length === 0) {
|
|
1409
1488
|
deliverGroupResults(groupJobs);
|
|
1410
1489
|
}
|
|
1411
|
-
} else if (
|
|
1490
|
+
} else if (getLiveAsyncCtx() && !job.fireAndForget) {
|
|
1412
1491
|
// Non-grouped killed job — deliver immediately so AI knows it was killed
|
|
1413
1492
|
const displayName = job.name || job.agent;
|
|
1414
1493
|
const duration = job.durationMs || (Date.now() - job.startedAt);
|
|
1415
1494
|
const rawOutput = typeof job.output === "string" ? job.output : "Killed by user";
|
|
1416
|
-
const output =
|
|
1495
|
+
const output = formatAsyncResultOutput(
|
|
1496
|
+
job.agent,
|
|
1497
|
+
rawOutput,
|
|
1498
|
+
resultOutputPath(job),
|
|
1499
|
+
);
|
|
1417
1500
|
const killContent = `## Async Agent Result: ${displayName} ❌ failed\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}`;
|
|
1418
1501
|
if (wasAlreadyDelivered(job.id)) {
|
|
1419
1502
|
job.delivered = true;
|
|
@@ -1524,13 +1607,15 @@ export function registerAsyncAgents(
|
|
|
1524
1607
|
|
|
1525
1608
|
// Spawn — create an async agent from pitasks' TaskExecute
|
|
1526
1609
|
handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
|
|
1527
|
-
"subagents:rpc:spawn", ({ type, prompt, options }) => {
|
|
1528
|
-
const ctx =
|
|
1610
|
+
"subagents:rpc:spawn", async ({ type, prompt, options }) => {
|
|
1611
|
+
const ctx = getLiveAsyncCtx();
|
|
1529
1612
|
if (!ctx) throw new Error("No active session");
|
|
1530
1613
|
if (!type || typeof type !== "string") throw new Error("Missing or invalid 'type' parameter");
|
|
1531
1614
|
if (!prompt || typeof prompt !== "string") throw new Error("Missing or invalid 'prompt' parameter");
|
|
1532
1615
|
|
|
1533
1616
|
const cwd = options?.cwd || ctx.cwd;
|
|
1617
|
+
log.debug("rpc_spawn_requested", { type, customCwd: Boolean(options?.cwd) });
|
|
1618
|
+
const discoverAgents = runtime.discoverAgents ?? (await import("./agents.js")).discoverAgents;
|
|
1534
1619
|
const discovery = discoverAgents(cwd, "both");
|
|
1535
1620
|
const agents = discovery.agents;
|
|
1536
1621
|
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/** Format async-agent output for bounded delivery and status persistence. */
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import * as path from "node:path";
|
|
5
|
+
import { createLogger } from "../shared/logger.js";
|
|
6
|
+
|
|
7
|
+
const log = createLogger("async_agents");
|
|
8
|
+
const MAX_OUTPUT_CHARS = 3000;
|
|
9
|
+
|
|
10
|
+
/** Return the completed-result archive path, outside cleanup-prone worker directories. */
|
|
11
|
+
export function reviewerOutputArchivePath(projectTmpDir: string, jobId: string): string {
|
|
12
|
+
const archiveDir = path.resolve(projectTmpDir, "reviewer-results");
|
|
13
|
+
const filename = `${createHash("sha256").update(jobId).digest("hex")}.json`;
|
|
14
|
+
const archivePath = path.resolve(archiveDir, filename);
|
|
15
|
+
if (path.dirname(archivePath) !== archiveDir) {
|
|
16
|
+
throw new Error("Reviewer archive destination escapes its archive root");
|
|
17
|
+
}
|
|
18
|
+
log.debug("reviewer_output_archive_path", { jobId, archivePath });
|
|
19
|
+
return archivePath;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Preserve valid reviewer JSON unless it exceeds the delivery budget.
|
|
24
|
+
* Oversized reviewer output is available only in its worker output.log.
|
|
25
|
+
*/
|
|
26
|
+
export function formatAsyncResultOutput(
|
|
27
|
+
agent: string,
|
|
28
|
+
output: string,
|
|
29
|
+
outputPath: string,
|
|
30
|
+
maxChars = MAX_OUTPUT_CHARS,
|
|
31
|
+
): string {
|
|
32
|
+
log.debug("format_async_result_output", { agent, outputChars: output.length, maxChars });
|
|
33
|
+
if (agent.startsWith("code-reviewer-") && output.length > maxChars) {
|
|
34
|
+
try {
|
|
35
|
+
const parsed = JSON.parse(output);
|
|
36
|
+
if (parsed && typeof parsed === "object" && Array.isArray(parsed.findings)) {
|
|
37
|
+
const metadata = {
|
|
38
|
+
truncated: true,
|
|
39
|
+
format: "json",
|
|
40
|
+
outputBytes: Buffer.byteLength(output, "utf8"),
|
|
41
|
+
outputPath,
|
|
42
|
+
};
|
|
43
|
+
const serialized = JSON.stringify(metadata);
|
|
44
|
+
if (serialized.length <= maxChars) return serialized;
|
|
45
|
+
log.warn("reviewer_metadata_path_exceeds_budget", { agent, maxChars });
|
|
46
|
+
return JSON.stringify({
|
|
47
|
+
truncated: true,
|
|
48
|
+
format: "json",
|
|
49
|
+
outputBytes: metadata.outputBytes,
|
|
50
|
+
outputPathOmitted: true,
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
} catch {
|
|
54
|
+
// Invalid reviewer output retains ordinary bounded-text behavior.
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return output.slice(0, maxChars);
|
|
58
|
+
}
|
|
@@ -9,6 +9,10 @@ import * as fs from "node:fs";
|
|
|
9
9
|
import * as path from "node:path";
|
|
10
10
|
import { createRequire } from "node:module";
|
|
11
11
|
import { getSetting } from "./project-settings.js";
|
|
12
|
+
import { createLogger } from "../shared/logger.js";
|
|
13
|
+
import { archiveReviewerOutput } from "./reviewer-output-archive.js";
|
|
14
|
+
|
|
15
|
+
const log = createLogger("async_agents");
|
|
12
16
|
|
|
13
17
|
interface RunConfig {
|
|
14
18
|
id: string;
|
|
@@ -20,6 +24,7 @@ interface RunConfig {
|
|
|
20
24
|
tools?: string[];
|
|
21
25
|
systemPrompt?: string;
|
|
22
26
|
resultPath: string;
|
|
27
|
+
reviewerOutputPath?: string;
|
|
23
28
|
workerDir: string;
|
|
24
29
|
sessionId?: string;
|
|
25
30
|
piCommand: string;
|
|
@@ -72,7 +77,7 @@ async function run(config: RunConfig): Promise<void> {
|
|
|
72
77
|
writeJson(statusPath, status);
|
|
73
78
|
let lastUsage: any = null;
|
|
74
79
|
|
|
75
|
-
const outputStream = fs.createWriteStream(outputPath, { flags: "w" });
|
|
80
|
+
const outputStream = fs.createWriteStream(outputPath, { flags: "w", mode: 0o600 });
|
|
76
81
|
|
|
77
82
|
// Connect to pidash server to stream events
|
|
78
83
|
const pidashPort = getSetting(config.cwd, "pidash_port");
|
|
@@ -157,7 +162,7 @@ async function run(config: RunConfig): Promise<void> {
|
|
|
157
162
|
if (pidashWs?.readyState === 1) {
|
|
158
163
|
pidashWs.send(JSON.stringify({ type: "async_event", id: config.id, event: ev }));
|
|
159
164
|
}
|
|
160
|
-
} catch (e: any) {
|
|
165
|
+
} catch (e: any) { log.warn(`event processing skipped: ${e?.message || e}`); }
|
|
161
166
|
}
|
|
162
167
|
|
|
163
168
|
// Update status periodically (every ~10 lines)
|
|
@@ -193,7 +198,7 @@ async function run(config: RunConfig): Promise<void> {
|
|
|
193
198
|
if (p.type === "text") finalOutput = p.text;
|
|
194
199
|
}
|
|
195
200
|
}
|
|
196
|
-
} catch (e: any) {
|
|
201
|
+
} catch (e: any) { log.error(`final line parse failed: ${e?.message || e}`); }
|
|
197
202
|
}
|
|
198
203
|
|
|
199
204
|
// Write final status
|
|
@@ -283,7 +288,18 @@ async function run(config: RunConfig): Promise<void> {
|
|
|
283
288
|
}
|
|
284
289
|
|
|
285
290
|
// End the output stream after all retries are done
|
|
286
|
-
|
|
291
|
+
await new Promise<void>((resolve, reject) => {
|
|
292
|
+
outputStream.once("error", reject);
|
|
293
|
+
outputStream.end(resolve);
|
|
294
|
+
});
|
|
295
|
+
if (config.reviewerOutputPath && config.agent.startsWith("code-reviewer-")) {
|
|
296
|
+
try {
|
|
297
|
+
archiveReviewerOutput(outputPath, config.reviewerOutputPath);
|
|
298
|
+
log.info("archived_reviewer_output", { agent: config.agent, outputPath: config.reviewerOutputPath });
|
|
299
|
+
} catch (err) {
|
|
300
|
+
log.error("archive_reviewer_output_failed", err);
|
|
301
|
+
}
|
|
302
|
+
}
|
|
287
303
|
|
|
288
304
|
// Write result for the watcher to pick up
|
|
289
305
|
writeJson(config.resultPath, {
|
|
@@ -3,10 +3,10 @@
|
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
export interface CronStatusTaskView {
|
|
6
|
-
/** Unique overlay row id (e.g. "
|
|
6
|
+
/** Unique scope-qualified overlay row id (e.g. "session:<uuid>"). */
|
|
7
7
|
id: string;
|
|
8
|
-
/**
|
|
9
|
-
taskId: number;
|
|
8
|
+
/** Stable UUID within its session or project scope. */
|
|
9
|
+
taskId: string | number;
|
|
10
10
|
description: string;
|
|
11
11
|
task: string;
|
|
12
12
|
intervalMs?: number;
|
|
@@ -15,7 +15,7 @@ export interface CronStatusTaskView {
|
|
|
15
15
|
createdAt: number;
|
|
16
16
|
lastRun?: number;
|
|
17
17
|
nextRun?: number;
|
|
18
|
-
/**
|
|
18
|
+
/** Optional scope and leader-state label shown in overlay rows. */
|
|
19
19
|
sessionLabel?: string;
|
|
20
20
|
isLocal?: boolean;
|
|
21
21
|
cronFile?: string;
|
|
@@ -91,7 +91,7 @@ export function formatNextRunLabel(
|
|
|
91
91
|
/** Map a CronTask-like object into an overlay row view. */
|
|
92
92
|
export function toCronStatusTaskView(
|
|
93
93
|
task: {
|
|
94
|
-
id: number;
|
|
94
|
+
id: string | number;
|
|
95
95
|
description: string;
|
|
96
96
|
task: string;
|
|
97
97
|
intervalMs?: number;
|
|
@@ -110,7 +110,7 @@ export function toCronStatusTaskView(
|
|
|
110
110
|
): CronStatusTaskView {
|
|
111
111
|
return {
|
|
112
112
|
id: opts.overlayId,
|
|
113
|
-
taskId:
|
|
113
|
+
taskId: opts.overlayId,
|
|
114
114
|
description: task.description,
|
|
115
115
|
task: task.task,
|
|
116
116
|
intervalMs: task.intervalMs,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* /cron list
|
|
2
|
+
* /cron list fullscreen overlay for session and project tasks — list dashboard → task detail.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
5
|
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
@@ -25,7 +25,7 @@ export {
|
|
|
25
25
|
|
|
26
26
|
export interface CronStatusUiDeps {
|
|
27
27
|
listTasks: () => CronStatusTaskView[];
|
|
28
|
-
/** Remove by
|
|
28
|
+
/** Remove by scope-qualified task id. Return true if removed. */
|
|
29
29
|
removeTask: (id: string) => boolean;
|
|
30
30
|
title?: string;
|
|
31
31
|
emptyMessage?: string;
|
|
@@ -33,7 +33,7 @@ export interface CronStatusUiDeps {
|
|
|
33
33
|
}
|
|
34
34
|
|
|
35
35
|
/**
|
|
36
|
-
* Open fullscreen cron list. Loops list → detail until
|
|
36
|
+
* Open fullscreen cron list for the selected scope. Loops list → detail until closed.
|
|
37
37
|
*/
|
|
38
38
|
export async function openCronStatusOverlay(
|
|
39
39
|
ctx: ExtensionCommandContext,
|