pi-orchestrator-config 4.2.1 → 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 +52 -61
- package/agents/git-expert.md +5 -1
- package/agents/github-expert.md +4 -1
- 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 +509 -85
- package/extensions/orchestrator/cron.ts +14 -8
- package/extensions/orchestrator/dreaming.ts +31 -24
- package/extensions/orchestrator/enforcement-helpers.ts +459 -17
- package/extensions/orchestrator/enforcement-rules.ts +5 -4
- package/extensions/orchestrator/enforcement.ts +79 -25
- package/extensions/orchestrator/extended-autocomplete.ts +7 -0
- package/extensions/orchestrator/git-helpers.ts +2 -2
- package/extensions/orchestrator/index.ts +23 -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/pi-config-review-state.ts +44 -33
- package/extensions/orchestrator/project-settings.ts +220 -21
- package/extensions/orchestrator/review-porcelain.ts +4 -0
- package/extensions/orchestrator/review-ui.ts +11 -5
- package/extensions/orchestrator/rule-placeholders.ts +384 -1
- 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 +105 -50
- 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 +64 -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 +7 -2
- package/prompts/coderabbit-rate-limit.md +2 -0
- package/rules/00-orchestrator-core.md +3 -0
- package/rules/10-agent-routing.md +1 -1
- package/rules/20-code-review-loop.md +38 -16
- 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 +481 -20
- 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,6 +2,7 @@
|
|
|
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";
|
|
@@ -22,6 +23,8 @@ import {
|
|
|
22
23
|
} from "./git-helpers.js";
|
|
23
24
|
import { waitForResultFiles } from "./async-wait.js";
|
|
24
25
|
import { openAsyncStatusOverlay } from "./async-status-ui.js";
|
|
26
|
+
const log = createLogger("async_agents");
|
|
27
|
+
|
|
25
28
|
export { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
|
|
26
29
|
import { autoCompleteTask, autoMarkInProgress } from "./task-lifecycle.js";
|
|
27
30
|
import { setSlot } from "./status-bar.js";
|
|
@@ -39,7 +42,7 @@ const SETTINGS_KEYS: Record<string, unknown> = JSON.parse(
|
|
|
39
42
|
|
|
40
43
|
|
|
41
44
|
|
|
42
|
-
|
|
45
|
+
// Resolved per-call from settings
|
|
43
46
|
|
|
44
47
|
// ── Interfaces ───────────────────────────────────────────────────────────
|
|
45
48
|
|
|
@@ -56,6 +59,7 @@ export interface AsyncJob {
|
|
|
56
59
|
exitCode?: number | null;
|
|
57
60
|
durationMs?: number;
|
|
58
61
|
delivered?: boolean;
|
|
62
|
+
sideEffectsApplied?: boolean;
|
|
59
63
|
fireAndForget?: boolean;
|
|
60
64
|
onComplete?: () => void;
|
|
61
65
|
groupId?: string;
|
|
@@ -64,6 +68,7 @@ export interface AsyncJob {
|
|
|
64
68
|
projectCwd?: string;
|
|
65
69
|
sessionId?: string;
|
|
66
70
|
model?: string;
|
|
71
|
+
restoredPid?: number;
|
|
67
72
|
}
|
|
68
73
|
|
|
69
74
|
/** Get the effective working directory for a job. */
|
|
@@ -120,13 +125,6 @@ export function registerAsyncAgents(
|
|
|
120
125
|
let PROJECT_TMP_DIR = path.join(process.cwd(), ".pi", "tmp"); // Computed only; created on session_start
|
|
121
126
|
let ASYNC_RESULTS_DIR = ""; // Set on session_start to project-scoped dir
|
|
122
127
|
|
|
123
|
-
const ASYNC_DEBUG = !!process.env.PI_ASYNC_DEBUG;
|
|
124
|
-
const EARLY_LOG_PATH = ASYNC_DEBUG ? path.join(PROJECT_TMP_DIR, `early-debug-${process.pid}.log`) : "";
|
|
125
|
-
let DEBUG_LOG_PATH = EARLY_LOG_PATH; // Starts with early log, moved to project dir on session_start
|
|
126
|
-
function asyncLog(msg: string) {
|
|
127
|
-
if (!ASYNC_DEBUG || !DEBUG_LOG_PATH) return;
|
|
128
|
-
try { fs.appendFileSync(DEBUG_LOG_PATH, `${new Date().toISOString()} ${msg}\n`); } catch {}
|
|
129
|
-
}
|
|
130
128
|
|
|
131
129
|
const asyncState: AsyncState = {
|
|
132
130
|
jobs: new Map(),
|
|
@@ -135,6 +133,20 @@ export function registerAsyncAgents(
|
|
|
135
133
|
lastCtx: null,
|
|
136
134
|
};
|
|
137
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
|
+
|
|
138
150
|
let lastWidgetKey = "";
|
|
139
151
|
function updateAsyncWidget() {
|
|
140
152
|
if (!asyncState.lastCtx?.hasUI) return;
|
|
@@ -184,15 +196,88 @@ export function registerAsyncAgents(
|
|
|
184
196
|
for (const job of asyncState.jobs.values()) {
|
|
185
197
|
if (job.status === "complete" || job.status === "failed") continue;
|
|
186
198
|
const status = readAsyncStatus(job.workerDir);
|
|
187
|
-
|
|
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
|
+
{
|
|
188
256
|
job.status = status.state;
|
|
189
257
|
job.updatedAt = status.lastUpdate ?? Date.now();
|
|
190
258
|
if (status.exitCode !== undefined) job.exitCode = status.exitCode;
|
|
191
259
|
|
|
192
260
|
// Check if process is actually alive — clean up zombies
|
|
193
|
-
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) {
|
|
194
278
|
try { process.kill(status.pid, 0); } catch {
|
|
195
279
|
// Process exited — check if it wrote a result file first
|
|
280
|
+
log.debug("poller_zombie", job.id, "pid", status.pid, "checking result file");
|
|
196
281
|
const resultFilePath = path.join(ASYNC_RESULTS_DIR, `${job.id}.json`);
|
|
197
282
|
if (fs.existsSync(resultFilePath)) {
|
|
198
283
|
// Result file exists — ingest it so output/durationMs are populated
|
|
@@ -203,29 +288,40 @@ export function registerAsyncAgents(
|
|
|
203
288
|
job.exitCode = data.exitCode;
|
|
204
289
|
job.durationMs = data.durationMs;
|
|
205
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;
|
|
206
293
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
207
294
|
const findings = countFindings(typeof data.output === "string" ? data.output : "");
|
|
208
|
-
try { recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings); } catch (e: any) {
|
|
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}`); }
|
|
209
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;
|
|
210
301
|
try { fs.unlinkSync(resultFilePath); } catch {}
|
|
211
302
|
} catch {
|
|
212
303
|
job.status = "failed";
|
|
213
304
|
job.output = "Process exited before result could be read";
|
|
214
305
|
job.durationMs = Date.now() - job.startedAt;
|
|
215
306
|
job.updatedAt = Date.now();
|
|
307
|
+
let catchSideEffectsOk = true;
|
|
216
308
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
217
|
-
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) {
|
|
309
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { catchSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
218
310
|
}
|
|
311
|
+
if (catchSideEffectsOk) job.sideEffectsApplied = true;
|
|
219
312
|
}
|
|
220
313
|
} else {
|
|
221
314
|
job.status = "failed";
|
|
222
315
|
job.output = "Process exited without producing results";
|
|
223
316
|
job.durationMs = Date.now() - job.startedAt;
|
|
224
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);
|
|
225
319
|
// Record killed reviewer as having 0 findings — prevents permanent commit block
|
|
320
|
+
let noFileSideEffectsOk = true;
|
|
226
321
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
227
|
-
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) {
|
|
322
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { noFileSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
228
323
|
}
|
|
324
|
+
if (noFileSideEffectsOk) job.sideEffectsApplied = true;
|
|
229
325
|
}
|
|
230
326
|
// Check if this completes a group — deliver remaining siblings' results
|
|
231
327
|
if (job.groupId) {
|
|
@@ -236,26 +332,126 @@ export function registerAsyncAgents(
|
|
|
236
332
|
}
|
|
237
333
|
}
|
|
238
334
|
}
|
|
335
|
+
|
|
239
336
|
}
|
|
240
337
|
|
|
241
338
|
// Fallback: check for result files the watcher may have missed
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
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
|
+
}
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
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
|
+
}
|
|
246
442
|
}
|
|
247
|
-
}
|
|
443
|
+
}
|
|
248
444
|
|
|
249
|
-
// Remove completed/failed jobs older than 30s
|
|
445
|
+
// Remove completed/failed jobs older than 30s — require delivered before cleanup
|
|
250
446
|
for (const [id, job] of asyncState.jobs.entries()) {
|
|
251
447
|
if ((job.status === "complete" || job.status === "failed")
|
|
252
448
|
&& Date.now() - job.updatedAt > 30000
|
|
253
|
-
&&
|
|
449
|
+
&& job.delivered) {
|
|
254
450
|
asyncState.jobs.delete(id);
|
|
255
451
|
}
|
|
256
452
|
}
|
|
257
453
|
updateAsyncWidget();
|
|
258
|
-
},
|
|
454
|
+
}, getSetting(process.cwd(), "async_poll_interval_ms"));
|
|
259
455
|
if (asyncState.poller.unref) asyncState.poller.unref();
|
|
260
456
|
}
|
|
261
457
|
|
|
@@ -288,8 +484,8 @@ export function registerAsyncAgents(
|
|
|
288
484
|
j.exitCode = data.exitCode;
|
|
289
485
|
j.durationMs = data.durationMs;
|
|
290
486
|
if (data.success !== undefined) j.status = data.success ? "complete" : "failed";
|
|
291
|
-
|
|
292
|
-
} catch (e: any) {
|
|
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}`); }
|
|
293
489
|
}
|
|
294
490
|
|
|
295
491
|
// Track reviewer completions for ALL code-reviewer jobs in the group.
|
|
@@ -302,7 +498,7 @@ export function registerAsyncAgents(
|
|
|
302
498
|
const findings = countFindings(output);
|
|
303
499
|
// -1 means invalid JSON output — treat conservatively as having findings
|
|
304
500
|
recordReviewerResult(jobCwd(j), j.agent, findings < 0 ? 1 : findings);
|
|
305
|
-
} catch (e: any) {
|
|
501
|
+
} catch (e: any) { j.sideEffectsApplied = false; log.error(`recordReviewerResult failed for ${j.agent}: ${e?.message}`); continue; }
|
|
306
502
|
}
|
|
307
503
|
|
|
308
504
|
// Track test agent completions for grouped test-automator/test-runner
|
|
@@ -314,26 +510,31 @@ export function registerAsyncAgents(
|
|
|
314
510
|
} else if (j.status === "failed") {
|
|
315
511
|
markTestsFailed(jobCwd(j));
|
|
316
512
|
}
|
|
317
|
-
} catch (e: any) {
|
|
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;
|
|
318
519
|
}
|
|
319
520
|
|
|
320
521
|
// Skip delivery if ALL jobs in group are fire-and-forget
|
|
321
522
|
if (groupJobs.every(j => j.fireAndForget)) {
|
|
322
523
|
for (const j of groupJobs) {
|
|
323
524
|
if (j.onComplete) {
|
|
324
|
-
try { j.onComplete(); } catch (e: any) {
|
|
525
|
+
try { j.onComplete(); } catch (e: any) { log.error(`onComplete callback failed for ${j.id}: ${e?.message}`); }
|
|
325
526
|
}
|
|
326
527
|
j.delivered = true;
|
|
327
528
|
}
|
|
328
529
|
// Clean up result files for fire-and-forget groups
|
|
329
530
|
for (const j of groupJobs) {
|
|
330
531
|
const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
|
|
331
|
-
try { fs.unlinkSync(rp); } catch (e: any) {
|
|
532
|
+
try { fs.unlinkSync(rp); } catch (e: any) { log.debug(`unlink failed ${rp}: ${e?.message}`); }
|
|
332
533
|
}
|
|
333
534
|
return;
|
|
334
535
|
}
|
|
335
536
|
|
|
336
|
-
// Emit lifecycle events for
|
|
537
|
+
// Emit lifecycle events for pitasks RPC bridge (one per job)
|
|
337
538
|
for (const j of groupJobs) {
|
|
338
539
|
if (j.status === "complete") {
|
|
339
540
|
pi.events.emit("subagents:completed", { id: j.id, result: j.output || "" });
|
|
@@ -343,6 +544,7 @@ export function registerAsyncAgents(
|
|
|
343
544
|
}
|
|
344
545
|
|
|
345
546
|
const sections: string[] = [];
|
|
547
|
+
const deliverableJobs: AsyncJob[] = [];
|
|
346
548
|
for (const j of groupJobs) {
|
|
347
549
|
if (j.fireAndForget) { j.delivered = true; continue; }
|
|
348
550
|
const resultStatus = j.status === "complete" ? "✅ completed" : "❌ failed";
|
|
@@ -353,29 +555,50 @@ export function registerAsyncAgents(
|
|
|
353
555
|
if (j.taskId && j.taskId !== "-1" && j.status === "complete" && j.cwd) {
|
|
354
556
|
try {
|
|
355
557
|
const completed = await autoCompleteTask(j.taskId, j.projectCwd || j.cwd, j.sessionId);
|
|
356
|
-
|
|
558
|
+
log.info(`auto-completed task #${j.taskId}: ${completed}`);
|
|
357
559
|
} catch (e: any) {
|
|
358
560
|
autoCompleteError = `\n\n⚠️ Failed to auto-complete task #${j.taskId}: ${e?.message}. Run TaskUpdate(taskId="${j.taskId}", status="completed") manually.`;
|
|
359
|
-
|
|
561
|
+
log.error(`auto-complete failed for task #${j.taskId}: ${e?.message}`);
|
|
360
562
|
}
|
|
361
563
|
}
|
|
362
564
|
const duration = j.durationMs || (j.updatedAt ? j.updatedAt - j.startedAt : 0);
|
|
363
565
|
sections.push(`## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${j.task}\nDuration: ${formatDuration(duration)}\n\n${output}${autoCompleteError}`);
|
|
364
|
-
j
|
|
566
|
+
deliverableJobs.push(j);
|
|
365
567
|
}
|
|
366
568
|
|
|
367
569
|
if (sections.length > 0) {
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
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
|
|
373
593
|
}
|
|
374
594
|
|
|
375
|
-
// Clean up result files
|
|
595
|
+
// Clean up any remaining result files (most already deleted by processResultFile)
|
|
376
596
|
for (const j of groupJobs) {
|
|
377
597
|
const rp = path.join(ASYNC_RESULTS_DIR, `${j.id}.json`);
|
|
378
|
-
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
|
+
}
|
|
379
602
|
}
|
|
380
603
|
} finally {
|
|
381
604
|
if (gid) groupDeliveryInProgress.delete(gid);
|
|
@@ -383,22 +606,32 @@ export function registerAsyncAgents(
|
|
|
383
606
|
}
|
|
384
607
|
|
|
385
608
|
async function processResultFile(resultPath: string) {
|
|
609
|
+
if (!fs.existsSync(resultPath)) {
|
|
610
|
+
log.debug(`processResultFile: file already deleted: ${path.basename(resultPath)}`);
|
|
611
|
+
return;
|
|
612
|
+
}
|
|
386
613
|
try {
|
|
387
614
|
const data = JSON.parse(fs.readFileSync(resultPath, "utf-8"));
|
|
388
615
|
const job = asyncState.jobs.get(data.id);
|
|
389
|
-
|
|
616
|
+
log.debug(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!asyncState.lastCtx} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
|
|
390
617
|
if (!job) {
|
|
391
618
|
// Orphan result file — job not in current session. Clean up to prevent re-delivery on reload.
|
|
392
|
-
try { fs.unlinkSync(resultPath); } catch (e: any) {
|
|
619
|
+
try { fs.unlinkSync(resultPath); } catch (e: any) { log.debug(`orphan cleanup failed: ${resultPath}: ${e?.message}`); }
|
|
393
620
|
return;
|
|
394
621
|
}
|
|
395
622
|
if (job.delivered) {
|
|
396
623
|
// Already delivered — clean up stale file
|
|
397
|
-
|
|
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
|
+
}
|
|
398
633
|
return;
|
|
399
634
|
}
|
|
400
|
-
// Skip if already ingested (grouped jobs stay on disk until group delivers)
|
|
401
|
-
if (job.output !== undefined) return;
|
|
402
635
|
|
|
403
636
|
// Update job state immediately (before group check)
|
|
404
637
|
job.status = data.success ? "complete" : "failed";
|
|
@@ -412,13 +645,14 @@ export function registerAsyncAgents(
|
|
|
412
645
|
terminalNotify("pi", `Async agent ${displayName} ${data.success ? "completed" : "failed"} (${formatDuration(data.durationMs)})`);
|
|
413
646
|
|
|
414
647
|
// Track reviewer completion for review loop enforcement
|
|
648
|
+
let processResultSideEffectsOk = true;
|
|
415
649
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
416
650
|
try {
|
|
417
651
|
const output = typeof data.output === "string" ? data.output : JSON.stringify(data.output ?? "");
|
|
418
652
|
const findings = countFindings(output);
|
|
419
653
|
// -1 means invalid JSON output — treat conservatively as having findings
|
|
420
654
|
recordReviewerResult(jobCwd(job), job.agent, findings < 0 ? 1 : findings);
|
|
421
|
-
} catch (e: any) {
|
|
655
|
+
} catch (e: any) { processResultSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
422
656
|
// Clear reviewer session if context usage > 80% to prevent overflow on next cycle
|
|
423
657
|
// Use model context window from the agent's effective model.
|
|
424
658
|
// If agent uses a custom model, we pass its context window via the config.
|
|
@@ -437,7 +671,7 @@ export function registerAsyncAgents(
|
|
|
437
671
|
for (const f of files) {
|
|
438
672
|
if (f.includes(sessId)) {
|
|
439
673
|
fs.unlinkSync(path.join(sessPath, f));
|
|
440
|
-
|
|
674
|
+
log.info(`Cleared session for ${job.agent} (context ${Math.round(pct)}%)`);
|
|
441
675
|
break;
|
|
442
676
|
}
|
|
443
677
|
}
|
|
@@ -454,19 +688,33 @@ export function registerAsyncAgents(
|
|
|
454
688
|
} else {
|
|
455
689
|
markTestsFailed(jobCwd(job));
|
|
456
690
|
}
|
|
457
|
-
} catch (e: any) {
|
|
691
|
+
} catch (e: any) { processResultSideEffectsOk = false; log.error(`markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
|
|
458
692
|
}
|
|
459
693
|
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
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}`); }
|
|
464
712
|
|
|
465
713
|
// Group-aware delivery: hold results until ALL jobs in the group are done
|
|
466
714
|
if (job.groupId) {
|
|
467
715
|
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
468
716
|
const pending = groupJobs.filter(j => j.status !== "complete" && j.status !== "failed");
|
|
469
|
-
|
|
717
|
+
log.info(`group ${job.groupId}: ${groupJobs.length} total, ${pending.length} pending`);
|
|
470
718
|
if (pending.length > 0) {
|
|
471
719
|
// Not all group members done yet — hold delivery
|
|
472
720
|
updateAsyncWidget();
|
|
@@ -478,7 +726,7 @@ export function registerAsyncAgents(
|
|
|
478
726
|
return;
|
|
479
727
|
}
|
|
480
728
|
|
|
481
|
-
// Emit lifecycle events for
|
|
729
|
+
// Emit lifecycle events for pitasks RPC bridge
|
|
482
730
|
if (data.success) {
|
|
483
731
|
pi.events.emit("subagents:completed", { id: job.id, result: data.output || "" });
|
|
484
732
|
} else {
|
|
@@ -486,6 +734,7 @@ export function registerAsyncAgents(
|
|
|
486
734
|
}
|
|
487
735
|
|
|
488
736
|
// Non-grouped job: deliver immediately (existing behavior)
|
|
737
|
+
let sendSucceeded = false;
|
|
489
738
|
if (asyncState.lastCtx && !job.fireAndForget) {
|
|
490
739
|
const resultStatus = data.success ? "✅ completed" : "❌ failed";
|
|
491
740
|
let autoCompleteError = "";
|
|
@@ -493,30 +742,59 @@ export function registerAsyncAgents(
|
|
|
493
742
|
if (job.taskId && job.taskId !== "-1" && data.success && job.cwd) {
|
|
494
743
|
try {
|
|
495
744
|
const completed = await autoCompleteTask(job.taskId, job.projectCwd || job.cwd, job.sessionId);
|
|
496
|
-
|
|
745
|
+
log.info(`auto-completed task #${job.taskId}: ${completed}`);
|
|
497
746
|
} catch (e: any) {
|
|
498
747
|
autoCompleteError = `\n\n⚠️ Failed to auto-complete task #${job.taskId}: ${e?.message}. Run TaskUpdate(taskId="${job.taskId}", status="completed") manually.`;
|
|
499
|
-
|
|
748
|
+
log.error(`auto-complete failed for task #${job.taskId}: ${e?.message}`);
|
|
500
749
|
}
|
|
501
750
|
}
|
|
502
751
|
const maxOutput = 3000 - autoCompleteError.length;
|
|
503
752
|
const output = (data.output || "").slice(0, Math.max(maxOutput, 500));
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
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
|
|
509
776
|
}
|
|
510
777
|
|
|
511
778
|
// Invoke onComplete callback if registered (e.g., dreaming → rebuildAndOrganize)
|
|
512
779
|
if (job.onComplete) {
|
|
513
|
-
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
|
+
}
|
|
514
793
|
}
|
|
515
|
-
job.delivered = true;
|
|
516
794
|
// Result file already deleted above (non-grouped path)
|
|
517
795
|
updateAsyncWidget();
|
|
518
796
|
} catch (e: any) {
|
|
519
|
-
|
|
797
|
+
log.error(`processResultFile ERROR: ${e.message}`);
|
|
520
798
|
}
|
|
521
799
|
}
|
|
522
800
|
|
|
@@ -531,6 +809,7 @@ export function registerAsyncAgents(
|
|
|
531
809
|
asyncState.watcher = fs.watch(ASYNC_RESULTS_DIR, (ev, file) => {
|
|
532
810
|
if (ev !== "rename" || !file) return;
|
|
533
811
|
const fileName = file.toString();
|
|
812
|
+
log.debug("watcher_event", ev, fileName);
|
|
534
813
|
if (!fileName.endsWith(".json")) return;
|
|
535
814
|
const resultPath = path.join(ASYNC_RESULTS_DIR, fileName);
|
|
536
815
|
setTimeout(() => processResultFile(resultPath), 100);
|
|
@@ -650,7 +929,7 @@ export function registerAsyncAgents(
|
|
|
650
929
|
const candidate = path.join(piPkgDir, "node_modules/jiti/lib/jiti-cli.mjs");
|
|
651
930
|
if (fs.existsSync(candidate)) jitiCliPath = candidate;
|
|
652
931
|
} catch (e: any) {
|
|
653
|
-
|
|
932
|
+
log.debug(`jiti strategy 1 (require.resolve) failed: ${e?.message || e}`);
|
|
654
933
|
}
|
|
655
934
|
// Strategy 2: resolve from the pi binary (works for global npm installs)
|
|
656
935
|
if (!jitiCliPath) {
|
|
@@ -664,7 +943,7 @@ export function registerAsyncAgents(
|
|
|
664
943
|
if (fs.existsSync(candidate)) jitiCliPath = candidate;
|
|
665
944
|
}
|
|
666
945
|
} catch (e: any) {
|
|
667
|
-
|
|
946
|
+
log.debug(`jiti strategy 2 (process.argv) failed: ${e?.message || e}`);
|
|
668
947
|
}
|
|
669
948
|
}
|
|
670
949
|
|
|
@@ -677,6 +956,9 @@ export function registerAsyncAgents(
|
|
|
677
956
|
...process.env,
|
|
678
957
|
PI_SUBAGENT_CHILD: "1",
|
|
679
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 || "",
|
|
680
962
|
};
|
|
681
963
|
if (agentName.startsWith("code-reviewer-") || agentName === "test-automator" || agentName === "test-runner") {
|
|
682
964
|
try {
|
|
@@ -729,6 +1011,50 @@ export function registerAsyncAgents(
|
|
|
729
1011
|
env: spawnEnv,
|
|
730
1012
|
});
|
|
731
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
|
+
|
|
732
1058
|
// addReviewerPending already called above (before piArgs construction)
|
|
733
1059
|
|
|
734
1060
|
updateAsyncWidget();
|
|
@@ -747,23 +1073,35 @@ export function registerAsyncAgents(
|
|
|
747
1073
|
// Start result watcher on session start
|
|
748
1074
|
pi.on("session_start", (_event, ctx) => {
|
|
749
1075
|
asyncState.lastCtx = ctx;
|
|
1076
|
+
deliveredResultIds.clear();
|
|
750
1077
|
|
|
751
1078
|
// Set project-scoped dir first (getProjectTmpDir creates it if missing)
|
|
752
1079
|
PROJECT_TMP_DIR = getProjectTmpDir(ctx.cwd);
|
|
753
1080
|
// Export as env var so prompts/CLI commands can reference it
|
|
754
1081
|
process.env.PROJECT_TMP_DIR = PROJECT_TMP_DIR;
|
|
755
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
|
+
|
|
756
1103
|
// Set results dir — PID-scoped under project dir
|
|
757
1104
|
ASYNC_RESULTS_DIR = path.join(PROJECT_TMP_DIR, sessionResultsDirName());
|
|
758
|
-
const projectLogPath = path.join(PROJECT_TMP_DIR, "debug.log");
|
|
759
|
-
// Move early startup logs to project dir
|
|
760
|
-
if (EARLY_LOG_PATH && fs.existsSync(EARLY_LOG_PATH)) {
|
|
761
|
-
try {
|
|
762
|
-
fs.appendFileSync(projectLogPath, fs.readFileSync(EARLY_LOG_PATH, "utf-8"));
|
|
763
|
-
fs.unlinkSync(EARLY_LOG_PATH);
|
|
764
|
-
} catch {}
|
|
765
|
-
}
|
|
766
|
-
DEBUG_LOG_PATH = projectLogPath;
|
|
767
1105
|
|
|
768
1106
|
// Zombie cleanup: scan project dir for dead agents
|
|
769
1107
|
try {
|
|
@@ -796,7 +1134,39 @@ export function registerAsyncAgents(
|
|
|
796
1134
|
}
|
|
797
1135
|
} catch (e: any) { console.debug("[async-agents] zombie cleanup failed:", e?.message?.slice(0, 100)); }
|
|
798
1136
|
|
|
799
|
-
|
|
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)}`);
|
|
800
1170
|
|
|
801
1171
|
// Restore jobs from status files in PROJECT_TMP_DIR that belong to this session
|
|
802
1172
|
try {
|
|
@@ -817,8 +1187,17 @@ export function registerAsyncAgents(
|
|
|
817
1187
|
if (!id || asyncState.jobs.has(id)) continue;
|
|
818
1188
|
const isAlive = status.pid ? (() => { try { process.kill(status.pid, 0); return true; } catch { return false; } })() : false;
|
|
819
1189
|
const isComplete = status.state === "complete" || status.state === "failed";
|
|
820
|
-
//
|
|
821
|
-
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`));
|
|
822
1201
|
const job: AsyncJob = {
|
|
823
1202
|
id,
|
|
824
1203
|
agent: status.agent || "unknown",
|
|
@@ -829,15 +1208,19 @@ export function registerAsyncAgents(
|
|
|
829
1208
|
updatedAt: status.lastUpdate || Date.now(),
|
|
830
1209
|
exitCode: status.exitCode,
|
|
831
1210
|
durationMs: status.endedAt ? status.endedAt - status.startedAt : undefined,
|
|
1211
|
+
output: status.output || undefined,
|
|
1212
|
+
delivered: isComplete,
|
|
1213
|
+
sideEffectsApplied: isComplete,
|
|
832
1214
|
fireAndForget: marker.fireAndForget || false,
|
|
833
1215
|
taskId: marker.taskId || undefined,
|
|
834
1216
|
cwd: marker.cwd || undefined,
|
|
835
1217
|
projectCwd: marker.projectCwd || undefined,
|
|
836
1218
|
sessionId: marker.sessionId || undefined,
|
|
837
1219
|
model: status.model || marker.model || undefined,
|
|
1220
|
+
restoredPid: typeof status.pid === "number" ? status.pid : undefined,
|
|
838
1221
|
};
|
|
839
1222
|
asyncState.jobs.set(id, job);
|
|
840
|
-
|
|
1223
|
+
log.info(`restored job: ${id} state=${job.status}`);
|
|
841
1224
|
}
|
|
842
1225
|
}
|
|
843
1226
|
} catch (e: any) { console.debug("[async-agents] job restore failed:", e?.message || e); }
|
|
@@ -857,7 +1240,7 @@ export function registerAsyncAgents(
|
|
|
857
1240
|
|
|
858
1241
|
// Clean up on shutdown
|
|
859
1242
|
pi.on("session_shutdown", () => {
|
|
860
|
-
|
|
1243
|
+
log.info(`shutdown: jobs=${asyncState.jobs.size}`);
|
|
861
1244
|
if (asyncState.poller) { clearInterval(asyncState.poller); asyncState.poller = null; }
|
|
862
1245
|
if (asyncState.watcher) { asyncState.watcher.close(); asyncState.watcher = null; }
|
|
863
1246
|
});
|
|
@@ -926,10 +1309,25 @@ export function registerAsyncAgents(
|
|
|
926
1309
|
killed.push(label);
|
|
927
1310
|
job.status = "failed";
|
|
928
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 {}
|
|
929
1325
|
// Record killed reviewer as having 0 findings — prevents permanent commit block
|
|
1326
|
+
let killSideEffectsOk = true;
|
|
930
1327
|
if (job.agent.startsWith("code-reviewer-")) {
|
|
931
|
-
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) {
|
|
1328
|
+
try { recordReviewerResult(jobCwd(job), job.agent, 0); } catch (e: any) { killSideEffectsOk = false; log.error(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
|
|
932
1329
|
}
|
|
1330
|
+
if (killSideEffectsOk) job.sideEffectsApplied = true;
|
|
933
1331
|
// Check if this completes a group — deliver remaining siblings' results
|
|
934
1332
|
if (job.groupId) {
|
|
935
1333
|
const groupJobs = Array.from(asyncState.jobs.values()).filter(j => j.groupId === job.groupId);
|
|
@@ -937,9 +1335,35 @@ export function registerAsyncAgents(
|
|
|
937
1335
|
if (pending.length === 0) {
|
|
938
1336
|
deliverGroupResults(groupJobs);
|
|
939
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;
|
|
940
1364
|
}
|
|
941
|
-
// Delay cleanup —
|
|
942
|
-
if (
|
|
1365
|
+
// Delay cleanup — only for delivered jobs
|
|
1366
|
+
if (job.delivered) {
|
|
943
1367
|
setTimeout(() => { asyncState.jobs.delete(job.id); updateAsyncWidget(); }, 5000);
|
|
944
1368
|
}
|
|
945
1369
|
}
|
|
@@ -995,7 +1419,7 @@ export function registerAsyncAgents(
|
|
|
995
1419
|
});
|
|
996
1420
|
|
|
997
1421
|
// ── pi-subagents RPC compatibility bridge ──────────────────────────────
|
|
998
|
-
// Implements the subagents:rpc:* protocol so
|
|
1422
|
+
// Implements the subagents:rpc:* protocol so pitasks' TaskExecute
|
|
999
1423
|
// can spawn our agents. Protocol version 2 matches @tintinweb/pi-subagents.
|
|
1000
1424
|
|
|
1001
1425
|
const RPC_PROTOCOL_VERSION = 2;
|
|
@@ -1025,7 +1449,7 @@ export function registerAsyncAgents(
|
|
|
1025
1449
|
return { version: RPC_PROTOCOL_VERSION };
|
|
1026
1450
|
});
|
|
1027
1451
|
|
|
1028
|
-
// Spawn — create an async agent from
|
|
1452
|
+
// Spawn — create an async agent from pitasks' TaskExecute
|
|
1029
1453
|
handleRpc<{ requestId: string; type: string; prompt: string; options?: any }>(
|
|
1030
1454
|
"subagents:rpc:spawn", ({ type, prompt, options }) => {
|
|
1031
1455
|
const ctx = asyncState.lastCtx;
|
|
@@ -1044,7 +1468,7 @@ export function registerAsyncAgents(
|
|
|
1044
1468
|
|
|
1045
1469
|
const result = spawnAsyncAgent(agentName, prompt, cwd, agents, {
|
|
1046
1470
|
name: options?.description || agentName,
|
|
1047
|
-
taskId: "-1", //
|
|
1471
|
+
taskId: "-1", // pitasks manages its own task linkage via agentTaskMap
|
|
1048
1472
|
fireAndForget: false,
|
|
1049
1473
|
});
|
|
1050
1474
|
|
|
@@ -1061,7 +1485,7 @@ export function registerAsyncAgents(
|
|
|
1061
1485
|
},
|
|
1062
1486
|
);
|
|
1063
1487
|
|
|
1064
|
-
// Emit subagents:ready so
|
|
1488
|
+
// Emit subagents:ready so pitasks can discover us
|
|
1065
1489
|
pi.on("session_start", () => {
|
|
1066
1490
|
pi.events.emit("subagents:ready", {});
|
|
1067
1491
|
});
|