pi-orchestrator-config 4.2.0 → 4.3.2

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