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.
Files changed (92) hide show
  1. package/README.md +52 -61
  2. package/agents/git-expert.md +5 -1
  3. package/agents/github-expert.md +4 -1
  4. package/extensions/acpx-provider/runtime-models.ts +18 -6
  5. package/extensions/cli-provider/agents/claude.ts +3 -2
  6. package/extensions/cli-provider/agents/cursor.ts +3 -5
  7. package/extensions/cli-provider/agents/gemini.ts +3 -2
  8. package/extensions/cli-provider/discover.ts +7 -7
  9. package/extensions/cli-provider/index.ts +5 -0
  10. package/extensions/cli-provider/runtime-models.ts +18 -6
  11. package/extensions/cli-provider/session-reaper.ts +5 -7
  12. package/extensions/cli-provider/sessions.ts +4 -0
  13. package/extensions/cli-provider/shared/discover-cache.ts +17 -176
  14. package/extensions/coms/coms-p2p.ts +1719 -423
  15. package/extensions/coms/coms-shared.ts +261 -39
  16. package/extensions/coms/coms-wrapper.ts +100 -62
  17. package/extensions/coms/index.ts +8 -4
  18. package/extensions/coms/mixed-turn.ts +26 -0
  19. package/extensions/coms/probe-socket.ts +42 -0
  20. package/extensions/coms/themeMap.ts +0 -1
  21. package/extensions/image-gen/image-gen.ts +19 -1
  22. package/extensions/orchestrator/async-agents.ts +509 -85
  23. package/extensions/orchestrator/cron.ts +14 -8
  24. package/extensions/orchestrator/dreaming.ts +31 -24
  25. package/extensions/orchestrator/enforcement-helpers.ts +459 -17
  26. package/extensions/orchestrator/enforcement-rules.ts +5 -4
  27. package/extensions/orchestrator/enforcement.ts +79 -25
  28. package/extensions/orchestrator/extended-autocomplete.ts +7 -0
  29. package/extensions/orchestrator/git-helpers.ts +2 -2
  30. package/extensions/orchestrator/index.ts +23 -10
  31. package/extensions/orchestrator/markdown-transformer.ts +313 -0
  32. package/extensions/orchestrator/memory-embeddings.ts +36 -34
  33. package/extensions/orchestrator/memory-provenance.ts +29 -25
  34. package/extensions/orchestrator/memory-scoring.ts +23 -18
  35. package/extensions/orchestrator/memory-tools.ts +10 -3
  36. package/extensions/orchestrator/overlay-dashboard.ts +17 -5
  37. package/extensions/orchestrator/pi-config-review-state.ts +44 -33
  38. package/extensions/orchestrator/project-settings.ts +220 -21
  39. package/extensions/orchestrator/review-porcelain.ts +4 -0
  40. package/extensions/orchestrator/review-ui.ts +11 -5
  41. package/extensions/orchestrator/rule-placeholders.ts +384 -1
  42. package/extensions/orchestrator/rules.ts +80 -132
  43. package/extensions/orchestrator/settings-tui-helpers.ts +380 -0
  44. package/extensions/orchestrator/settings-tui-submenus.ts +647 -0
  45. package/extensions/orchestrator/settings-tui.ts +636 -0
  46. package/extensions/orchestrator/state-jsonl.ts +431 -0
  47. package/extensions/orchestrator/subagent-tool.ts +105 -50
  48. package/extensions/orchestrator/sync-limit.ts +23 -0
  49. package/extensions/orchestrator/task-lifecycle.ts +77 -67
  50. package/extensions/orchestrator/utils.ts +8 -5
  51. package/extensions/pidash/pidash.ts +193 -156
  52. package/extensions/pidiff/pidiff.ts +64 -38
  53. package/extensions/pidiff/spawn-lock.ts +62 -0
  54. package/extensions/pitasks/index.ts +430 -0
  55. package/extensions/pitasks/reminders.ts +37 -0
  56. package/extensions/pitasks/task-store.ts +467 -0
  57. package/extensions/pitasks/task-tools.ts +375 -0
  58. package/extensions/pitasks/task-widget.ts +181 -0
  59. package/extensions/pitasks/types.ts +38 -0
  60. package/extensions/providers/acpx-driver.ts +12 -2
  61. package/extensions/providers/built-in-drivers.ts +6 -6
  62. package/extensions/providers/claude-driver.ts +1 -1
  63. package/extensions/providers/cursor-cli-driver.ts +1 -1
  64. package/extensions/providers/gemini-driver.ts +1 -1
  65. package/extensions/providers/index.ts +144 -26
  66. package/extensions/providers/initialized-guard.ts +27 -0
  67. package/extensions/providers/restore-default-model.ts +596 -0
  68. package/extensions/providers/session-shutdown.ts +39 -0
  69. package/extensions/shared/coms-active.ts +23 -0
  70. package/extensions/shared/create-runtime-provider.ts +2 -0
  71. package/extensions/shared/daemon-manager.ts +0 -8
  72. package/extensions/shared/file-logger.ts +132 -25
  73. package/extensions/shared/logger.ts +50 -0
  74. package/extensions/shared/models-dev.ts +421 -0
  75. package/extensions/shared/oneshot.ts +116 -0
  76. package/extensions/shared/resolve-binary.ts +191 -0
  77. package/extensions/shared/types.ts +3 -0
  78. package/package.json +7 -2
  79. package/prompts/coderabbit-rate-limit.md +2 -0
  80. package/rules/00-orchestrator-core.md +3 -0
  81. package/rules/10-agent-routing.md +1 -1
  82. package/rules/20-code-review-loop.md +38 -16
  83. package/rules/30-prompt-templates.md +4 -0
  84. package/rules/35-memory.md +3 -3
  85. package/rules/40-critical-rules.md +3 -0
  86. package/rules/55-coms-protocol.md +84 -18
  87. package/rules/60-task-tracking.md +2 -0
  88. package/settings-keys.json +481 -20
  89. package/templates/coms-feature-manager-prompt.md +4 -6
  90. package/extensions/coms/coms-net-server.ts +0 -1642
  91. package/extensions/coms/coms-net-wrapper.ts +0 -521
  92. 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
- const ASYNC_POLL_INTERVAL_MS = 3000;
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
- if (status) {
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) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
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) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
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) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
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
- try {
243
- const files = fs.readdirSync(ASYNC_RESULTS_DIR).filter(f => f.endsWith(".json"));
244
- for (const file of files) {
245
- processResultFile(path.join(ASYNC_RESULTS_DIR, file));
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
- } catch (e: any) { console.debug("[async-agents] result file scan failed:", e?.message || e); }
443
+ }
248
444
 
249
- // Remove completed/failed jobs older than 30s (skip undelivered group members)
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
- && (job.delivered || !job.groupId)) {
449
+ && job.delivered) {
254
450
  asyncState.jobs.delete(id);
255
451
  }
256
452
  }
257
453
  updateAsyncWidget();
258
- }, ASYNC_POLL_INTERVAL_MS);
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
- asyncLog(`deliverGroupResults: late-ingested result for ${j.id}`);
292
- } 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}`); }
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) { asyncLog(`recordReviewerResult failed for ${j.agent}: ${e?.message}`); }
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) { asyncLog(`markTests failed for ${j.agent}: ${e?.message}`); }
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) { asyncLog(`onComplete callback failed for ${j.id}: ${e?.message}`); }
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) { asyncLog(`unlink failed ${rp}: ${e?.message}`); }
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 pi-tasks RPC bridge (one per job)
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
- asyncLog(`auto-completed task #${j.taskId}: ${completed}`);
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
- asyncLog(`auto-complete failed for task #${j.taskId}: ${e?.message}`);
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.delivered = true;
566
+ deliverableJobs.push(j);
365
567
  }
366
568
 
367
569
  if (sections.length > 0) {
368
- pi.sendMessage({
369
- customType: "async-agent-result",
370
- content: sections.join("\n\n---\n\n"),
371
- display: true,
372
- }, { triggerTurn: true, deliverAs: "followUp" });
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 for group members now that delivery succeeded
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) { asyncLog(`unlink failed ${rp}: ${e?.message}`); }
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
- asyncLog(`processResultFile: ${path.basename(resultPath)} job=${!!job} delivered=${job?.delivered} hasCtx=${!!asyncState.lastCtx} fireAndForget=${job?.fireAndForget} groupId=${job?.groupId}`);
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) { asyncLog(`orphan cleanup failed: ${resultPath}: ${e?.message}`); }
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
- try { fs.unlinkSync(resultPath); } catch (e: any) { asyncLog(`stale cleanup failed: ${resultPath}: ${e?.message}`); }
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) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
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
- asyncLog(`Cleared session for ${job.agent} (context ${Math.round(pct)}%)`);
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) { console.debug(`[async-agents] markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
691
+ } catch (e: any) { processResultSideEffectsOk = false; log.error(`markTests(Passed|Failed) failed for ${job.agent}: ${e?.message}`); }
458
692
  }
459
693
 
460
- // Clean up result file — for grouped jobs, defer to deliverGroupResults
461
- if (!job.groupId) {
462
- try { fs.unlinkSync(resultPath); } catch (e: any) { asyncLog(`unlink failed ${resultPath}: ${e?.message}`); }
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
- asyncLog(`group ${job.groupId}: ${groupJobs.length} total, ${pending.length} pending`);
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 pi-tasks RPC bridge
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
- asyncLog(`auto-completed task #${job.taskId}: ${completed}`);
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
- asyncLog(`auto-complete failed for task #${job.taskId}: ${e?.message}`);
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
- pi.sendMessage({
505
- customType: "async-agent-result",
506
- content: `## Async Agent Result: ${displayName} ${resultStatus}\n\nTask: ${data.task}\nDuration: ${formatDuration(data.durationMs)}\n\n${output}${autoCompleteError}`,
507
- display: true,
508
- }, { triggerTurn: true, deliverAs: "followUp" });
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) { asyncLog(`onComplete callback failed for ${job.id}: ${e?.message}`); }
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
- asyncLog(`processResultFile ERROR: ${e.message}`);
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
- asyncLog(`jiti strategy 1 (require.resolve) failed: ${e?.message || e}`);
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
- asyncLog(`jiti strategy 2 (process.argv) failed: ${e?.message || e}`);
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
- asyncLog(`session_start: resultsDir=${path.basename(ASYNC_RESULTS_DIR)}`);
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
- // Always restore — poller's zombie check will mark dead ones as failed
821
- if (isAlive || isComplete || status.state === "running") {
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
- asyncLog(`restored job: ${id} state=${job.status}`);
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
- asyncLog(`shutdown: jobs=${asyncState.jobs.size}`);
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) { asyncLog(`recordReviewerResult failed for ${job.agent}: ${e?.message}`); }
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 — skip auto-delete for undelivered group members (let reaper handle them)
942
- if (!job.groupId || job.delivered) {
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 pi-tasks' TaskExecute
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 pi-tasks' TaskExecute
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", // pi-tasks manages its own task linkage via agentTaskMap
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 pi-tasks can discover us
1488
+ // Emit subagents:ready so pitasks can discover us
1065
1489
  pi.on("session_start", () => {
1066
1490
  pi.events.emit("subagents:ready", {});
1067
1491
  });