pi-orchestrator-config 4.2.0 → 4.3.2
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 +76 -67
- package/agents/code-reviewer-docs.md +11 -1
- package/agents/code-reviewer-guidelines.md +11 -1
- package/agents/code-reviewer-quality.md +11 -1
- package/agents/code-reviewer-security.md +11 -1
- package/agents/code-reviewer-spec.md +11 -1
- package/agents/git-expert.md +42 -3
- package/agents/github-expert.md +11 -0
- package/extensions/acpx-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/agents/claude.ts +3 -2
- package/extensions/cli-provider/agents/cursor.ts +3 -5
- package/extensions/cli-provider/agents/gemini.ts +3 -2
- package/extensions/cli-provider/discover.ts +7 -7
- package/extensions/cli-provider/index.ts +5 -0
- package/extensions/cli-provider/runtime-models.ts +18 -6
- package/extensions/cli-provider/session-reaper.ts +5 -7
- package/extensions/cli-provider/sessions.ts +4 -0
- package/extensions/cli-provider/shared/discover-cache.ts +17 -176
- package/extensions/coms/coms-p2p.ts +1719 -423
- package/extensions/coms/coms-shared.ts +261 -39
- package/extensions/coms/coms-wrapper.ts +100 -62
- package/extensions/coms/index.ts +8 -4
- package/extensions/coms/mixed-turn.ts +26 -0
- package/extensions/coms/probe-socket.ts +42 -0
- package/extensions/coms/themeMap.ts +0 -1
- package/extensions/image-gen/image-gen.ts +19 -1
- package/extensions/orchestrator/async-agents.ts +563 -102
- package/extensions/orchestrator/async-capability.ts +8 -8
- package/extensions/orchestrator/cron.ts +15 -9
- package/extensions/orchestrator/dreaming.ts +32 -25
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +80 -27
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +78 -10
- package/extensions/orchestrator/markdown-transformer.ts +313 -0
- package/extensions/orchestrator/memory-embeddings.ts +36 -34
- package/extensions/orchestrator/memory-provenance.ts +29 -25
- package/extensions/orchestrator/memory-scoring.ts +23 -18
- package/extensions/orchestrator/memory-tools.ts +10 -3
- package/extensions/orchestrator/overlay-dashboard.ts +17 -5
- package/extensions/orchestrator/parse-model-override.ts +29 -0
- package/extensions/orchestrator/pi-config-review-state.ts +57 -33
- package/extensions/orchestrator/project-settings.ts +333 -127
- package/extensions/orchestrator/resolve-agent-model.ts +24 -13
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +63 -15
- package/extensions/orchestrator/rule-placeholders.ts +411 -2
- package/extensions/orchestrator/rules.ts +80 -132
- package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
- package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
- package/extensions/orchestrator/settings-tui.ts +636 -0
- package/extensions/orchestrator/state-jsonl.ts +431 -0
- package/extensions/orchestrator/subagent-tool.ts +157 -59
- package/extensions/orchestrator/sync-limit.ts +23 -0
- package/extensions/orchestrator/task-lifecycle.ts +77 -67
- package/extensions/orchestrator/utils.ts +8 -5
- package/extensions/pidash/pidash.ts +193 -156
- package/extensions/pidiff/pidiff.ts +96 -38
- package/extensions/pidiff/spawn-lock.ts +62 -0
- package/extensions/pitasks/index.ts +430 -0
- package/extensions/pitasks/reminders.ts +37 -0
- package/extensions/pitasks/task-store.ts +467 -0
- package/extensions/pitasks/task-tools.ts +375 -0
- package/extensions/pitasks/task-widget.ts +181 -0
- package/extensions/pitasks/types.ts +38 -0
- package/extensions/providers/acpx-driver.ts +12 -2
- package/extensions/providers/built-in-drivers.ts +6 -6
- package/extensions/providers/claude-driver.ts +1 -1
- package/extensions/providers/cursor-cli-driver.ts +1 -1
- package/extensions/providers/gemini-driver.ts +1 -1
- package/extensions/providers/index.ts +144 -26
- package/extensions/providers/initialized-guard.ts +27 -0
- package/extensions/providers/restore-default-model.ts +596 -0
- package/extensions/providers/session-shutdown.ts +39 -0
- package/extensions/shared/coms-active.ts +23 -0
- package/extensions/shared/create-runtime-provider.ts +2 -0
- package/extensions/shared/daemon-manager.ts +0 -8
- package/extensions/shared/file-logger.ts +132 -25
- package/extensions/shared/logger.ts +50 -0
- package/extensions/shared/models-dev.ts +421 -0
- package/extensions/shared/oneshot.ts +116 -0
- package/extensions/shared/resolve-binary.ts +191 -0
- package/extensions/shared/types.ts +3 -0
- package/package.json +9 -3
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +12 -1
- package/rules/20-code-review-loop.md +52 -25
- package/rules/30-prompt-templates.md +4 -0
- package/rules/35-memory.md +3 -3
- package/rules/40-critical-rules.md +3 -0
- package/rules/55-coms-protocol.md +84 -18
- package/rules/60-task-tracking.md +2 -0
- package/settings-keys.json +563 -0
- package/templates/coms-feature-manager-prompt.md +4 -6
- package/extensions/coms/coms-net-server.ts +0 -1642
- package/extensions/coms/coms-net-wrapper.ts +0 -521
- package/extensions/coms/coms-net.ts +0 -1589
|
@@ -2,11 +2,13 @@
|
|
|
2
2
|
* Async agent infrastructure — background agent spawning, polling, result watching.
|
|
3
3
|
*/
|
|
4
4
|
|
|
5
|
+
import { createLogger } from "../shared/logger.js";
|
|
5
6
|
import { execFileSync, execSync, spawn, spawnSync } from "node:child_process";
|
|
6
7
|
import * as fs from "node:fs";
|
|
7
8
|
import * as os from "node:os";
|
|
8
9
|
import * as path from "node:path";
|
|
9
10
|
import { createRequire } from "node:module";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
10
12
|
|
|
11
13
|
const require = createRequire(import.meta.url);
|
|
12
14
|
|
|
@@ -16,18 +18,31 @@ import { discoverAgents } from "./agents.js";
|
|
|
16
18
|
import { resolveAgentModelProvider } from "./resolve-agent-model.js";
|
|
17
19
|
import { getPiInvocation, getProjectTmpDir, parseProcStartTime, djb2Hash } from "./utils.js";
|
|
18
20
|
import { addReviewerPending, recordReviewerResult, countFindings, readReviewState, markTestsPassed, markTestsFailed } from "./pi-config-review-state.js";
|
|
19
|
-
import {
|
|
21
|
+
import {
|
|
22
|
+
getMainBranch,
|
|
23
|
+
} from "./git-helpers.js";
|
|
20
24
|
import { waitForResultFiles } from "./async-wait.js";
|
|
21
25
|
import { openAsyncStatusOverlay } from "./async-status-ui.js";
|
|
26
|
+
const log = createLogger("async_agents");
|
|
27
|
+
|
|
22
28
|
export { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
|
|
23
29
|
import { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
|
|
24
30
|
import { setSlot } from "./status-bar.js";
|
|
31
|
+
import { getSetting } from "./project-settings.js";
|
|
32
|
+
import { substituteSettingsPlaceholders } from "./rule-placeholders.js";
|
|
33
|
+
|
|
34
|
+
const SETTINGS_KEYS: Record<string, unknown> = JSON.parse(
|
|
35
|
+
fs.readFileSync(
|
|
36
|
+
path.join(path.dirname(fileURLToPath(import.meta.url)), "..", "..", "settings-keys.json"),
|
|
37
|
+
"utf-8",
|
|
38
|
+
),
|
|
39
|
+
);
|
|
25
40
|
|
|
26
41
|
// ── Constants ────────────────────────────────────────────────────────────
|
|
27
42
|
|
|
28
43
|
|
|
29
44
|
|
|
30
|
-
|
|
45
|
+
// Resolved per-call from settings
|
|
31
46
|
|
|
32
47
|
// ── Interfaces ───────────────────────────────────────────────────────────
|
|
33
48
|
|
|
@@ -44,6 +59,7 @@ export interface AsyncJob {
|
|
|
44
59
|
exitCode?: number | null;
|
|
45
60
|
durationMs?: number;
|
|
46
61
|
delivered?: boolean;
|
|
62
|
+
sideEffectsApplied?: boolean;
|
|
47
63
|
fireAndForget?: boolean;
|
|
48
64
|
onComplete?: () => void;
|
|
49
65
|
groupId?: string;
|
|
@@ -52,6 +68,7 @@ export interface AsyncJob {
|
|
|
52
68
|
projectCwd?: string;
|
|
53
69
|
sessionId?: string;
|
|
54
70
|
model?: string;
|
|
71
|
+
restoredPid?: number;
|
|
55
72
|
}
|
|
56
73
|
|
|
57
74
|
/** Get the effective working directory for a job. */
|
|
@@ -101,20 +118,13 @@ export function registerAsyncAgents(
|
|
|
101
118
|
pi: ExtensionAPI,
|
|
102
119
|
terminalNotify: (title: string, body: string) => void,
|
|
103
120
|
): {
|
|
104
|
-
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 }) => { id: string; error?: string; model?: string };
|
|
121
|
+
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 };
|
|
105
122
|
killAsyncAgent: (target: string) => { killed: string[]; errors: string[] };
|
|
106
123
|
getAsyncJobs: () => Array<{ id: string; agent: string; name?: string; task: string; status: string; startedAt: number }>;
|
|
107
124
|
} {
|
|
108
125
|
let PROJECT_TMP_DIR = path.join(process.cwd(), ".pi", "tmp"); // Computed only; created on session_start
|
|
109
126
|
let ASYNC_RESULTS_DIR = ""; // Set on session_start to project-scoped dir
|
|
110
127
|
|
|
111
|
-
const ASYNC_DEBUG = !!process.env.PI_ASYNC_DEBUG;
|
|
112
|
-
const EARLY_LOG_PATH = ASYNC_DEBUG ? path.join(PROJECT_TMP_DIR, `early-debug-${process.pid}.log`) : "";
|
|
113
|
-
let DEBUG_LOG_PATH = EARLY_LOG_PATH; // Starts with early log, moved to project dir on session_start
|
|
114
|
-
function asyncLog(msg: string) {
|
|
115
|
-
if (!ASYNC_DEBUG || !DEBUG_LOG_PATH) return;
|
|
116
|
-
try { fs.appendFileSync(DEBUG_LOG_PATH, `${new Date().toISOString()} ${msg}\n`); } catch {}
|
|
117
|
-
}
|
|
118
128
|
|
|
119
129
|
const asyncState: AsyncState = {
|
|
120
130
|
jobs: new Map(),
|
|
@@ -123,6 +133,20 @@ export function registerAsyncAgents(
|
|
|
123
133
|
lastCtx: null,
|
|
124
134
|
};
|
|
125
135
|
|
|
136
|
+
// Track job IDs that have already been delivered in this session (including previous lifecycles).
|
|
137
|
+
// Populated from status.json delivered flags on session_start to prevent re-delivery after reload.
|
|
138
|
+
const deliveredResultIds = new Set<string>();
|
|
139
|
+
|
|
140
|
+
/** Check if a job result was already delivered in a previous session lifecycle. */
|
|
141
|
+
function wasAlreadyDelivered(jobId: string): boolean {
|
|
142
|
+
return deliveredResultIds.has(jobId);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Record a job result as delivered so it won't be re-sent after reload. */
|
|
146
|
+
function recordDelivered(jobId: string): void {
|
|
147
|
+
deliveredResultIds.add(jobId);
|
|
148
|
+
}
|
|
149
|
+
|
|
126
150
|
let lastWidgetKey = "";
|
|
127
151
|
function updateAsyncWidget() {
|
|
128
152
|
if (!asyncState.lastCtx?.hasUI) return;
|
|
@@ -172,15 +196,88 @@ export function registerAsyncAgents(
|
|
|
172
196
|
for (const job of asyncState.jobs.values()) {
|
|
173
197
|
if (job.status === "complete" || job.status === "failed") continue;
|
|
174
198
|
const status = readAsyncStatus(job.workerDir);
|
|
175
|
-
|
|
199
|
+
log.debug("poller_check", job.id, "workerDir exists", fs.existsSync(job.workerDir), "pid", status?.pid);
|
|
200
|
+
if (!status) {
|
|
201
|
+
// No status file — process never started or died before writing status.
|
|
202
|
+
// Check if workerDir still exists; if not, the job is definitely dead.
|
|
203
|
+
if (!fs.existsSync(job.workerDir)) {
|
|
204
|
+
// Worker dir missing (e.g. cleaned across a reload). Do NOT declare
|
|
205
|
+
// death if the worker process is still alive — trust the live PID.
|
|
206
|
+
const restoredPid = job.restoredPid;
|
|
207
|
+
const stillAlive = restoredPid
|
|
208
|
+
? (() => { try { process.kill(restoredPid, 0); return true; } catch { return false; } })()
|
|
209
|
+
: false;
|
|
210
|
+
if (stillAlive) {
|
|
211
|
+
const elapsed = Date.now() - job.startedAt;
|
|
212
|
+
if (elapsed <= getSetting(jobCwd(job), "async_phantom_timeout_ms")) {
|
|
213
|
+
log.debug(`restored job dir missing but pid ${restoredPid} alive — waiting`, job.id);
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
// else fall through to failure below (grace window exceeded)
|
|
217
|
+
}
|
|
218
|
+
job.status = "failed";
|
|
219
|
+
job.output = "Agent process died without writing status";
|
|
220
|
+
job.durationMs = Date.now() - job.startedAt;
|
|
221
|
+
job.updatedAt = Date.now();
|
|
222
|
+
job.sideEffectsApplied = true;
|
|
223
|
+
log.debug(`phantom: ${job.id} — no status file, worker dir missing`);
|
|
224
|
+
if (job.agent.startsWith("code-reviewer-")) {
|
|
225
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
|
|
226
|
+
}
|
|
227
|
+
if (job.groupId) {
|
|
228
|
+
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
229
|
+
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
230
|
+
if (pending.length === 0) deliverGroupResults(groupJobs);
|
|
231
|
+
}
|
|
232
|
+
} else {
|
|
233
|
+
// Worker dir exists but no status file — check timeout
|
|
234
|
+
const elapsed = Date.now() - job.startedAt;
|
|
235
|
+
if (elapsed > getSetting(jobCwd(job), "async_phantom_timeout_ms")) {
|
|
236
|
+
job.status = "failed";
|
|
237
|
+
job.output = `Agent process timed out — no status file after ${Math.round(elapsed / 1000)}s`;
|
|
238
|
+
job.durationMs = elapsed;
|
|
239
|
+
job.updatedAt = Date.now();
|
|
240
|
+
job.sideEffectsApplied = true;
|
|
241
|
+
log.debug(`phantom-timeout: ${job.id} — no status file after ${Math.round(elapsed / 1000)}s`);
|
|
242
|
+
if (job.agent.startsWith("code-reviewer-")) {
|
|
243
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
|
|
244
|
+
}
|
|
245
|
+
if (job.groupId) {
|
|
246
|
+
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
247
|
+
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
248
|
+
if (pending.length === 0) deliverGroupResults(groupJobs);
|
|
249
|
+
}
|
|
250
|
+
updateAsyncWidget();
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
continue;
|
|
254
|
+
}
|
|
255
|
+
{
|
|
176
256
|
job.status = status.state;
|
|
177
257
|
job.updatedAt = status.lastUpdate ?? Date.now();
|
|
178
258
|
if (status.exitCode !== undefined) job.exitCode = status.exitCode;
|
|
179
259
|
|
|
180
260
|
// Check if process is actually alive — clean up zombies
|
|
181
|
-
if (job.status === "running" && status.pid) {
|
|
261
|
+
if (job.status === "running" && !status.pid) {
|
|
262
|
+
// Status file exists but no PID — process died before recording PID
|
|
263
|
+
job.status = "failed";
|
|
264
|
+
job.output = "Agent process died before recording PID";
|
|
265
|
+
job.durationMs = Date.now() - job.startedAt;
|
|
266
|
+
job.updatedAt = Date.now();
|
|
267
|
+
job.sideEffectsApplied = true;
|
|
268
|
+
log.debug(`phantom: ${job.id} — status file exists but no PID`);
|
|
269
|
+
if (job.agent.startsWith("code-reviewer-")) {
|
|
270
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
|
|
271
|
+
}
|
|
272
|
+
if (job.groupId) {
|
|
273
|
+
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
274
|
+
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
275
|
+
if (pending.length === 0) deliverGroupResults(groupJobs);
|
|
276
|
+
}
|
|
277
|
+
} else if (job.status === "running" && status.pid) {
|
|
182
278
|
try { process.kill(status.pid, 0); } catch {
|
|
183
279
|
// Process exited — check if it wrote a result file first
|
|
280
|
+
log.debug("poller_zombie", job.id, "pid", status.pid, "checking result file");
|
|
184
281
|
const resultFilePath = path.join(ASYNC_RESULTS_DIR, `${job.id}.json`);
|
|
185
282
|
if (fs.existsSync(resultFilePath)) {
|
|
186
283
|
// Result file exists — ingest it so output/durationMs are populated
|
|
@@ -191,29 +288,40 @@ export function registerAsyncAgents(
|
|
|
191
288
|
job.exitCode = data.exitCode;
|
|
192
289
|
job.durationMs = data.durationMs;
|
|
193
290
|
job.updatedAt = Date.now();
|
|
291
|
+
log.debug("poller_zombie_ingest", job.id, "status", job.status, "output_len", job.output?.length);
|
|
292
|
+
let zombieSideEffectsOk = true;
|
|
194
293
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
195
294
|
const findings = countFindings(typeof data.output === "string" ? data.output : "");
|
|
196
|
-
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch {}
|
|
295
|
+
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { zombieSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
197
296
|
}
|
|
297
|
+
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
298
|
+
try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { zombieSideEffectsOk = false; log.error(`markTests failed for ${job.agent}: ${e?.message}`); }
|
|
299
|
+
}
|
|
300
|
+
if (zombieSideEffectsOk) job.sideEffectsApplied = true;
|
|
198
301
|
try { fs.unlinkSync(resultFilePath); } catch {}
|
|
199
302
|
} catch {
|
|
200
303
|
job.status = "failed";
|
|
201
304
|
job.output = "Process exited before result could be read";
|
|
202
305
|
job.durationMs = Date.now() - job.startedAt;
|
|
203
306
|
job.updatedAt = Date.now();
|
|
307
|
+
let catchSideEffectsOk = true;
|
|
204
308
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
205
|
-
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
|
|
309
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { catchSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
206
310
|
}
|
|
311
|
+
if (catchSideEffectsOk) job.sideEffectsApplied = true;
|
|
207
312
|
}
|
|
208
313
|
} else {
|
|
209
314
|
job.status = "failed";
|
|
210
315
|
job.output = "Process exited without producing results";
|
|
211
316
|
job.durationMs = Date.now() - job.startedAt;
|
|
212
317
|
job.updatedAt = Date.now();
|
|
318
|
+
log.debug("poller_zombie_no_result", job.id, "agent", job.agent, "pid", status?.pid, "elapsed_ms", Date.now() - job.startedAt);
|
|
213
319
|
// Record killed reviewer as having 0 findings — prevents permanent commit block
|
|
320
|
+
let noFileSideEffectsOk = true;
|
|
214
321
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
215
|
-
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {
|
|
322
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { noFileSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
216
323
|
}
|
|
324
|
+
if (noFileSideEffectsOk) job.sideEffectsApplied = true;
|
|
217
325
|
}
|
|
218
326
|
// Check if this completes a group — deliver remaining siblings' results
|
|
219
327
|
if (job.groupId) {
|
|
@@ -224,26 +332,126 @@ export function registerAsyncAgents(
|
|
|
224
332
|
}
|
|
225
333
|
}
|
|
226
334
|
}
|
|
335
|
+
|
|
227
336
|
}
|
|
228
337
|
|
|
229
338
|
// Fallback: check for result files the watcher may have missed
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
339
|
+
if (fs.existsSync(ASYNC_RESULTS_DIR)) {
|
|
340
|
+
try {
|
|
341
|
+
const files = fs.readdirSync(ASYNC_RESULTS_DIR).filter(f => f.endsWith(".json"));
|
|
342
|
+
for (const file of files) {
|
|
343
|
+
try {
|
|
344
|
+
processResultFile(path.join(ASYNC_RESULTS_DIR, file));
|
|
345
|
+
} catch (e: any) { log.error(`processResultFile error for ${file}: ${e?.message}`); }
|
|
346
|
+
}
|
|
347
|
+
} catch (e: any) {
|
|
348
|
+
// ENOENT = directory removed between existsSync and readdirSync — expected race
|
|
349
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
350
|
+
log.error(`result file scan error: ${e?.message}`);
|
|
351
|
+
}
|
|
234
352
|
}
|
|
235
|
-
}
|
|
353
|
+
}
|
|
236
354
|
|
|
237
|
-
//
|
|
355
|
+
// Reconciliation pass — retry side-effects + delivery for done-but-undelivered jobs
|
|
356
|
+
for (const [id, job] of asyncState.jobs.entries()) {
|
|
357
|
+
if ((job.status === "complete" || job.status === "failed") && !job.delivered) {
|
|
358
|
+
// Side-effects: ensure they fired (only mark applied when ALL succeed)
|
|
359
|
+
if (!job.sideEffectsApplied) {
|
|
360
|
+
let sideEffectsOk = true;
|
|
361
|
+
if (job.agent.startsWith("code-reviewer-")) {
|
|
362
|
+
const findings = countFindings(typeof job.output === "string" ? job.output : "");
|
|
363
|
+
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
364
|
+
}
|
|
365
|
+
if (job.agent === "test-automator" || job.agent === "test-runner") {
|
|
366
|
+
try { job.status === "complete" ? markTestsPassed(jobCwd(job)) : markTestsFailed(jobCwd(job)); } catch (e: any) { sideEffectsOk = false; log.error(`reconcile: markTests failed for ${job.agent}: ${e?.message}`); }
|
|
367
|
+
}
|
|
368
|
+
if (sideEffectsOk) job.sideEffectsApplied = true;
|
|
369
|
+
}
|
|
370
|
+
// Delivery: retry for groups where all members are done
|
|
371
|
+
if (job.groupId) {
|
|
372
|
+
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
373
|
+
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
374
|
+
if (pending.length === 0) {
|
|
375
|
+
deliverGroupResults(groupJobs);
|
|
376
|
+
}
|
|
377
|
+
// Delivery: retry for non-grouped jobs (zombie-ingest path may skip delivery)
|
|
378
|
+
} else if (asyncState.lastCtx && !job.fireAndForget) {
|
|
379
|
+
const displayName = job.name || job.agent;
|
|
380
|
+
const resultStatus = job.status === "complete" ? "✅ completed" : "❌ failed";
|
|
381
|
+
const duration = job.durationMs || (job.updatedAt ? job.updatedAt - job.startedAt : 0);
|
|
382
|
+
// Auto-complete linked task (with logging, matching processResultFile behavior)
|
|
383
|
+
let autoCompleteError = "";
|
|
384
|
+
if (job.taskId && job.taskId !== "-1" && job.status === "complete" && job.cwd) {
|
|
385
|
+
try {
|
|
386
|
+
autoCompleteTask(job.taskId, job.projectCwd || job.cwd, job.sessionId)
|
|
387
|
+
.then((completed) => log.info(`reconcile: auto-completed task #${job.taskId}: ${completed}`))
|
|
388
|
+
.catch((e: any) => log.error(`reconcile: auto-complete failed for task #${job.taskId}: ${e?.message}`));
|
|
389
|
+
} catch (e: any) {
|
|
390
|
+
const safeTaskId = /^-?\d+$/.test(job.taskId!) ? job.taskId : "(invalid)";
|
|
391
|
+
autoCompleteError = `\n\n⚠️ Failed to auto-complete task #${safeTaskId}: ${e?.message}. Run TaskUpdate(taskId="${safeTaskId}", status="completed") manually.`;
|
|
392
|
+
log.error(`reconcile: auto-complete failed for task #${safeTaskId}: ${e?.message}`);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
// Enforce same output-length budget as processResultFile
|
|
396
|
+
const maxOutput = 3000 - autoCompleteError.length;
|
|
397
|
+
const output = (job.output || "").slice(0, Math.max(maxOutput, 500));
|
|
398
|
+
// Emit lifecycle events
|
|
399
|
+
if (job.status === "complete") {
|
|
400
|
+
pi.events.emit("subagents:completed", { id: job.id, result: job.output || "" });
|
|
401
|
+
} else {
|
|
402
|
+
pi.events.emit("subagents:failed", { id: job.id, error: job.output || "Agent failed", result: job.output || "", status: "failed" });
|
|
403
|
+
}
|
|
404
|
+
const msgContent = `## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}${autoCompleteError}`;
|
|
405
|
+
if (wasAlreadyDelivered(job.id)) {
|
|
406
|
+
job.delivered = true;
|
|
407
|
+
log.debug(`reconcile: skipping already-delivered result for ${job.id}`);
|
|
408
|
+
} else {
|
|
409
|
+
log.debug("reconcile_deliver", job.id, "hasCtx", !!asyncState.lastCtx);
|
|
410
|
+
try {
|
|
411
|
+
pi.sendMessage({
|
|
412
|
+
customType: "async-agent-result",
|
|
413
|
+
content: msgContent,
|
|
414
|
+
display: true,
|
|
415
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
416
|
+
if (job.onComplete) {
|
|
417
|
+
try { job.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${job.id}: ${e?.message}`); }
|
|
418
|
+
}
|
|
419
|
+
job.delivered = true;
|
|
420
|
+
recordDelivered(job.id);
|
|
421
|
+
log.debug("reconcile_deliver_ok", job.id);
|
|
422
|
+
} catch (e: any) {
|
|
423
|
+
log.error(`reconcile: sendMessage failed for ${job.id}: ${e?.message}`);
|
|
424
|
+
// delivered stays false — will retry on next poll
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
// Persist delivered state so restore skips this job
|
|
428
|
+
if (job.delivered) {
|
|
429
|
+
try {
|
|
430
|
+
const sp = path.join(job.workerDir, "status.json");
|
|
431
|
+
const ex = fs.existsSync(sp) ? JSON.parse(fs.readFileSync(sp, "utf-8")) : {};
|
|
432
|
+
ex.delivered = true;
|
|
433
|
+
fs.writeFileSync(sp, JSON.stringify(ex), { mode: 0o600 });
|
|
434
|
+
} catch {}
|
|
435
|
+
}
|
|
436
|
+
} else if (job.fireAndForget) {
|
|
437
|
+
if (job.onComplete) {
|
|
438
|
+
try { job.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${job.id}: ${e?.message}`); }
|
|
439
|
+
}
|
|
440
|
+
job.delivered = true;
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
// Remove completed/failed jobs older than 30s — require delivered before cleanup
|
|
238
446
|
for (const [id, job] of asyncState.jobs.entries()) {
|
|
239
447
|
if ((job.status === "complete" || job.status === "failed")
|
|
240
448
|
&& Date.now() - job.updatedAt > 30000
|
|
241
|
-
&&
|
|
449
|
+
&& job.delivered) {
|
|
242
450
|
asyncState.jobs.delete(id);
|
|
243
451
|
}
|
|
244
452
|
}
|
|
245
453
|
updateAsyncWidget();
|
|
246
|
-
},
|
|
454
|
+
}, getSetting(process.cwd(), "async_poll_interval_ms"));
|
|
247
455
|
if (asyncState.poller.unref) asyncState.poller.unref();
|
|
248
456
|
}
|
|
249
457
|
|
|
@@ -260,7 +468,6 @@ export function registerAsyncAgents(
|
|
|
260
468
|
// Ingest any unprocessed result files — zombie/kill paths may trigger delivery
|
|
261
469
|
// before processResultFile() has read all group members' outputs.
|
|
262
470
|
// Wait up to 2s total (not per-job) for all missing result files.
|
|
263
|
-
const lateIngestedIds = new Set<string>();
|
|
264
471
|
// Wait for non-failed, non-fireAndForget jobs that haven't been ingested yet
|
|
265
472
|
const waitJobs = groupJobs.filter(j => j.output === undefined && !j.fireAndForget && j.status !== "failed");
|
|
266
473
|
if (waitJobs.length > 0) {
|
|
@@ -277,40 +484,57 @@ export function registerAsyncAgents(
|
|
|
277
484
|
j.exitCode = data.exitCode;
|
|
278
485
|
j.durationMs = data.durationMs;
|
|
279
486
|
if (data.success !== undefined) j.status = data.success ? "complete" : "failed";
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
} catch (e: any) { asyncLog(`deliverGroupResults: late-ingest failed for ${j.id}: ${e?.message}`); }
|
|
487
|
+
log.debug(`deliverGroupResults: late-ingested result for ${j.id}`);
|
|
488
|
+
} catch (e: any) { log.error(`deliverGroupResults: late-ingest failed for ${j.id}: ${e?.message}`); }
|
|
283
489
|
}
|
|
284
490
|
|
|
285
|
-
// Track reviewer completions for
|
|
286
|
-
//
|
|
491
|
+
// Track reviewer completions for ALL code-reviewer jobs in the group.
|
|
492
|
+
// processResultFile may have already called recordReviewerResult for some,
|
|
493
|
+
// but recordReviewerResult is idempotent (skips if reviewer already reported).
|
|
287
494
|
for (const j of groupJobs) {
|
|
288
|
-
if (!j.agent.startsWith("code-reviewer-")
|
|
495
|
+
if (!j.agent.startsWith("code-reviewer-")) continue;
|
|
289
496
|
try {
|
|
290
497
|
const output = typeof j.output === "string" ? j.output : "";
|
|
291
498
|
const findings = countFindings(output);
|
|
292
499
|
// -1 means invalid JSON output — treat conservatively as having findings
|
|
293
500
|
recordReviewerResult(jobCwd(j), j.agent, findings < 0 ? 1 : findings);
|
|
294
|
-
} catch {
|
|
501
|
+
} catch (e: any) { j.sideEffectsApplied = false; log.error(`recordReviewerResult failed for ${j.agent}: ${e?.message}`); continue; }
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Track test agent completions for grouped test-automator/test-runner
|
|
505
|
+
for (const j of groupJobs) {
|
|
506
|
+
if (j.agent !== "test-automator" && j.agent !== "test-runner") continue;
|
|
507
|
+
try {
|
|
508
|
+
if (j.status === "complete") {
|
|
509
|
+
markTestsPassed(jobCwd(j));
|
|
510
|
+
} else if (j.status === "failed") {
|
|
511
|
+
markTestsFailed(jobCwd(j));
|
|
512
|
+
}
|
|
513
|
+
} catch (e: any) { j.sideEffectsApplied = false; log.error(`markTests failed for ${j.agent}: ${e?.message}`); continue; }
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
// Mark side-effects applied per-job (only if not already marked false by a failed side-effect above)
|
|
517
|
+
for (const j of groupJobs) {
|
|
518
|
+
if (j.sideEffectsApplied !== false) j.sideEffectsApplied = true;
|
|
295
519
|
}
|
|
296
520
|
|
|
297
521
|
// Skip delivery if ALL jobs in group are fire-and-forget
|
|
298
522
|
if (groupJobs.every(j => j.fireAndForget)) {
|
|
299
523
|
for (const j of groupJobs) {
|
|
300
524
|
if (j.onComplete) {
|
|
301
|
-
try { j.onComplete(); } catch (e: any) {
|
|
525
|
+
try { j.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${j.id}: ${e?.message}`); }
|
|
302
526
|
}
|
|
303
527
|
j.delivered = true;
|
|
304
528
|
}
|
|
305
529
|
// Clean up result files for fire-and-forget groups
|
|
306
530
|
for (const j of groupJobs) {
|
|
307
531
|
const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
|
|
308
|
-
try { fs.unlinkSync(rp); } catch (e: any) {
|
|
532
|
+
try { fs.unlinkSync(rp); } catch (e: any) { log.debug(`unlink failed ${rp}: ${e?.message}`); }
|
|
309
533
|
}
|
|
310
534
|
return;
|
|
311
535
|
}
|
|
312
536
|
|
|
313
|
-
// Emit lifecycle events for
|
|
537
|
+
// Emit lifecycle events for pitasks RPC bridge (one per job)
|
|
314
538
|
for (const j of groupJobs) {
|
|
315
539
|
if (j.status === "complete") {
|
|
316
540
|
pi.events.emit("subagents:completed", { id: j.id, result: j.output || "" });
|
|
@@ -320,6 +544,7 @@ export function registerAsyncAgents(
|
|
|
320
544
|
}
|
|
321
545
|
|
|
322
546
|
const sections: string[] = [];
|
|
547
|
+
const deliverableJobs: AsyncJob[] = [];
|
|
323
548
|
for (const j of groupJobs) {
|
|
324
549
|
if (j.fireAndForget) { j.delivered = true; continue; }
|
|
325
550
|
const resultStatus = j.status === "complete" ? "✅ completed" : "❌ failed";
|
|
@@ -330,29 +555,50 @@ export function registerAsyncAgents(
|
|
|
330
555
|
if (j.taskId && j.taskId !== "-1" && j.status === "complete" && j.cwd) {
|
|
331
556
|
try {
|
|
332
557
|
const completed = await autoCompleteTask(j.taskId, j.projectCwd || j.cwd, j.sessionId);
|
|
333
|
-
|
|
558
|
+
log.info(`auto-completed task #${j.taskId}: ${completed}`);
|
|
334
559
|
} catch (e: any) {
|
|
335
560
|
autoCompleteError = `\n\n⚠️ Failed to auto-complete task #${j.taskId}: ${e?.message}. Run TaskUpdate(taskId="${j.taskId}", status="completed") manually.`;
|
|
336
|
-
|
|
561
|
+
log.error(`auto-complete failed for task #${j.taskId}: ${e?.message}`);
|
|
337
562
|
}
|
|
338
563
|
}
|
|
339
564
|
const duration = j.durationMs || (j.updatedAt ? j.updatedAt - j.startedAt : 0);
|
|
340
565
|
sections.push(`## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${j.task}\nDuration: ${formatDuration(duration)}\n\n${output}${autoCompleteError}`);
|
|
341
|
-
j
|
|
566
|
+
deliverableJobs.push(j);
|
|
342
567
|
}
|
|
343
568
|
|
|
344
569
|
if (sections.length > 0) {
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
570
|
+
try {
|
|
571
|
+
pi.sendMessage({
|
|
572
|
+
customType: "async-agent-result",
|
|
573
|
+
content: sections.join("\n\n---\n\n"),
|
|
574
|
+
display: true,
|
|
575
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
576
|
+
// Only mark delivered AFTER sendMessage succeeds
|
|
577
|
+
for (const j of deliverableJobs) {
|
|
578
|
+
j.delivered = true;
|
|
579
|
+
// Persist delivered state so restore skips this job
|
|
580
|
+
try {
|
|
581
|
+
const sp = path.join(j.workerDir, "status.json");
|
|
582
|
+
const ex = fs.existsSync(sp) ? JSON.parse(fs.readFileSync(sp, "utf-8")) : {};
|
|
583
|
+
ex.delivered = true;
|
|
584
|
+
fs.writeFileSync(sp, JSON.stringify(ex), { mode: 0o600 });
|
|
585
|
+
} catch {}
|
|
586
|
+
}
|
|
587
|
+
} catch (e: any) {
|
|
588
|
+
log.error(`deliverGroupResults: sendMessage failed: ${e?.message}`);
|
|
589
|
+
// delivered stays false — reconciliation will retry on next poll
|
|
590
|
+
}
|
|
591
|
+
} else {
|
|
592
|
+
// No sections to deliver (all fire-and-forget) — already marked above
|
|
350
593
|
}
|
|
351
594
|
|
|
352
|
-
// Clean up result files
|
|
595
|
+
// Clean up any remaining result files (most already deleted by processResultFile)
|
|
353
596
|
for (const j of groupJobs) {
|
|
354
597
|
const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
|
|
355
|
-
try { fs.unlinkSync(rp); } catch (e: any) {
|
|
598
|
+
try { fs.unlinkSync(rp); } catch (e: any) {
|
|
599
|
+
// ENOENT = already deleted by processResultFile — expected
|
|
600
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") log.debug(`unlink failed ${rp}: ${e?.message}`);
|
|
601
|
+
}
|
|
356
602
|
}
|
|
357
603
|
} finally {
|
|
358
604
|
if (gid) groupDeliveryInProgress.delete(gid);
|
|
@@ -360,14 +606,32 @@ export function registerAsyncAgents(
|
|
|
360
606
|
}
|
|
361
607
|
|
|
362
608
|
async function processResultFile(resultPath: string) {
|
|
609
|
+
if (!fs.existsSync(resultPath)) {
|
|
610
|
+
log.debug(`processResultFile: file already deleted: ${path.basename(resultPath)}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
363
613
|
try {
|
|
364
614
|
const data = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
365
615
|
const job = asyncState.jobs.get(data.id);
|
|
366
|
-
|
|
367
|
-
if (!job)
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
616
|
+
log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!asyncState.lastCtx} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
|
|
617
|
+
if (!job) {
|
|
618
|
+
// Orphan result file — job not in current session. Clean up to prevent re-delivery on reload.
|
|
619
|
+
try { fs.unlinkSync(resultPath); } catch (e: any) { log.debug(`orphan cleanup failed: ${resultPath}: ${e?.message}`); }
|
|
620
|
+
return;
|
|
621
|
+
}
|
|
622
|
+
if (job.delivered) {
|
|
623
|
+
// Already delivered — clean up stale file
|
|
624
|
+
log.debug("processResultFile_skip_delivered", job.id);
|
|
625
|
+
try { fs.unlinkSync(resultPath); } catch (e: any) { log.debug(`stale cleanup failed: ${resultPath}: ${e?.message}`); }
|
|
626
|
+
return;
|
|
627
|
+
}
|
|
628
|
+
// Skip if already ingested — delete file to prevent re-scan every 3s
|
|
629
|
+
if (job.output !== undefined) {
|
|
630
|
+
try { fs.unlinkSync(resultPath); } catch (e: any) {
|
|
631
|
+
if ((e as NodeJS.ErrnoException).code !== "ENOENT") log.debug(`re-ingest unlink failed ${resultPath}: ${e?.message}`);
|
|
632
|
+
}
|
|
633
|
+
return;
|
|
634
|
+
}
|
|
371
635
|
|
|
372
636
|
// Update job state immediately (before group check)
|
|
373
637
|
job.status = data.success ? "complete" : "failed";
|
|
@@ -381,13 +645,14 @@ export function registerAsyncAgents(
|
|
|
381
645
|
terminalNotify("pi", `Async agent ${displayName} ${data.success ? "completed" : "failed"} (${formatDuration(data.durationMs)})`);
|
|
382
646
|
|
|
383
647
|
// Track reviewer completion for review loop enforcement
|
|
648
|
+
let processResultSideEffectsOk = true;
|
|
384
649
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
385
650
|
try {
|
|
386
651
|
const output = typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? "");
|
|
387
652
|
const findings = countFindings(output);
|
|
388
653
|
// -1 means invalid JSON output — treat conservatively as having findings
|
|
389
654
|
recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings);
|
|
390
|
-
} catch {
|
|
655
|
+
} catch (e: any) { processResultSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
391
656
|
// Clear reviewer session if context usage > 80% to prevent overflow on next cycle
|
|
392
657
|
// Use model context window from the agent's effective model.
|
|
393
658
|
// If agent uses a custom model, we pass its context window via the config.
|
|
@@ -406,7 +671,7 @@ export function registerAsyncAgents(
|
|
|
406
671
|
for (const f of files) {
|
|
407
672
|
if (f.includes(sessId)) {
|
|
408
673
|
fs.unlinkSync(path.join(sessPath, f));
|
|
409
|
-
|
|
674
|
+
log.info(`Cleared session for ${job.agent} (context ${Math.round(pct)}%)`);
|
|
410
675
|
break;
|
|
411
676
|
}
|
|
412
677
|
}
|
|
@@ -423,19 +688,33 @@ export function registerAsyncAgents(
|
|
|
423
688
|
} else {
|
|
424
689
|
markTestsFailed(jobCwd(job));
|
|
425
690
|
}
|
|
426
|
-
} catch (e: any) {
|
|
691
|
+
} catch (e: any) { processResultSideEffectsOk = false; log.error(`markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
|
|
427
692
|
}
|
|
428
693
|
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
694
|
+
if (processResultSideEffectsOk) job.sideEffectsApplied = true;
|
|
695
|
+
|
|
696
|
+
// Always delete result file after ingestion — data is in memory (job.output).
|
|
697
|
+
// Reconciliation uses in-memory data, not the file. Leaving files on disk
|
|
698
|
+
// causes the poller's readdirSync to re-ingest them every 3s.
|
|
699
|
+
try { fs.unlinkSync(resultPath); } catch (e: any) { log.debug(`unlink failed ${resultPath}: ${e?.message}`); }
|
|
700
|
+
|
|
701
|
+
// Persist output summary to status.json so session restore can recover it
|
|
702
|
+
// (result file is now deleted, but output must survive restart)
|
|
703
|
+
try {
|
|
704
|
+
const statusPath = path.join(job.workerDir, "status.json");
|
|
705
|
+
const existing = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
706
|
+
existing.output = (data.output || "").slice(0, 3000);
|
|
707
|
+
existing.state = job.status;
|
|
708
|
+
existing.exitCode = job.exitCode;
|
|
709
|
+
existing.durationMs = job.durationMs;
|
|
710
|
+
fs.writeFileSync(statusPath, JSON.stringify(existing), { mode: 0o600 });
|
|
711
|
+
} catch (e: any) { log.error(`status.json output persist failed for ${job.id}: ${e?.message}`); }
|
|
433
712
|
|
|
434
713
|
// Group-aware delivery: hold results until ALL jobs in the group are done
|
|
435
714
|
if (job.groupId) {
|
|
436
715
|
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
437
716
|
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
438
|
-
|
|
717
|
+
log.info(`group ${job.groupId}: ${groupJobs.length} total, ${pending.length} pending`);
|
|
439
718
|
if (pending.length > 0) {
|
|
440
719
|
// Not all group members done yet — hold delivery
|
|
441
720
|
updateAsyncWidget();
|
|
@@ -447,7 +726,7 @@ export function registerAsyncAgents(
|
|
|
447
726
|
return;
|
|
448
727
|
}
|
|
449
728
|
|
|
450
|
-
// Emit lifecycle events for
|
|
729
|
+
// Emit lifecycle events for pitasks RPC bridge
|
|
451
730
|
if (data.success) {
|
|
452
731
|
pi.events.emit("subagents:completed", { id: job.id, result: data.output || "" });
|
|
453
732
|
} else {
|
|
@@ -455,6 +734,7 @@ export function registerAsyncAgents(
|
|
|
455
734
|
}
|
|
456
735
|
|
|
457
736
|
// Non-grouped job: deliver immediately (existing behavior)
|
|
737
|
+
let sendSucceeded = false;
|
|
458
738
|
if (asyncState.lastCtx && !job.fireAndForget) {
|
|
459
739
|
const resultStatus = data.success ? "✅ completed" : "❌ failed";
|
|
460
740
|
let autoCompleteError = "";
|
|
@@ -462,30 +742,59 @@ export function registerAsyncAgents(
|
|
|
462
742
|
if (job.taskId && job.taskId !== "-1" && data.success && job.cwd) {
|
|
463
743
|
try {
|
|
464
744
|
const completed = await autoCompleteTask(job.taskId, job.projectCwd || job.cwd, job.sessionId);
|
|
465
|
-
|
|
745
|
+
log.info(`auto-completed task #${job.taskId}: ${completed}`);
|
|
466
746
|
} catch (e: any) {
|
|
467
747
|
autoCompleteError = `\n\n⚠️ Failed to auto-complete task #${job.taskId}: ${e?.message}. Run TaskUpdate(taskId="${job.taskId}", status="completed") manually.`;
|
|
468
|
-
|
|
748
|
+
log.error(`auto-complete failed for task #${job.taskId}: ${e?.message}`);
|
|
469
749
|
}
|
|
470
750
|
}
|
|
471
751
|
const maxOutput = 3000 - autoCompleteError.length;
|
|
472
752
|
const output = (data.output || "").slice(0, Math.max(maxOutput, 500));
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
753
|
+
const pContent = `## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${data.task}\nDuration: ${formatDuration(data.durationMs)}\n\n${output}${autoCompleteError}`;
|
|
754
|
+
if (wasAlreadyDelivered(job.id)) {
|
|
755
|
+
// Already delivered in previous lifecycle — skip send AND onComplete
|
|
756
|
+
job.delivered = true;
|
|
757
|
+
log.debug(`processResultFile: skipping already-delivered result for ${job.id}`);
|
|
758
|
+
updateAsyncWidget();
|
|
759
|
+
return;
|
|
760
|
+
} else {
|
|
761
|
+
try {
|
|
762
|
+
pi.sendMessage({
|
|
763
|
+
customType: "async-agent-result",
|
|
764
|
+
content: pContent,
|
|
765
|
+
display: true,
|
|
766
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
767
|
+
sendSucceeded = true;
|
|
768
|
+
recordDelivered(job.id);
|
|
769
|
+
} catch (e: any) {
|
|
770
|
+
log.error(`processResultFile: sendMessage failed for ${job.id}: ${e?.message}`);
|
|
771
|
+
// sendSucceeded stays false — delivered won't be set, reconciliation retries
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
} else if (job.fireAndForget) {
|
|
775
|
+
sendSucceeded = true; // fire-and-forget doesn't need sendMessage
|
|
478
776
|
}
|
|
479
777
|
|
|
480
778
|
// Invoke onComplete callback if registered (e.g., dreaming → rebuildAndOrganize)
|
|
481
779
|
if (job.onComplete) {
|
|
482
|
-
try { job.onComplete(); } catch (e: any) {
|
|
780
|
+
try { job.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${job.id}: ${e?.message}`); }
|
|
781
|
+
}
|
|
782
|
+
if (sendSucceeded) {
|
|
783
|
+
job.delivered = true;
|
|
784
|
+
// Persist delivered state so restore skips this job
|
|
785
|
+
if (job.delivered) {
|
|
786
|
+
try {
|
|
787
|
+
const sp = path.join(job.workerDir, "status.json");
|
|
788
|
+
const ex = fs.existsSync(sp) ? JSON.parse(fs.readFileSync(sp, "utf-8")) : {};
|
|
789
|
+
ex.delivered = true;
|
|
790
|
+
fs.writeFileSync(sp, JSON.stringify(ex), { mode: 0o600 });
|
|
791
|
+
} catch {}
|
|
792
|
+
}
|
|
483
793
|
}
|
|
484
|
-
job.delivered = true;
|
|
485
794
|
// Result file already deleted above (non-grouped path)
|
|
486
795
|
updateAsyncWidget();
|
|
487
796
|
} catch (e: any) {
|
|
488
|
-
|
|
797
|
+
log.error(`processResultFile ERROR: ${e.message}`);
|
|
489
798
|
}
|
|
490
799
|
}
|
|
491
800
|
|
|
@@ -500,6 +809,7 @@ export function registerAsyncAgents(
|
|
|
500
809
|
asyncState.watcher = fs.watch(ASYNC_RESULTS_DIR, (ev, file) => {
|
|
501
810
|
if (ev !== "rename" || !file) return;
|
|
502
811
|
const fileName = file.toString();
|
|
812
|
+
log.debug("watcher_event", ev, fileName);
|
|
503
813
|
if (!fileName.endsWith(".json")) return;
|
|
504
814
|
const resultPath = path.join(ASYNC_RESULTS_DIR, fileName);
|
|
505
815
|
setTimeout(() => processResultFile(resultPath), 100);
|
|
@@ -513,7 +823,7 @@ export function registerAsyncAgents(
|
|
|
513
823
|
task: string,
|
|
514
824
|
cwd: string,
|
|
515
825
|
agents: AgentConfig[],
|
|
516
|
-
options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean },
|
|
826
|
+
options?: { fireAndForget?: boolean; name?: string; parentModelId?: string; parentProvider?: string; groupId?: string; taskId?: string; onComplete?: () => void; persistSession?: boolean; explicit?: { model?: string; provider?: string } },
|
|
517
827
|
): { id: string; error?: string; model?: string } {
|
|
518
828
|
const agent = agents.find(a => a.name === agentName);
|
|
519
829
|
if (!agent) return { id: "", error: `Unknown agent: "${agentName}"` };
|
|
@@ -536,6 +846,7 @@ export function registerAsyncAgents(
|
|
|
536
846
|
if (!parentStartTime) {
|
|
537
847
|
console.debug("[async-agents] WARNING: could not read /proc/self/stat starttime after 3 attempts");
|
|
538
848
|
}
|
|
849
|
+
const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd, options?.explicit);
|
|
539
850
|
fs.writeFileSync(path.join(workerDir, "session.json"), JSON.stringify({
|
|
540
851
|
resultsDir: ASYNC_RESULTS_DIR,
|
|
541
852
|
fireAndForget: options?.fireAndForget || false,
|
|
@@ -572,14 +883,18 @@ export function registerAsyncAgents(
|
|
|
572
883
|
: agentName + ':' + task.slice(0, 100);
|
|
573
884
|
const deterministicSessionId = `async-${agentName}-${djb2Hash(sessionIdSource).toString(36)}`;
|
|
574
885
|
piArgs.push("--session-id", deterministicSessionId);
|
|
575
|
-
const { model: effectiveModel, provider: effectiveProvider } = resolveAgentModelProvider(agentName, agent, options?.parentModelId, options?.parentProvider, cwd);
|
|
576
886
|
if (effectiveModel) piArgs.push("--model", effectiveModel);
|
|
577
887
|
if (effectiveProvider) piArgs.push("--provider", effectiveProvider);
|
|
578
888
|
if (agent.tools?.length) piArgs.push("--tools", agent.tools.join(","));
|
|
579
889
|
|
|
580
890
|
if (agent.systemPrompt?.trim()) {
|
|
581
891
|
const promptPath = path.join(workerDir, "system-prompt.md");
|
|
582
|
-
|
|
892
|
+
const resolvedPrompt = substituteSettingsPlaceholders(
|
|
893
|
+
agent.systemPrompt,
|
|
894
|
+
(key) => getSetting(cwd, key as any),
|
|
895
|
+
Object.keys(SETTINGS_KEYS),
|
|
896
|
+
);
|
|
897
|
+
fs.writeFileSync(promptPath, resolvedPrompt, { mode: 0o600 });
|
|
583
898
|
piArgs.push("--append-system-prompt", promptPath);
|
|
584
899
|
}
|
|
585
900
|
|
|
@@ -614,7 +929,7 @@ export function registerAsyncAgents(
|
|
|
614
929
|
const candidate = path.join(piPkgDir, "node_modules/jiti/lib/jiti-cli.mjs");
|
|
615
930
|
if (fs.existsSync(candidate)) jitiCliPath = candidate;
|
|
616
931
|
} catch (e: any) {
|
|
617
|
-
|
|
932
|
+
log.debug(`jiti strategy 1 (require.resolve) failed: ${e?.message || e}`);
|
|
618
933
|
}
|
|
619
934
|
// Strategy 2: resolve from the pi binary (works for global npm installs)
|
|
620
935
|
if (!jitiCliPath) {
|
|
@@ -628,7 +943,7 @@ export function registerAsyncAgents(
|
|
|
628
943
|
if (fs.existsSync(candidate)) jitiCliPath = candidate;
|
|
629
944
|
}
|
|
630
945
|
} catch (e: any) {
|
|
631
|
-
|
|
946
|
+
log.debug(`jiti strategy 2 (process.argv) failed: ${e?.message || e}`);
|
|
632
947
|
}
|
|
633
948
|
}
|
|
634
949
|
|
|
@@ -641,6 +956,9 @@ export function registerAsyncAgents(
|
|
|
641
956
|
...process.env,
|
|
642
957
|
PI_SUBAGENT_CHILD: "1",
|
|
643
958
|
PI_AGENT_NAME: agentName,
|
|
959
|
+
PI_PRIMARY_MODEL: process.env.PI_PRIMARY_MODEL || process.env.PI_MODEL || "",
|
|
960
|
+
__PI_CONFIG_SESSION_ID: (globalThis as any).__piConfigSessionId || "",
|
|
961
|
+
__PI_PARENT_SESSION_ID: (globalThis as any).__piConfigSessionId || "",
|
|
644
962
|
};
|
|
645
963
|
if (agentName.startsWith("code-reviewer-") || agentName === "test-automator" || agentName === "test-runner") {
|
|
646
964
|
try {
|
|
@@ -664,14 +982,8 @@ export function registerAsyncAgents(
|
|
|
664
982
|
}
|
|
665
983
|
}
|
|
666
984
|
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
stdio: "ignore",
|
|
670
|
-
windowsHide: true,
|
|
671
|
-
env: spawnEnv,
|
|
672
|
-
});
|
|
673
|
-
|
|
674
|
-
// Track the job
|
|
985
|
+
// Register job BEFORE spawn — child can finish and write result file before
|
|
986
|
+
// jobs.set runs; watcher/poller would then treat it as orphan and delete it.
|
|
675
987
|
const job: AsyncJob = {
|
|
676
988
|
id,
|
|
677
989
|
agent: agentName,
|
|
@@ -692,6 +1004,57 @@ export function registerAsyncAgents(
|
|
|
692
1004
|
};
|
|
693
1005
|
asyncState.jobs.set(id, job);
|
|
694
1006
|
|
|
1007
|
+
const proc = spawn(process.execPath, spawnArgs, {
|
|
1008
|
+
cwd,
|
|
1009
|
+
stdio: "ignore",
|
|
1010
|
+
windowsHide: true,
|
|
1011
|
+
env: spawnEnv,
|
|
1012
|
+
});
|
|
1013
|
+
|
|
1014
|
+
proc.once("error", (err) => {
|
|
1015
|
+
log.info(`spawn-error: ${job.id} — ${err.message}`);
|
|
1016
|
+
if (job.status === "complete" || job.status === "failed") return;
|
|
1017
|
+
job.status = "failed";
|
|
1018
|
+
job.output = `Spawn error: ${err.message}`;
|
|
1019
|
+
job.durationMs = Date.now() - job.startedAt;
|
|
1020
|
+
job.updatedAt = Date.now();
|
|
1021
|
+
job.sideEffectsApplied = true;
|
|
1022
|
+
if (job.agent.startsWith("code-reviewer-")) {
|
|
1023
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
|
|
1024
|
+
}
|
|
1025
|
+
if (job.groupId) {
|
|
1026
|
+
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
1027
|
+
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
1028
|
+
if (pending.length === 0) deliverGroupResults(groupJobs);
|
|
1029
|
+
}
|
|
1030
|
+
updateAsyncWidget();
|
|
1031
|
+
});
|
|
1032
|
+
|
|
1033
|
+
proc.once("exit", (code, signal) => {
|
|
1034
|
+
if (job.status === "complete" || job.status === "failed") return;
|
|
1035
|
+
if (code !== 0) {
|
|
1036
|
+
// Give 2s for result file to be written by async-runner
|
|
1037
|
+
setTimeout(() => {
|
|
1038
|
+
if (job.status === "complete" || job.status === "failed") return;
|
|
1039
|
+
log.info(`spawn-exit: ${job.id} — code=${code} signal=${signal}`);
|
|
1040
|
+
job.status = "failed";
|
|
1041
|
+
job.output = `Process exited with code ${code} signal ${signal}`;
|
|
1042
|
+
job.durationMs = Date.now() - job.startedAt;
|
|
1043
|
+
job.updatedAt = Date.now();
|
|
1044
|
+
job.sideEffectsApplied = true;
|
|
1045
|
+
if (job.agent.startsWith("code-reviewer-")) {
|
|
1046
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {}
|
|
1047
|
+
}
|
|
1048
|
+
if (job.groupId) {
|
|
1049
|
+
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
1050
|
+
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
1051
|
+
if (pending.length === 0) deliverGroupResults(groupJobs);
|
|
1052
|
+
}
|
|
1053
|
+
updateAsyncWidget();
|
|
1054
|
+
}, 2000).unref();
|
|
1055
|
+
}
|
|
1056
|
+
});
|
|
1057
|
+
|
|
695
1058
|
// addReviewerPending already called above (before piArgs construction)
|
|
696
1059
|
|
|
697
1060
|
updateAsyncWidget();
|
|
@@ -710,23 +1073,35 @@ export function registerAsyncAgents(
|
|
|
710
1073
|
// Start result watcher on session start
|
|
711
1074
|
pi.on("session_start", (_event, ctx) => {
|
|
712
1075
|
asyncState.lastCtx = ctx;
|
|
1076
|
+
deliveredResultIds.clear();
|
|
713
1077
|
|
|
714
1078
|
// Set project-scoped dir first (getProjectTmpDir creates it if missing)
|
|
715
1079
|
PROJECT_TMP_DIR = getProjectTmpDir(ctx.cwd);
|
|
716
1080
|
// Export as env var so prompts/CLI commands can reference it
|
|
717
1081
|
process.env.PROJECT_TMP_DIR = PROJECT_TMP_DIR;
|
|
718
1082
|
|
|
1083
|
+
// Scan worker directories for jobs with delivered=true in status.json
|
|
1084
|
+
// This is more reliable than content hashing — uses job IDs directly
|
|
1085
|
+
try {
|
|
1086
|
+
for (const entry of fs.readdirSync(PROJECT_TMP_DIR)) {
|
|
1087
|
+
const jobDir = path.join(PROJECT_TMP_DIR, entry);
|
|
1088
|
+
try {
|
|
1089
|
+
if (!fs.statSync(jobDir).isDirectory()) continue;
|
|
1090
|
+
const statusPath = path.join(jobDir, "status.json");
|
|
1091
|
+
if (!fs.existsSync(statusPath)) continue;
|
|
1092
|
+
const status = JSON.parse(fs.readFileSync(statusPath, "utf-8"));
|
|
1093
|
+
if (status.delivered && status.runId) {
|
|
1094
|
+
deliveredResultIds.add(status.runId);
|
|
1095
|
+
}
|
|
1096
|
+
} catch {}
|
|
1097
|
+
}
|
|
1098
|
+
if (deliveredResultIds.size > 0) {
|
|
1099
|
+
log.info(`session_start: found ${deliveredResultIds.size} previously delivered job IDs`);
|
|
1100
|
+
}
|
|
1101
|
+
} catch (e: any) { log.error(`delivered job scan failed: ${e?.message}`); }
|
|
1102
|
+
|
|
719
1103
|
// Set results dir — PID-scoped under project dir
|
|
720
1104
|
ASYNC_RESULTS_DIR = path.join(PROJECT_TMP_DIR, sessionResultsDirName());
|
|
721
|
-
const projectLogPath = path.join(PROJECT_TMP_DIR, "debug.log");
|
|
722
|
-
// Move early startup logs to project dir
|
|
723
|
-
if (EARLY_LOG_PATH && fs.existsSync(EARLY_LOG_PATH)) {
|
|
724
|
-
try {
|
|
725
|
-
fs.appendFileSync(projectLogPath, fs.readFileSync(EARLY_LOG_PATH, "utf-8"));
|
|
726
|
-
fs.unlinkSync(EARLY_LOG_PATH);
|
|
727
|
-
} catch {}
|
|
728
|
-
}
|
|
729
|
-
DEBUG_LOG_PATH = projectLogPath;
|
|
730
1105
|
|
|
731
1106
|
// Zombie cleanup: scan project dir for dead agents
|
|
732
1107
|
try {
|
|
@@ -759,7 +1134,39 @@ export function registerAsyncAgents(
|
|
|
759
1134
|
}
|
|
760
1135
|
} catch (e: any) { console.debug("[async-agents] zombie cleanup failed:", e?.message?.slice(0, 100)); }
|
|
761
1136
|
|
|
762
|
-
|
|
1137
|
+
// Clean up stale result directories from dead sessions
|
|
1138
|
+
try {
|
|
1139
|
+
for (const entry of fs.readdirSync(PROJECT_TMP_DIR)) {
|
|
1140
|
+
if (!entry.startsWith("async-results-pid-")) continue;
|
|
1141
|
+
const resultDir = path.join(PROJECT_TMP_DIR, entry);
|
|
1142
|
+
if (resultDir === ASYNC_RESULTS_DIR) continue; // skip our own
|
|
1143
|
+
try {
|
|
1144
|
+
if (!fs.statSync(resultDir).isDirectory()) continue;
|
|
1145
|
+
// Extract PID and starttime from directory name
|
|
1146
|
+
// Format: async-results-pid-{pid}-{starttime} (current) or async-results-pid-{pid} (legacy)
|
|
1147
|
+
const match = entry.match(/^async-results-pid-(\d+)(?:-(\d+))?$/);
|
|
1148
|
+
if (!match) continue;
|
|
1149
|
+
const dirPid = parseInt(match[1], 10);
|
|
1150
|
+
const dirStartTime = match[2]; // undefined for legacy PID-only format
|
|
1151
|
+
let parentAlive = false;
|
|
1152
|
+
try {
|
|
1153
|
+
const stat = fs.readFileSync(`/proc/${dirPid}/stat`, "utf-8");
|
|
1154
|
+
const currentStartTime = parseProcStartTime(stat);
|
|
1155
|
+
if (currentStartTime) {
|
|
1156
|
+
// With starttime: must match exactly. Without: PID alive = keep (conservative)
|
|
1157
|
+
parentAlive = dirStartTime ? currentStartTime === dirStartTime : true;
|
|
1158
|
+
}
|
|
1159
|
+
} catch {} // /proc not found = dead
|
|
1160
|
+
if (!parentAlive) {
|
|
1161
|
+
// Parent dead — clean up entire result directory
|
|
1162
|
+
try { fs.rmSync(resultDir, { recursive: true, force: true }); } catch {}
|
|
1163
|
+
log.info(`cleaned stale result dir: ${entry}`);
|
|
1164
|
+
}
|
|
1165
|
+
} catch {} // skip unreadable dirs
|
|
1166
|
+
}
|
|
1167
|
+
} catch (e: any) { log.error(`stale result dir cleanup failed: ${e?.message?.slice(0, 100)}`); }
|
|
1168
|
+
|
|
1169
|
+
log.info(`session_start: resultsDir=${path.basename(ASYNC_RESULTS_DIR)}`);
|
|
763
1170
|
|
|
764
1171
|
// Restore jobs from status files in PROJECT_TMP_DIR that belong to this session
|
|
765
1172
|
try {
|
|
@@ -780,8 +1187,17 @@ export function registerAsyncAgents(
|
|
|
780
1187
|
if (!id || asyncState.jobs.has(id)) continue;
|
|
781
1188
|
const isAlive = status.pid ? (() => { try { process.kill(status.pid, 0); return true; } catch { return false; } })() : false;
|
|
782
1189
|
const isComplete = status.state === "complete" || status.state === "failed";
|
|
783
|
-
//
|
|
784
|
-
if (
|
|
1190
|
+
// Skip completed/failed jobs — they were already delivered. Clean up worker dir.
|
|
1191
|
+
if (isComplete) {
|
|
1192
|
+
try { fs.rmSync(jobDir, { recursive: true, force: true }); } catch {}
|
|
1193
|
+
log.debug(`cleaned completed worker dir on restore: ${entry} (state=${status.state})`);
|
|
1194
|
+
continue;
|
|
1195
|
+
}
|
|
1196
|
+
// Only restore running/queued jobs with alive processes
|
|
1197
|
+
log.debug("restore_check", id, "isAlive", isAlive, "state", status.state);
|
|
1198
|
+
if (isAlive || status.state === "running") {
|
|
1199
|
+
// No result file = already processed and delivered — mark as delivered
|
|
1200
|
+
const hasResultFile = fs.existsSync(path.join(ASYNC_RESULTS_DIR, `${id}.json`));
|
|
785
1201
|
const job: AsyncJob = {
|
|
786
1202
|
id,
|
|
787
1203
|
agent: status.agent || "unknown",
|
|
@@ -792,15 +1208,19 @@ export function registerAsyncAgents(
|
|
|
792
1208
|
updatedAt: status.lastUpdate || Date.now(),
|
|
793
1209
|
exitCode: status.exitCode,
|
|
794
1210
|
durationMs: status.endedAt ? status.endedAt - status.startedAt : undefined,
|
|
1211
|
+
output: status.output || undefined,
|
|
1212
|
+
delivered: isComplete,
|
|
1213
|
+
sideEffectsApplied: isComplete,
|
|
795
1214
|
fireAndForget: marker.fireAndForget || false,
|
|
796
1215
|
taskId: marker.taskId || undefined,
|
|
797
1216
|
cwd: marker.cwd || undefined,
|
|
798
1217
|
projectCwd: marker.projectCwd || undefined,
|
|
799
1218
|
sessionId: marker.sessionId || undefined,
|
|
800
1219
|
model: status.model || marker.model || undefined,
|
|
1220
|
+
restoredPid: typeof status.pid === "number" ? status.pid : undefined,
|
|
801
1221
|
};
|
|
802
1222
|
asyncState.jobs.set(id, job);
|
|
803
|
-
|
|
1223
|
+
log.info(`restored job: ${id} state=${job.status}`);
|
|
804
1224
|
}
|
|
805
1225
|
}
|
|
806
1226
|
} catch (e: any) { console.debug("[async-agents] job restore failed:", e?.message || e); }
|
|
@@ -820,7 +1240,7 @@ export function registerAsyncAgents(
|
|
|
820
1240
|
|
|
821
1241
|
// Clean up on shutdown
|
|
822
1242
|
pi.on("session_shutdown", () => {
|
|
823
|
-
|
|
1243
|
+
log.info(`shutdown: jobs=${asyncState.jobs.size}`);
|
|
824
1244
|
if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
|
|
825
1245
|
if (asyncState.watcher) { asyncState.watcher.close(); asyncState.watcher = null; }
|
|
826
1246
|
});
|
|
@@ -889,10 +1309,25 @@ export function registerAsyncAgents(
|
|
|
889
1309
|
killed.push(label);
|
|
890
1310
|
job.status = "failed";
|
|
891
1311
|
job.updatedAt = Date.now();
|
|
1312
|
+
job.durationMs = Date.now() - job.startedAt;
|
|
1313
|
+
// Persist killed state to disk — prevents stale re-delivery on reload
|
|
1314
|
+
try {
|
|
1315
|
+
const statusPath = path.join(job.workerDir, "status.json");
|
|
1316
|
+
const existing = fs.existsSync(statusPath) ? JSON.parse(fs.readFileSync(statusPath, "utf-8")) : {};
|
|
1317
|
+
existing.state = "failed";
|
|
1318
|
+
existing.exitCode = -9;
|
|
1319
|
+
existing.endedAt = Date.now();
|
|
1320
|
+
existing.output = "Killed by user";
|
|
1321
|
+
fs.writeFileSync(statusPath, JSON.stringify(existing), { mode: 0o600 });
|
|
1322
|
+
} catch (e: any) { log.error(`kill: status.json update failed for ${job.id}: ${e?.message}`); }
|
|
1323
|
+
// Delete result file if it exists — prevent re-ingestion on reload
|
|
1324
|
+
try { fs.unlinkSync(path.join(ASYNC_RESULTS_DIR, `${job.id}.json`)); } catch {}
|
|
892
1325
|
// Record killed reviewer as having 0 findings — prevents permanent commit block
|
|
1326
|
+
let killSideEffectsOk = true;
|
|
893
1327
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
894
|
-
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch {
|
|
1328
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { killSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
895
1329
|
}
|
|
1330
|
+
if (killSideEffectsOk) job.sideEffectsApplied = true;
|
|
896
1331
|
// Check if this completes a group — deliver remaining siblings' results
|
|
897
1332
|
if (job.groupId) {
|
|
898
1333
|
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
@@ -900,9 +1335,35 @@ export function registerAsyncAgents(
|
|
|
900
1335
|
if (pending.length === 0) {
|
|
901
1336
|
deliverGroupResults(groupJobs);
|
|
902
1337
|
}
|
|
1338
|
+
} else if (asyncState.lastCtx && !job.fireAndForget) {
|
|
1339
|
+
// Non-grouped killed job — deliver immediately so AI knows it was killed
|
|
1340
|
+
const displayName = job.name || job.agent;
|
|
1341
|
+
const duration = job.durationMs || (Date.now() - job.startedAt);
|
|
1342
|
+
const rawOutput = typeof job.output === "string" ? job.output : "Killed by user";
|
|
1343
|
+
const output = rawOutput.slice(0, 3000);
|
|
1344
|
+
const killContent = `## Async Agent Result: ${displayName} ❌ failed\n\nTask: ${job.task}\nDuration: ${formatDuration(duration)}\n\n${output}`;
|
|
1345
|
+
if (wasAlreadyDelivered(job.id)) {
|
|
1346
|
+
job.delivered = true;
|
|
1347
|
+
log.debug(`kill: skipping already-delivered result for ${job.id}`);
|
|
1348
|
+
} else {
|
|
1349
|
+
try {
|
|
1350
|
+
pi.sendMessage({
|
|
1351
|
+
customType: "async-agent-result",
|
|
1352
|
+
content: killContent,
|
|
1353
|
+
display: true,
|
|
1354
|
+
}, { triggerTurn: true, deliverAs: "followUp" });
|
|
1355
|
+
job.delivered = true;
|
|
1356
|
+
recordDelivered(job.id);
|
|
1357
|
+
} catch (e: any) {
|
|
1358
|
+
log.error(`kill delivery failed for ${job.id}: ${e?.message}`);
|
|
1359
|
+
// delivered stays false — reconciliation will retry
|
|
1360
|
+
}
|
|
1361
|
+
}
|
|
1362
|
+
} else if (job.fireAndForget) {
|
|
1363
|
+
job.delivered = true;
|
|
903
1364
|
}
|
|
904
|
-
// Delay cleanup —
|
|
905
|
-
if (
|
|
1365
|
+
// Delay cleanup — only for delivered jobs
|
|
1366
|
+
if (job.delivered) {
|
|
906
1367
|
setTimeout(() => { asyncState.jobs.delete(job.id); updateAsyncWidget(); }, 5000);
|
|
907
1368
|
}
|
|
908
1369
|
}
|
|
@@ -958,7 +1419,7 @@ export function registerAsyncAgents(
|
|
|
958
1419
|
});
|
|
959
1420
|
|
|
960
1421
|
// ── pi-subagents RPC compatibility bridge ──────────────────────────────
|
|
961
|
-
// Implements the subagents:rpc:* protocol so
|
|
1422
|
+
// Implements the subagents:rpc:* protocol so pitasks' TaskExecute
|
|
962
1423
|
// can spawn our agents. Protocol version 2 matches @tintinweb/pi-subagents.
|
|
963
1424
|
|
|
964
1425
|
const RPC_PROTOCOL_VERSION = 2;
|
|
@@ -988,7 +1449,7 @@ export function registerAsyncAgents(
|
|
|
988
1449
|
return { version: RPC_PROTOCOL_VERSION };
|
|
989
1450
|
});
|
|
990
1451
|
|
|
991
|
-
// Spawn — create an async agent from
|
|
1452
|
+
// Spawn — create an async agent from pitasks' TaskExecute
|
|
992
1453
|
handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
|
|
993
1454
|
"subagents:rpc:spawn", ({ type, prompt, options }) => {
|
|
994
1455
|
const ctx = asyncState.lastCtx;
|
|
@@ -1007,7 +1468,7 @@ export function registerAsyncAgents(
|
|
|
1007
1468
|
|
|
1008
1469
|
const result = spawnAsyncAgent(agentName, prompt, cwd, agents, {
|
|
1009
1470
|
name: options?.description || agentName,
|
|
1010
|
-
taskId: "-1", //
|
|
1471
|
+
taskId: "-1", // pitasks manages its own task linkage via agentTaskMap
|
|
1011
1472
|
fireAndForget: false,
|
|
1012
1473
|
});
|
|
1013
1474
|
|
|
@@ -1024,7 +1485,7 @@ export function registerAsyncAgents(
|
|
|
1024
1485
|
},
|
|
1025
1486
|
);
|
|
1026
1487
|
|
|
1027
|
-
// Emit subagents:ready so
|
|
1488
|
+
// Emit subagents:ready so pitasks can discover us
|
|
1028
1489
|
pi.on("session_start", () => {
|
|
1029
1490
|
pi.events.emit("subagents:ready", {});
|
|
1030
1491
|
});
|