patchwarden 1.5.0 → 1.5.1

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 (217) hide show
  1. package/README.en.md +43 -2
  2. package/README.md +43 -2
  3. package/dist/assessments/agentAssessor.d.ts +1 -1
  4. package/dist/assessments/agentAssessor.js +3 -3
  5. package/dist/assessments/assessmentStore.d.ts +1 -1
  6. package/dist/assessments/assessmentStore.js +2 -2
  7. package/dist/assessments/confirmCli.js +5 -4
  8. package/dist/config.d.ts +7 -0
  9. package/dist/config.js +34 -0
  10. package/dist/control/middleware/auth.d.ts +10 -0
  11. package/dist/control/middleware/auth.js +8 -0
  12. package/dist/control/middleware/static.d.ts +3 -0
  13. package/dist/control/middleware/static.js +65 -0
  14. package/dist/control/routes/audit.d.ts +15 -0
  15. package/dist/control/routes/audit.js +277 -0
  16. package/dist/control/routes/evidence.d.ts +4 -0
  17. package/dist/control/routes/evidence.js +96 -0
  18. package/dist/control/routes/lineage.d.ts +3 -0
  19. package/dist/control/routes/lineage.js +71 -0
  20. package/dist/control/routes/policy.d.ts +3 -0
  21. package/dist/control/routes/policy.js +81 -0
  22. package/dist/control/routes/process.d.ts +5 -0
  23. package/dist/control/routes/process.js +200 -0
  24. package/dist/control/routes/sessions.d.ts +22 -0
  25. package/dist/control/routes/sessions.js +224 -0
  26. package/dist/control/routes/status.d.ts +6 -0
  27. package/dist/control/routes/status.js +250 -0
  28. package/dist/control/routes/taskActions.d.ts +21 -0
  29. package/dist/control/routes/taskActions.js +233 -0
  30. package/dist/control/routes/tasks.d.ts +20 -0
  31. package/dist/control/routes/tasks.js +310 -0
  32. package/dist/control/routes/workspace.d.ts +15 -0
  33. package/dist/control/routes/workspace.js +193 -0
  34. package/dist/control/runtime.d.ts +91 -0
  35. package/dist/control/runtime.js +392 -0
  36. package/dist/control/server.d.ts +13 -0
  37. package/dist/control/server.js +479 -0
  38. package/dist/control/shared.d.ts +35 -0
  39. package/dist/control/shared.js +288 -0
  40. package/dist/controlCenter.d.ts +6 -0
  41. package/dist/controlCenter.js +7 -2197
  42. package/dist/direct/directGuards.js +30 -8
  43. package/dist/doctor.d.ts +18 -1
  44. package/dist/doctor.js +579 -348
  45. package/dist/goal/goalReport.d.ts +54 -0
  46. package/dist/goal/goalReport.js +204 -0
  47. package/dist/goal/goalStatus.d.ts +6 -0
  48. package/dist/goal/specKitImport.d.ts +63 -0
  49. package/dist/goal/specKitImport.js +220 -0
  50. package/dist/goal/subgoalSync.js +2 -1
  51. package/dist/httpServer.js +15 -12
  52. package/dist/index.js +7 -4
  53. package/dist/logging.d.ts +7 -1
  54. package/dist/logging.js +8 -0
  55. package/dist/runner/changeCapture.d.ts +3 -3
  56. package/dist/runner/changeCapture.js +63 -39
  57. package/dist/runner/cli.js +7 -6
  58. package/dist/runner/postTaskCleanup.js +26 -2
  59. package/dist/runner/runTask.js +245 -221
  60. package/dist/runner/simpleProcess.js +4 -4
  61. package/dist/runner/watch.js +17 -14
  62. package/dist/security/contentRedaction.d.ts +6 -0
  63. package/dist/security/contentRedaction.js +22 -0
  64. package/dist/smoke-test.js +257 -251
  65. package/dist/test/unit/apply-patch.test.d.ts +1 -0
  66. package/dist/test/unit/apply-patch.test.js +225 -0
  67. package/dist/test/unit/create-task.test.d.ts +1 -0
  68. package/dist/test/unit/create-task.test.js +197 -0
  69. package/dist/test/unit/direct-guards.test.js +124 -9
  70. package/dist/test/unit/evidence-pack.test.js +95 -1
  71. package/dist/test/unit/get-task-status.test.d.ts +1 -0
  72. package/dist/test/unit/get-task-status.test.js +174 -0
  73. package/dist/test/unit/get-task-summary.test.d.ts +1 -0
  74. package/dist/test/unit/get-task-summary.test.js +146 -0
  75. package/dist/test/unit/goal-report.test.d.ts +1 -0
  76. package/dist/test/unit/goal-report.test.js +159 -0
  77. package/dist/test/unit/goal-subgoal-task.test.js +6 -6
  78. package/dist/test/unit/goal-tools-registry.test.js +6 -4
  79. package/dist/test/unit/path-guard.test.js +24 -0
  80. package/dist/test/unit/spec-kit-import.test.d.ts +1 -0
  81. package/dist/test/unit/spec-kit-import.test.js +341 -0
  82. package/dist/test/unit/wait-for-task.test.d.ts +1 -0
  83. package/dist/test/unit/wait-for-task.test.js +144 -0
  84. package/dist/tools/auditTask.d.ts +8 -63
  85. package/dist/tools/auditTask.js +12 -8
  86. package/dist/tools/createDirectSession.d.ts +1 -1
  87. package/dist/tools/createDirectSession.js +2 -2
  88. package/dist/tools/createTask.d.ts +2 -2
  89. package/dist/tools/createTask.js +4 -4
  90. package/dist/tools/dispatch/coreDispatch.d.ts +9 -0
  91. package/dist/tools/dispatch/coreDispatch.js +282 -0
  92. package/dist/tools/dispatch/diagnosticDispatch.d.ts +14 -0
  93. package/dist/tools/dispatch/diagnosticDispatch.js +78 -0
  94. package/dist/tools/dispatch/directDispatch.d.ts +8 -0
  95. package/dist/tools/dispatch/directDispatch.js +115 -0
  96. package/dist/tools/dispatch/goalDispatch.d.ts +8 -0
  97. package/dist/tools/dispatch/goalDispatch.js +91 -0
  98. package/dist/tools/dispatch/releaseDispatch.d.ts +8 -0
  99. package/dist/tools/dispatch/releaseDispatch.js +45 -0
  100. package/dist/tools/dispatch/types.d.ts +23 -0
  101. package/dist/tools/dispatch/types.js +15 -0
  102. package/dist/tools/evidencePack.d.ts +6 -0
  103. package/dist/tools/evidencePack.js +213 -6
  104. package/dist/tools/finalizeDirectSession.d.ts +1 -1
  105. package/dist/tools/finalizeDirectSession.js +3 -3
  106. package/dist/tools/goalSubgoalTask.d.ts +1 -1
  107. package/dist/tools/goalSubgoalTask.js +2 -2
  108. package/dist/tools/healthCheck.js +3 -3
  109. package/dist/tools/registry.d.ts +3 -3
  110. package/dist/tools/registry.js +60 -503
  111. package/dist/tools/retryTask.d.ts +2 -2
  112. package/dist/tools/retryTask.js +2 -2
  113. package/dist/tools/runTaskLoop.js +4 -4
  114. package/dist/tools/safeViews.d.ts +2 -2
  115. package/dist/tools/safeViews.js +2 -2
  116. package/dist/tools/toolRegistry.js +22 -0
  117. package/dist/version.d.ts +1 -1
  118. package/dist/version.js +1 -1
  119. package/docs/CODE_WIKI.md +983 -0
  120. package/docs/agentseal-integration.md +150 -0
  121. package/docs/architecture.md +63 -0
  122. package/docs/assets/patchwarden-oss-demo.gif +0 -0
  123. package/docs/dashboard-overview.md +86 -0
  124. package/docs/demo.md +8 -0
  125. package/docs/direct-session-workflow.md +98 -0
  126. package/docs/evidence-pack-schema.md +215 -0
  127. package/docs/execution-plan-2026-07-09.md +315 -0
  128. package/docs/lineage-evidence-pack-workflow.md +127 -0
  129. package/docs/mcp-inspector-testing.md +200 -0
  130. package/docs/open-source-application.md +162 -0
  131. package/docs/opencode-worker.md +151 -0
  132. package/docs/openhands-worker.md +181 -0
  133. package/docs/release-evidence.md +72 -0
  134. package/docs/roadmap-execution-and-acceptance.md +365 -0
  135. package/docs/spec-kit-integration.md +131 -0
  136. package/docs/task-safe-review-workflow.md +98 -0
  137. package/docs/threat-model.md +79 -0
  138. package/docs/user-feedback.md +40 -0
  139. package/docs/why-patchwarden.md +110 -0
  140. package/package.json +2 -2
  141. package/scripts/checks/control-center-smoke.js +356 -0
  142. package/scripts/checks/lifecycle-smoke.js +29 -23
  143. package/scripts/checks/mcp-smoke.js +3 -1
  144. package/scripts/generate-demo-gif.py +320 -0
  145. package/src/assessments/agentAssessor.ts +3 -3
  146. package/src/assessments/assessmentStore.ts +2 -2
  147. package/src/assessments/confirmCli.ts +5 -4
  148. package/src/config.ts +37 -0
  149. package/src/control/middleware/auth.ts +17 -0
  150. package/src/control/middleware/static.ts +71 -0
  151. package/src/control/routes/audit.ts +321 -0
  152. package/src/control/routes/evidence.ts +107 -0
  153. package/src/control/routes/lineage.ts +92 -0
  154. package/src/control/routes/policy.ts +81 -0
  155. package/src/control/routes/process.ts +204 -0
  156. package/src/control/routes/sessions.ts +251 -0
  157. package/src/control/routes/status.ts +325 -0
  158. package/src/control/routes/taskActions.ts +248 -0
  159. package/src/control/routes/tasks.ts +323 -0
  160. package/src/control/routes/workspace.ts +203 -0
  161. package/src/control/runtime.ts +472 -0
  162. package/src/control/server.ts +471 -0
  163. package/src/control/shared.ts +294 -0
  164. package/src/controlCenter.ts +7 -2347
  165. package/src/direct/directGuards.ts +28 -7
  166. package/src/doctor.ts +741 -481
  167. package/src/goal/goalReport.ts +271 -0
  168. package/src/goal/goalStatus.ts +6 -0
  169. package/src/goal/specKitImport.ts +355 -0
  170. package/src/goal/subgoalSync.ts +4 -2
  171. package/src/httpServer.ts +17 -14
  172. package/src/index.ts +7 -4
  173. package/src/logging.ts +10 -1
  174. package/src/runner/changeCapture.ts +70 -42
  175. package/src/runner/cli.ts +7 -6
  176. package/src/runner/postTaskCleanup.ts +26 -2
  177. package/src/runner/runTask.ts +325 -223
  178. package/src/runner/simpleProcess.ts +4 -4
  179. package/src/runner/watch.ts +17 -14
  180. package/src/security/contentRedaction.ts +29 -0
  181. package/src/smoke-test.ts +252 -250
  182. package/src/test/unit/apply-patch.test.ts +293 -0
  183. package/src/test/unit/create-task.test.ts +255 -0
  184. package/src/test/unit/direct-guards.test.ts +178 -8
  185. package/src/test/unit/evidence-pack.test.ts +110 -1
  186. package/src/test/unit/get-task-status.test.ts +203 -0
  187. package/src/test/unit/get-task-summary.test.ts +173 -0
  188. package/src/test/unit/goal-report.test.ts +189 -0
  189. package/src/test/unit/goal-subgoal-task.test.ts +6 -6
  190. package/src/test/unit/goal-tools-registry.test.ts +7 -5
  191. package/src/test/unit/path-guard.test.ts +30 -0
  192. package/src/test/unit/spec-kit-import.test.ts +429 -0
  193. package/src/test/unit/wait-for-task.test.ts +176 -0
  194. package/src/tools/auditTask.ts +99 -59
  195. package/src/tools/createDirectSession.ts +3 -3
  196. package/src/tools/createTask.ts +7 -7
  197. package/src/tools/dispatch/coreDispatch.ts +374 -0
  198. package/src/tools/dispatch/diagnosticDispatch.ts +101 -0
  199. package/src/tools/dispatch/directDispatch.ts +167 -0
  200. package/src/tools/dispatch/goalDispatch.ts +127 -0
  201. package/src/tools/dispatch/releaseDispatch.ts +65 -0
  202. package/src/tools/dispatch/types.ts +24 -0
  203. package/src/tools/evidencePack.ts +291 -6
  204. package/src/tools/finalizeDirectSession.ts +4 -4
  205. package/src/tools/goalSubgoalTask.ts +2 -2
  206. package/src/tools/healthCheck.ts +3 -3
  207. package/src/tools/registry.ts +68 -628
  208. package/src/tools/retryTask.ts +2 -2
  209. package/src/tools/runTaskLoop.ts +4 -4
  210. package/src/tools/safeViews.ts +2 -2
  211. package/src/tools/toolRegistry.ts +22 -0
  212. package/src/version.ts +1 -1
  213. package/ui/pages/audit.html +192 -3
  214. package/ui/pages/dashboard.html +959 -44
  215. package/ui/pages/direct-sessions.html +505 -53
  216. package/ui/pages/task-detail.html +456 -438
  217. package/ui/pages/tasks.html +598 -61
@@ -31,6 +31,8 @@ import {
31
31
  type ChangeArtifacts,
32
32
  type ExternalDirtyFile,
33
33
  type ArtifactManifest,
34
+ type RepoSnapshot,
35
+ type ChangedFileGroups,
34
36
  } from "./changeCapture.js";
35
37
  import { PatchWardenError, errorPayload } from "../errors.js";
36
38
  import { diagnoseAndroidBuild } from "../tools/androidDoctor.js";
@@ -85,7 +87,63 @@ interface VerifyReport {
85
87
  failure_reason?: string;
86
88
  }
87
89
 
90
+ interface TaskContext {
91
+ taskId: string;
92
+ taskDir: string;
93
+ statusFile: string;
94
+ repoPath: string;
95
+ wsRoot: string;
96
+ config: ReturnType<typeof getConfig>;
97
+ plansDir: string;
98
+ initialStatus: Record<string, any>;
99
+ planId: string;
100
+ agentName: string;
101
+ testCommand: string;
102
+ changePolicy: string;
103
+ verifyCommands: string[];
104
+ timeoutSeconds: number;
105
+ startedAtMs: number;
106
+ deadlineMs: number;
107
+ beforeSnapshot: RepoSnapshot;
108
+ beforeWorkspaceSnapshot: RepoSnapshot;
109
+ externalDirtyBaseline: ExternalDirtyFile[];
110
+ }
111
+
112
+ interface ExecutionState {
113
+ agentResult: ManagedProcessResult | null;
114
+ testResult: TestExecutionResult;
115
+ verifyResults: TestExecutionResult[];
116
+ finalStatus: TaskStatus;
117
+ finalError: string | null;
118
+ lastCaughtError: unknown;
119
+ }
120
+
121
+ interface ArtifactEvidence {
122
+ changes: ChangeArtifacts;
123
+ artifactStatus: "collected" | "partial" | "failed" | "timeout";
124
+ artifactCollectionError: string | null;
125
+ artifactCollectionStartedAt: string;
126
+ artifactCollectionFinishedAt: string;
127
+ outOfScopeChanges: ChangedFile[];
128
+ newOutOfScopeChanges: ExternalDirtyFile[];
129
+ preexistingExternalDirty: ExternalDirtyFile[];
130
+ preexistingWarnings: string[];
131
+ cleanupReport: PostTaskCleanupReport;
132
+ artifactManifest: ArtifactManifest;
133
+ changedFileGroups: ChangedFileGroups;
134
+ }
135
+
88
136
  export async function runTask(taskId: string): Promise<TaskRunResult> {
137
+ const prepared = await prepareTask(taskId);
138
+ if (!("taskDir" in prepared)) return prepared;
139
+ const ctx: TaskContext = prepared;
140
+ const state = await executeAgent(ctx);
141
+ await runVerification(ctx, state);
142
+ const evidence = await collectArtifacts(ctx, state);
143
+ return finalizeTask(ctx, state, evidence);
144
+ }
145
+
146
+ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult> {
89
147
  const config = getConfig();
90
148
  const tasksDir = getTasksDir(config);
91
149
  const plansDir = getPlansDir(config);
@@ -126,11 +184,10 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
126
184
  return failBeforeExecution(taskId, taskDir, message, error);
127
185
  }
128
186
 
129
- // ── Assessment freshness revalidation before execution ──
130
187
  const assessmentId = String(initialStatus.assessment_id || "");
131
188
  if (assessmentId) {
132
189
  try {
133
- const preExecSnapshot = captureRepoSnapshot(repoPath);
190
+ const preExecSnapshot = await captureRepoSnapshot(repoPath);
134
191
  const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
135
192
  if (!validation.valid) {
136
193
  const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
@@ -151,141 +208,157 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
151
208
  current_command: null,
152
209
  error: null,
153
210
  });
154
- // v0.7.0: record task_started_at and watcher_instance_id so diagnose_task
155
- // can detect PID reuse and orphaned tasks. watcher_instance_id comes from
156
- // the watcher process env; when run_task MCP tool is used directly, this
157
- // field is intentionally left undefined so ownership cannot be falsely claimed.
158
211
  writeTaskRuntime(taskDir, {
159
212
  task_started_at: new Date(startedAtMs).toISOString(),
160
213
  watcher_instance_id: process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || undefined,
161
214
  });
162
215
  setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
163
216
 
164
- let beforeSnapshot;
165
- let beforeWorkspaceSnapshot;
217
+ let beforeSnapshot: RepoSnapshot;
218
+ let beforeWorkspaceSnapshot: RepoSnapshot;
166
219
  let externalDirtyBaseline: ExternalDirtyFile[] = [];
167
220
  try {
168
- beforeSnapshot = captureRepoSnapshot(repoPath);
169
- beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : captureRepoSnapshot(wsRoot);
221
+ beforeSnapshot = await captureRepoSnapshot(repoPath);
222
+ beforeWorkspaceSnapshot = repoPath === wsRoot ? beforeSnapshot : await captureRepoSnapshot(wsRoot);
170
223
  writeSnapshot(taskDir, "git-before.json", beforeSnapshot);
171
224
  writeSnapshot(taskDir, "workspace-before.json", beforeWorkspaceSnapshot);
172
- // Phase 4: Record external dirty files as baseline before task execution
173
225
  externalDirtyBaseline = extractExternalDirtyFiles(beforeWorkspaceSnapshot, repoPath, wsRoot);
174
226
  } catch (error) {
175
227
  const message = `Pre-task snapshot failed: ${errorMessage(error)}`;
176
228
  return failBeforeExecution(taskId, taskDir, message, error);
177
229
  }
178
230
 
179
- let agentResult: ManagedProcessResult | null = null;
180
- let testResult: TestExecutionResult = skippedTest(testCommand, repoPath, "Agent did not complete successfully.");
181
- const verifyResults: TestExecutionResult[] = [];
182
- let finalStatus: TaskStatus = "failed";
183
- let finalError: string | null = null;
184
- let lastCaughtError: unknown = null;
231
+ return {
232
+ taskId, taskDir, statusFile, repoPath, wsRoot, config, plansDir, initialStatus,
233
+ planId, agentName, testCommand, changePolicy, verifyCommands, timeoutSeconds,
234
+ startedAtMs, deadlineMs, beforeSnapshot, beforeWorkspaceSnapshot, externalDirtyBaseline,
235
+ };
236
+ }
237
+
238
+ async function executeAgent(ctx: TaskContext): Promise<ExecutionState> {
239
+ const state: ExecutionState = {
240
+ agentResult: null,
241
+ testResult: skippedTest(ctx.testCommand, ctx.repoPath, "Agent did not complete successfully."),
242
+ verifyResults: [],
243
+ finalStatus: "failed",
244
+ finalError: null,
245
+ lastCaughtError: null,
246
+ };
185
247
 
186
248
  try {
187
- const planFile = resolve(plansDir, planId, "plan.md");
188
- if (!existsSync(planFile)) throw new Error(`Plan not found: "${planId}". Save the plan first.`);
249
+ const planFile = resolve(ctx.plansDir, ctx.planId, "plan.md");
250
+ if (!existsSync(planFile)) throw new Error(`Plan not found: "${ctx.planId}". Save the plan first.`);
189
251
  const planContent = readFileSync(planFile, "utf-8");
190
- const prompt = buildExecutionPrompt(planContent, repoPath, testCommand);
191
- const invocation = buildAgentInvocation(agentName, repoPath, prompt, config);
192
- const agentCommandLabel = invocation.commandLabel;
252
+ const prompt = buildExecutionPrompt(planContent, ctx.repoPath, ctx.testCommand);
253
+ const invocation = buildAgentInvocation(ctx.agentName, ctx.repoPath, prompt, ctx.config);
193
254
 
194
- setTaskPhase(taskDir, "executing_agent", agentCommandLabel);
195
- agentResult = await runManagedProcess({
255
+ setTaskPhase(ctx.taskDir, "executing_agent", invocation.commandLabel);
256
+ state.agentResult = await runManagedProcess({
196
257
  command: invocation.command,
197
258
  args: invocation.args,
198
- cwd: repoPath,
199
- taskDir,
200
- statusFile,
259
+ cwd: ctx.repoPath,
260
+ taskDir: ctx.taskDir,
261
+ statusFile: ctx.statusFile,
201
262
  phase: "executing_agent",
202
- currentCommand: agentCommandLabel,
203
- deadlineMs,
204
- stdoutPath: join(taskDir, "stdout.log"),
205
- stderrPath: join(taskDir, "stderr.log"),
263
+ currentCommand: invocation.commandLabel,
264
+ deadlineMs: ctx.deadlineMs,
265
+ stdoutPath: join(ctx.taskDir, "stdout.log"),
266
+ stderrPath: join(ctx.taskDir, "stderr.log"),
206
267
  });
207
268
 
269
+ const agentResult = state.agentResult;
208
270
  if (agentResult.terminationReason === "canceled" || agentResult.terminationReason === "killed") {
209
- finalStatus = "canceled";
210
- finalError = agentResult.terminationReason === "killed"
271
+ state.finalStatus = "canceled";
272
+ state.finalError = agentResult.terminationReason === "killed"
211
273
  ? "Task was terminated by kill_task."
212
274
  : "Task was canceled by user request.";
213
275
  } else if (agentResult.terminationReason === "timeout") {
214
- finalError = `Task timed out after ${timeoutSeconds} seconds during agent execution.`;
276
+ state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during agent execution.`;
215
277
  } else if (agentResult.spawnError) {
216
- finalError = `Agent spawn failed: ${agentResult.spawnError}`;
278
+ state.finalError = `Agent spawn failed: ${agentResult.spawnError}`;
217
279
  } else if (agentResult.exitCode !== 0) {
218
- finalError = `Agent exited with code ${agentResult.exitCode}.`;
219
- } else if (verifyCommands.length > 0) {
220
- for (const command of verifyCommands) {
221
- setTaskPhase(taskDir, "running_tests", command);
222
- const verification = await runTrustedTestCommand(command, repoPath, taskDir, statusFile, deadlineMs);
223
- verifyResults.push(verification);
224
- if (verification.terminationReason || Date.now() >= deadlineMs) break;
280
+ state.finalError = `Agent exited with code ${agentResult.exitCode}.`;
281
+ }
282
+ } catch (error) {
283
+ state.lastCaughtError = error;
284
+ state.finalError = errorMessage(error);
285
+ }
286
+
287
+ return state;
288
+ }
289
+
290
+ async function runVerification(ctx: TaskContext, state: ExecutionState): Promise<ExecutionState> {
291
+ if (state.finalError !== null) return state;
292
+
293
+ try {
294
+ if (ctx.verifyCommands.length > 0) {
295
+ for (const command of ctx.verifyCommands) {
296
+ setTaskPhase(ctx.taskDir, "running_tests", command);
297
+ const verification = await runTrustedTestCommand(command, ctx.repoPath, ctx.taskDir, ctx.statusFile, ctx.deadlineMs);
298
+ state.verifyResults.push(verification);
299
+ if (verification.terminationReason || Date.now() >= ctx.deadlineMs) break;
225
300
  }
226
- testResult = verifyResults[0] || skippedTest(testCommand, repoPath, "No verification command ran.");
227
- const interrupted = verifyResults.find((result) => result.terminationReason);
228
- const failedVerification = verifyResults.find((result) => result.spawnError || result.exitCode !== 0);
301
+ state.testResult = state.verifyResults[0] || skippedTest(ctx.testCommand, ctx.repoPath, "No verification command ran.");
302
+ const interrupted = state.verifyResults.find((result) => result.terminationReason);
303
+ const failedVerification = state.verifyResults.find((result) => result.spawnError || result.exitCode !== 0);
229
304
  if (interrupted?.terminationReason === "canceled" || interrupted?.terminationReason === "killed") {
230
- finalStatus = "canceled";
231
- finalError = interrupted.terminationReason === "killed"
305
+ state.finalStatus = "canceled";
306
+ state.finalError = interrupted.terminationReason === "killed"
232
307
  ? "Task was terminated by kill_task during verification."
233
308
  : "Task was canceled during verification.";
234
- } else if (interrupted?.terminationReason === "timeout" || Date.now() >= deadlineMs) {
235
- finalError = `Task timed out after ${timeoutSeconds} seconds during verification.`;
309
+ } else if (interrupted?.terminationReason === "timeout" || Date.now() >= ctx.deadlineMs) {
310
+ state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during verification.`;
236
311
  } else if (failedVerification) {
237
- finalStatus = "failed_verification";
238
- finalError = failedVerification.spawnError
239
- ? `Verification command \"${failedVerification.command}\" could not start: ${failedVerification.spawnError}`
240
- : `Verification command \"${failedVerification.command}\" exited with code ${failedVerification.exitCode}.`;
241
- } else if (verifyResults.length === verifyCommands.length) {
242
- finalStatus = "done_by_agent";
312
+ state.finalStatus = "failed_verification";
313
+ state.finalError = failedVerification.spawnError
314
+ ? `Verification command "${failedVerification.command}" could not start: ${failedVerification.spawnError}`
315
+ : `Verification command "${failedVerification.command}" exited with code ${failedVerification.exitCode}.`;
316
+ } else if (state.verifyResults.length === ctx.verifyCommands.length) {
317
+ state.finalStatus = "done_by_agent";
243
318
  } else {
244
- finalError = "Verification did not complete all configured commands.";
319
+ state.finalError = "Verification did not complete all configured commands.";
245
320
  }
246
321
  } else {
247
- testResult = skippedTest(testCommand, repoPath, "No verification command configured.");
248
- finalStatus = "done_by_agent";
322
+ state.testResult = skippedTest(ctx.testCommand, ctx.repoPath, "No verification command configured.");
323
+ state.finalStatus = "done_by_agent";
249
324
  }
250
325
  } catch (error) {
251
- lastCaughtError = error;
252
- finalError = errorMessage(error);
326
+ state.lastCaughtError = error;
327
+ state.finalError = errorMessage(error);
253
328
  }
254
329
 
255
- let cleanupReport: PostTaskCleanupReport = {
256
- enabled: true,
257
- removed: [],
258
- skipped: [],
259
- source_files_touched: 0,
260
- };
261
- if (finalStatus !== "canceled") {
330
+ return state;
331
+ }
332
+
333
+ async function collectArtifacts(ctx: TaskContext, state: ExecutionState): Promise<ArtifactEvidence> {
334
+ let cleanupReport: PostTaskCleanupReport = { enabled: true, removed: [], skipped: [], source_files_touched: 0 };
335
+ if (state.finalStatus !== "canceled") {
262
336
  try {
263
- cleanupReport = runPostTaskCleanup(repoPath, taskDir);
264
- updateStatus(taskDir, { cleanup: cleanupReport });
337
+ cleanupReport = runPostTaskCleanup(ctx.repoPath, ctx.taskDir);
338
+ updateStatus(ctx.taskDir, { cleanup: cleanupReport });
265
339
  } catch (error) {
266
340
  cleanupReport = {
267
- enabled: true,
268
- removed: [],
341
+ enabled: true, removed: [],
269
342
  skipped: [{ path: ".", reason: "post_task_cleanup", skip_reason: errorMessage(error) }],
270
343
  source_files_touched: 0,
271
344
  };
272
- writeFileSync(join(taskDir, "post-task-cleanup.json"), JSON.stringify(cleanupReport, null, 2), "utf-8");
273
- updateStatus(taskDir, { cleanup: cleanupReport });
345
+ writeFileSync(join(ctx.taskDir, "post-task-cleanup.json"), JSON.stringify(cleanupReport, null, 2), "utf-8");
346
+ updateStatus(ctx.taskDir, { cleanup: cleanupReport });
274
347
  }
275
348
  }
276
- setTaskPhase(taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
349
+
350
+ setTaskPhase(ctx.taskDir, "collecting_artifacts", null, "Capturing post-task state and writing reports.");
277
351
  const artifactCollectionStartedAt = new Date().toISOString();
278
352
  let changes: ChangeArtifacts;
279
353
  let artifactStatus: "collected" | "partial" | "failed" | "timeout" = "collected";
280
354
  let artifactCollectionError: string | null = null;
281
355
  let timeoutHandle: ReturnType<typeof setTimeout> | undefined;
282
356
  try {
283
- // Phase 5: Wrap artifact collection with a timeout
284
357
  const collectionResult = await Promise.race([
285
- Promise.resolve().then(() => {
286
- const afterSnapshot = captureRepoSnapshot(repoPath);
287
- writeSnapshot(taskDir, "git-after.json", afterSnapshot);
288
- return buildChangeArtifacts(repoPath, beforeSnapshot, afterSnapshot);
358
+ Promise.resolve().then(async () => {
359
+ const afterSnapshot = await captureRepoSnapshot(ctx.repoPath);
360
+ writeSnapshot(ctx.taskDir, "git-after.json", afterSnapshot);
361
+ return await buildChangeArtifacts(ctx.repoPath, ctx.beforeSnapshot, afterSnapshot);
289
362
  }),
290
363
  new Promise<never>((_, reject) => {
291
364
  timeoutHandle = setTimeout(() => reject(new Error("Artifact collection timed out")), ARTIFACT_COLLECTION_TIMEOUT_MS);
@@ -295,13 +368,9 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
295
368
  changes = collectionResult;
296
369
  } catch (error) {
297
370
  if (timeoutHandle) clearTimeout(timeoutHandle);
298
- lastCaughtError = error;
371
+ state.lastCaughtError = error;
299
372
  artifactCollectionError = errorMessage(error);
300
- if (artifactCollectionError.includes("timed out")) {
301
- artifactStatus = "timeout";
302
- } else {
303
- artifactStatus = "failed";
304
- }
373
+ artifactStatus = artifactCollectionError.includes("timed out") ? "timeout" : "failed";
305
374
  changes = {
306
375
  changed_files: [],
307
376
  diff: `(change capture failed: ${artifactCollectionError})\n`,
@@ -311,17 +380,16 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
311
380
  additions: 0,
312
381
  deletions: 0,
313
382
  file_stats: [],
314
- workspace_dirty_before: beforeSnapshot.workspace_dirty,
315
- workspace_dirty_after: beforeSnapshot.workspace_dirty,
383
+ workspace_dirty_before: ctx.beforeSnapshot.workspace_dirty,
384
+ workspace_dirty_after: ctx.beforeSnapshot.workspace_dirty,
316
385
  patch_mode: "hash_only",
317
386
  unavailable_reason: `Change capture failed: ${artifactCollectionError}`,
318
387
  artifact_hygiene: emptyArtifactHygiene(),
319
388
  };
320
- // Phase 5: Write partial_result.md when artifact collection fails
321
- writeFileSync(join(taskDir, "partial_result.md"), [
389
+ writeFileSync(join(ctx.taskDir, "partial_result.md"), [
322
390
  "# PatchWarden Partial Result",
323
391
  "",
324
- `Task: ${taskId}`,
392
+ `Task: ${ctx.taskId}`,
325
393
  "",
326
394
  "## Artifact Collection Status",
327
395
  artifactStatus,
@@ -330,64 +398,90 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
330
398
  artifactCollectionError,
331
399
  "",
332
400
  "## Agent Status",
333
- agentResult ? `Exit code: ${agentResult.exitCode}` : "Agent did not run",
401
+ state.agentResult ? `Exit code: ${state.agentResult.exitCode}` : "Agent did not run",
334
402
  "",
335
403
  "## Verification Status",
336
- verifyResults.length > 0 ? verifyResults.map((r) => `- ${r.command}: exit ${r.exitCode}`).join("\n") : "No verification ran",
404
+ state.verifyResults.length > 0 ? state.verifyResults.map((r) => `- ${r.command}: exit ${r.exitCode}`).join("\n") : "No verification ran",
337
405
  "",
338
406
  "## Note",
339
407
  "Artifact collection did not complete. The task result may be incomplete. Review stdout.log, stderr.log, and verify.json for details.",
340
408
  "",
341
409
  ].join("\n"), "utf-8");
342
- // Don't override test failure with artifact failure
343
- if (finalStatus === "done_by_agent") {
344
- finalError = `Change capture failed: ${artifactCollectionError}`;
345
- finalStatus = "failed";
410
+ if (state.finalStatus === "done_by_agent") {
411
+ state.finalError = `Change capture failed: ${artifactCollectionError}`;
412
+ state.finalStatus = "failed";
346
413
  } else {
347
- finalError ||= `Change capture failed: ${artifactCollectionError}`;
414
+ state.finalError ||= `Change capture failed: ${artifactCollectionError}`;
348
415
  }
349
416
  }
350
417
  const artifactCollectionFinishedAt = new Date().toISOString();
351
418
 
352
419
  let outOfScopeChanges: ChangedFile[] = [];
353
- let preexistingExternalDirty: ExternalDirtyFile[] = externalDirtyBaseline;
420
+ const preexistingExternalDirty: ExternalDirtyFile[] = ctx.externalDirtyBaseline;
354
421
  let newOutOfScopeChanges: ExternalDirtyFile[] = [];
355
422
  try {
356
- const afterWorkspaceSnapshot = repoPath === wsRoot ? captureRepoSnapshot(repoPath) : captureRepoSnapshot(wsRoot);
357
- writeSnapshot(taskDir, "workspace-after.json", afterWorkspaceSnapshot);
358
- const allExternalDirty = extractExternalDirtyFiles(afterWorkspaceSnapshot, repoPath, wsRoot);
359
- // Phase 4: Only NEW external dirty files (not in baseline) are scope violations
360
- newOutOfScopeChanges = findNewExternalDirtyFiles(externalDirtyBaseline, allExternalDirty);
361
- outOfScopeChanges = compareSnapshots(beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
423
+ const afterWorkspaceSnapshot = ctx.repoPath === ctx.wsRoot ? await captureRepoSnapshot(ctx.repoPath) : await captureRepoSnapshot(ctx.wsRoot);
424
+ writeSnapshot(ctx.taskDir, "workspace-after.json", afterWorkspaceSnapshot);
425
+ const allExternalDirty = extractExternalDirtyFiles(afterWorkspaceSnapshot, ctx.repoPath, ctx.wsRoot);
426
+ newOutOfScopeChanges = findNewExternalDirtyFiles(ctx.externalDirtyBaseline, allExternalDirty);
427
+ outOfScopeChanges = compareSnapshots(ctx.beforeWorkspaceSnapshot, afterWorkspaceSnapshot)
362
428
  .filter((file) =>
363
- !isPathInside(resolve(wsRoot, file.path), repoPath) ||
364
- Boolean(file.old_path && !isPathInside(resolve(wsRoot, file.old_path), repoPath))
429
+ !isPathInside(resolve(ctx.wsRoot, file.path), ctx.repoPath) ||
430
+ Boolean(file.old_path && !isPathInside(resolve(ctx.wsRoot, file.old_path), ctx.repoPath))
365
431
  );
366
432
  } catch (error) {
367
- finalError ||= `Workspace scope capture failed: ${errorMessage(error)}`;
368
- if (finalStatus === "done_by_agent") finalStatus = "failed";
433
+ state.finalError ||= `Workspace scope capture failed: ${errorMessage(error)}`;
434
+ if (state.finalStatus === "done_by_agent") state.finalStatus = "failed";
369
435
  }
370
436
 
371
- // Phase 4: Pre-existing external dirty files are warnings, not failures
372
437
  const preexistingWarnings: string[] = preexistingExternalDirty.length > 0
373
438
  ? [`Pre-existing external dirty files (not caused by this task): ${preexistingExternalDirty.length} file(s)`]
374
439
  : [];
375
440
 
441
+ applyScopeViolationVerdict(ctx, state, changes, newOutOfScopeChanges, preexistingExternalDirty);
442
+
443
+ writeFileSync(join(ctx.taskDir, "git.diff"), changes.diff, "utf-8");
444
+ writeFileSync(join(ctx.taskDir, "diff.patch"), changes.diff, "utf-8");
445
+ writeFileSync(join(ctx.taskDir, "changed-files.json"), JSON.stringify(changes, null, 2), "utf-8");
446
+ const artifactManifest = await buildArtifactManifest(changes.changed_files, ctx.repoPath, ctx.taskId);
447
+ writeFileSync(join(ctx.taskDir, "artifact_manifest.json"), JSON.stringify(artifactManifest, null, 2), "utf-8");
448
+ const changedFileGroups = groupChangedFiles(changes.changed_files);
449
+ writeFileSync(join(ctx.taskDir, "file-stats.json"), JSON.stringify({
450
+ task_id: ctx.taskId,
451
+ additions: changes.additions,
452
+ deletions: changes.deletions,
453
+ files: changes.file_stats,
454
+ }, null, 2), "utf-8");
455
+
456
+ return {
457
+ changes, artifactStatus, artifactCollectionError, artifactCollectionStartedAt, artifactCollectionFinishedAt,
458
+ outOfScopeChanges, newOutOfScopeChanges, preexistingExternalDirty, preexistingWarnings, cleanupReport,
459
+ artifactManifest, changedFileGroups,
460
+ };
461
+ }
462
+
463
+ function applyScopeViolationVerdict(
464
+ ctx: TaskContext,
465
+ state: ExecutionState,
466
+ changes: ChangeArtifacts,
467
+ newOutOfScopeChanges: ExternalDirtyFile[],
468
+ preexistingExternalDirty: ExternalDirtyFile[],
469
+ ): void {
376
470
  if (newOutOfScopeChanges.length > 0) {
377
- finalStatus = "failed_scope_violation";
378
- finalError = `Detected ${newOutOfScopeChanges.length} new change(s) outside resolved_repo_path during task execution.`;
379
- writeFileSync(join(taskDir, "rollback-plan.json"), JSON.stringify({
380
- task_id: taskId,
471
+ state.finalStatus = "failed_scope_violation";
472
+ state.finalError = `Detected ${newOutOfScopeChanges.length} new change(s) outside resolved_repo_path during task execution.`;
473
+ writeFileSync(join(ctx.taskDir, "rollback-plan.json"), JSON.stringify({
474
+ task_id: ctx.taskId,
381
475
  status: "review_required",
382
476
  automatic_rollback_performed: false,
383
477
  warning: "Review ownership and concurrent edits before rollback. PatchWarden did not modify or restore these files.",
384
478
  out_of_scope_changes: newOutOfScopeChanges,
385
479
  preexisting_external_dirty_files: preexistingExternalDirty,
386
480
  }, null, 2), "utf-8");
387
- writeFileSync(join(taskDir, "rollback_scope_violation_plan.md"), [
481
+ writeFileSync(join(ctx.taskDir, "rollback_scope_violation_plan.md"), [
388
482
  "# Scope Violation Rollback Plan",
389
483
  "",
390
- `Task: ${taskId}`,
484
+ `Task: ${ctx.taskId}`,
391
485
  "",
392
486
  "PatchWarden did not automatically roll back any file. Review concurrent or user-owned edits before acting.",
393
487
  "",
@@ -395,47 +489,34 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
395
489
  ...newOutOfScopeChanges.map((file) => `- ${file.change}: ${file.path}`),
396
490
  "",
397
491
  ].join("\n"), "utf-8");
398
- } else if (changePolicy === "no_changes" && changes.changed_files.length > 0) {
399
- finalStatus = "failed_policy_violation";
400
- finalError = `Task policy requires no repository changes, but detected ${changes.changed_files.length} change(s).`;
492
+ } else if (ctx.changePolicy === "no_changes" && changes.changed_files.length > 0) {
493
+ state.finalStatus = "failed_policy_violation";
494
+ state.finalError = `Task policy requires no repository changes, but detected ${changes.changed_files.length} change(s).`;
401
495
  }
496
+ }
402
497
 
403
- writeFileSync(join(taskDir, "git.diff"), changes.diff, "utf-8");
404
- writeFileSync(join(taskDir, "diff.patch"), changes.diff, "utf-8");
405
- writeFileSync(join(taskDir, "changed-files.json"), JSON.stringify(changes, null, 2), "utf-8");
406
-
407
- // Phase 6: Generate artifact_manifest.json and group changed files
408
- const artifactManifest: ArtifactManifest = buildArtifactManifest(changes.changed_files, repoPath, taskId);
409
- writeFileSync(join(taskDir, "artifact_manifest.json"), JSON.stringify(artifactManifest, null, 2), "utf-8");
410
- const changedFileGroups = groupChangedFiles(changes.changed_files);
498
+ function finalizeTask(ctx: TaskContext, state: ExecutionState, evidence: ArtifactEvidence): TaskRunResult {
499
+ const { changes, artifactStatus, artifactCollectionError, artifactCollectionStartedAt, artifactCollectionFinishedAt,
500
+ outOfScopeChanges, newOutOfScopeChanges, preexistingExternalDirty, preexistingWarnings, cleanupReport,
501
+ artifactManifest, changedFileGroups } = evidence;
411
502
 
412
- writeFileSync(join(taskDir, "file-stats.json"), JSON.stringify({
413
- task_id: taskId,
414
- additions: changes.additions,
415
- deletions: changes.deletions,
416
- files: changes.file_stats,
417
- }, null, 2), "utf-8");
418
- writeFileSync(join(taskDir, "test.log"), buildTestLog(testResult), "utf-8");
419
- const verifyJson = buildVerifyJson(verifyCommands, verifyResults, repoPath);
420
- // Phase 4: Use newOutOfScopeChanges (not outOfScopeChanges) for verify_status
421
- // to stay consistent with finalStatus. Pre-existing external dirty files
422
- // that didn't change during the task should NOT cause verify_status to fail.
503
+ writeFileSync(join(ctx.taskDir, "test.log"), buildTestLog(state.testResult), "utf-8");
504
+ const verifyJson = buildVerifyJson(ctx.verifyCommands, state.verifyResults, ctx.repoPath);
423
505
  if (newOutOfScopeChanges.length > 0) {
424
506
  verifyJson.status = "failed";
425
507
  verifyJson.failure_reason = "scope_violation";
426
- } else if (finalStatus === "failed_policy_violation") {
508
+ } else if (state.finalStatus === "failed_policy_violation") {
427
509
  verifyJson.status = "failed";
428
510
  verifyJson.failure_reason = "change_policy_violation";
429
511
  }
430
- writeFileSync(join(taskDir, "verify.json"), JSON.stringify(verifyJson, null, 2), "utf-8");
431
- writeFileSync(join(taskDir, "verify.log"), buildVerifyLog(verifyJson.commands), "utf-8");
512
+ writeFileSync(join(ctx.taskDir, "verify.json"), JSON.stringify(verifyJson, null, 2), "utf-8");
513
+ writeFileSync(join(ctx.taskDir, "verify.log"), buildVerifyLog(verifyJson.commands), "utf-8");
432
514
 
433
- if (!["canceled", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(finalStatus)) finalStatus = "failed";
434
- const finalPhase: TaskPhase = finalStatus === "done_by_agent" ? "done_by_agent" : finalStatus;
435
- const followup = buildFailureFollowup(finalStatus, finalError, verifyJson.commands);
515
+ if (!["canceled", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(state.finalStatus)) state.finalStatus = "failed";
516
+ const finalPhase: TaskPhase = state.finalStatus === "done_by_agent" ? "done_by_agent" : (state.finalStatus as TaskPhase);
517
+ const followup = buildFailureFollowup(state.finalStatus, state.finalError, verifyJson.commands);
436
518
 
437
- // Phase 7: Run Android build environment diagnostics if android_app exists
438
- const androidDiagnostic = diagnoseAndroidBuild(repoPath);
519
+ const androidDiagnostic = diagnoseAndroidBuild(ctx.repoPath);
439
520
  let androidWarning: string | null = null;
440
521
  if (androidDiagnostic.status !== "skip") {
441
522
  if (androidDiagnostic.status === "fail") {
@@ -449,13 +530,13 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
449
530
  }
450
531
 
451
532
  const resultMd = buildResultMarkdown({
452
- taskId,
453
- planId,
454
- agent: agentName,
455
- status: finalStatus,
456
- error: finalError,
457
- agentResult,
458
- testResult,
533
+ taskId: ctx.taskId,
534
+ planId: ctx.planId,
535
+ agent: ctx.agentName,
536
+ status: state.finalStatus,
537
+ error: state.finalError,
538
+ agentResult: state.agentResult,
539
+ testResult: state.testResult,
459
540
  verify: verifyJson,
460
541
  changes,
461
542
  outOfScopeChanges,
@@ -466,18 +547,88 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
466
547
  androidWarning,
467
548
  preexistingWarnings,
468
549
  });
469
- writeFileSync(join(taskDir, "result.md"), resultMd, "utf-8");
470
- writeFileSync(join(taskDir, "result.json"), JSON.stringify({
471
- task_id: taskId,
472
- status: finalStatus,
473
- agent: agentName,
474
- workspace_root: wsRoot,
475
- repo_path: initialStatus.repo_path,
476
- resolved_repo_path: repoPath,
477
- plan_source: initialStatus.plan_source || "saved",
478
- template: initialStatus.template || null,
479
- change_policy: changePolicy,
480
- summary: finalError || "Agent execution and configured verification completed successfully.",
550
+ writeFileSync(join(ctx.taskDir, "result.md"), resultMd, "utf-8");
551
+
552
+ const resultJson = buildResultJson({
553
+ ctx, state, evidence, verifyJson, followup, androidDiagnostic, androidWarning,
554
+ });
555
+ writeFileSync(join(ctx.taskDir, "result.json"), JSON.stringify(resultJson, null, 2), "utf-8");
556
+
557
+ if (state.finalError) {
558
+ const structuredError = errorPayload(state.lastCaughtError);
559
+ writeFileSync(join(ctx.taskDir, "error.log"), JSON.stringify({
560
+ summary: state.finalError,
561
+ ...structuredError,
562
+ }, null, 2), "utf-8");
563
+ }
564
+
565
+ const finishedAt = new Date().toISOString();
566
+ updateStatus(ctx.taskDir, {
567
+ status: state.finalStatus,
568
+ phase: finalPhase,
569
+ current_command: null,
570
+ last_heartbeat_at: finishedAt,
571
+ finished_at: finishedAt,
572
+ error: state.finalError,
573
+ changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
574
+ artifact_hygiene_counts: changes.artifact_hygiene.counts,
575
+ artifact_status: artifactStatus,
576
+ artifact_collection_error: artifactCollectionError,
577
+ artifact_collection_started_at: artifactCollectionStartedAt,
578
+ artifact_collection_finished_at: artifactCollectionFinishedAt,
579
+ cleanup: cleanupReport,
580
+ out_of_scope_changes: outOfScopeChanges,
581
+ new_out_of_scope_changes: newOutOfScopeChanges,
582
+ preexisting_external_dirty_files: preexistingExternalDirty,
583
+ verify_status: verifyJson.status,
584
+ verify_commands: ctx.verifyCommands,
585
+ diff_available: changes.diff_available,
586
+ diff_truncated: changes.diff_truncated,
587
+ workspace_dirty_before: changes.workspace_dirty_before,
588
+ workspace_dirty_after: changes.workspace_dirty_after,
589
+ workspace_dirty: changes.workspace_dirty_after,
590
+ acceptance_status: state.finalStatus === "done_by_agent" ? "pending" : null,
591
+ });
592
+ writeTaskRuntime(ctx.taskDir, {
593
+ phase: finalPhase,
594
+ current_command: null,
595
+ last_heartbeat_at: finishedAt,
596
+ runner_pid: process.pid,
597
+ child_pid: undefined,
598
+ });
599
+ writeTaskProgress(ctx.taskDir, finalPhase, {
600
+ heartbeatAt: finishedAt,
601
+ note: state.finalError || `Task finished with status ${state.finalStatus}.`,
602
+ });
603
+
604
+ return { task_id: ctx.taskId, status: state.finalStatus, error: state.finalError };
605
+ }
606
+
607
+ function buildResultJson(input: {
608
+ ctx: TaskContext;
609
+ state: ExecutionState;
610
+ evidence: ArtifactEvidence;
611
+ verifyJson: VerifyReport;
612
+ followup: ReturnType<typeof buildFailureFollowup>;
613
+ androidDiagnostic: ReturnType<typeof diagnoseAndroidBuild>;
614
+ androidWarning: string | null;
615
+ }): Record<string, unknown> {
616
+ const { ctx, state, evidence, verifyJson, followup, androidDiagnostic, androidWarning } = input;
617
+ const { changes, artifactStatus, artifactCollectionError, artifactCollectionStartedAt,
618
+ artifactCollectionFinishedAt, outOfScopeChanges, newOutOfScopeChanges,
619
+ preexistingExternalDirty, cleanupReport, artifactManifest, changedFileGroups } = evidence;
620
+
621
+ return {
622
+ task_id: ctx.taskId,
623
+ status: state.finalStatus,
624
+ agent: ctx.agentName,
625
+ workspace_root: ctx.wsRoot,
626
+ repo_path: ctx.initialStatus.repo_path,
627
+ resolved_repo_path: ctx.repoPath,
628
+ plan_source: ctx.initialStatus.plan_source || "saved",
629
+ template: ctx.initialStatus.template || null,
630
+ change_policy: ctx.changePolicy,
631
+ summary: state.finalError || "Agent execution and configured verification completed successfully.",
481
632
  changed_files: changes.changed_files,
482
633
  changed_file_groups: {
483
634
  source_changes: changedFileGroups.source_changes.length,
@@ -515,70 +666,21 @@ export async function runTask(taskId: string): Promise<TaskRunResult> {
515
666
  ...(artifactStatus !== "collected" ? ["partial_result.md"] : []),
516
667
  ],
517
668
  warnings: [
518
- ...beforeSnapshot.warnings,
669
+ ...ctx.beforeSnapshot.warnings,
519
670
  ...(changes.diff_truncated ? ["diff.patch was truncated; changed-files.json retains file evidence."] : []),
520
- ...preexistingWarnings,
671
+ ...evidence.preexistingWarnings,
521
672
  ...(androidWarning ? [androidWarning] : []),
522
673
  ],
523
- errors: finalError ? [finalError] : [],
524
- known_issues: finalError ? [finalError] : [],
674
+ errors: state.finalError ? [state.finalError] : [],
675
+ known_issues: state.finalError ? [state.finalError] : [],
525
676
  failure_reason: followup.failure_reason,
526
677
  failed_command: followup.failed_command,
527
678
  suggested_next_action: followup.suggested_next_action,
528
679
  safe_followup_prompt: followup.safe_followup_prompt,
529
- next_steps: finalStatus === "done_by_agent"
680
+ next_steps: state.finalStatus === "done_by_agent"
530
681
  ? ["Review get_task_summary and audit_task before accepting the work."]
531
682
  : ["Resolve the reported failure before accepting the work."],
532
- }, null, 2), "utf-8");
533
- if (finalError) {
534
- // Phase 9: Preserve PatchWardenError structured info in error.log
535
- const structuredError = errorPayload(lastCaughtError);
536
- writeFileSync(join(taskDir, "error.log"), JSON.stringify({
537
- summary: finalError,
538
- ...structuredError,
539
- }, null, 2), "utf-8");
540
- }
541
-
542
- const finishedAt = new Date().toISOString();
543
- updateStatus(taskDir, {
544
- status: finalStatus,
545
- phase: finalPhase,
546
- current_command: null,
547
- last_heartbeat_at: finishedAt,
548
- finished_at: finishedAt,
549
- error: finalError,
550
- changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
551
- artifact_hygiene_counts: changes.artifact_hygiene.counts,
552
- artifact_status: artifactStatus,
553
- artifact_collection_error: artifactCollectionError,
554
- artifact_collection_started_at: artifactCollectionStartedAt,
555
- artifact_collection_finished_at: artifactCollectionFinishedAt,
556
- cleanup: cleanupReport,
557
- out_of_scope_changes: outOfScopeChanges,
558
- new_out_of_scope_changes: newOutOfScopeChanges,
559
- preexisting_external_dirty_files: preexistingExternalDirty,
560
- verify_status: verifyJson.status,
561
- verify_commands: verifyCommands,
562
- diff_available: changes.diff_available,
563
- diff_truncated: changes.diff_truncated,
564
- workspace_dirty_before: changes.workspace_dirty_before,
565
- workspace_dirty_after: changes.workspace_dirty_after,
566
- workspace_dirty: changes.workspace_dirty_after,
567
- acceptance_status: finalStatus === "done_by_agent" ? "pending" : null,
568
- });
569
- writeTaskRuntime(taskDir, {
570
- phase: finalPhase,
571
- current_command: null,
572
- last_heartbeat_at: finishedAt,
573
- runner_pid: process.pid,
574
- child_pid: undefined,
575
- });
576
- writeTaskProgress(taskDir, finalPhase, {
577
- heartbeatAt: finishedAt,
578
- note: finalError || `Task finished with status ${finalStatus}.`,
579
- });
580
-
581
- return { task_id: taskId, status: finalStatus, error: finalError };
683
+ };
582
684
  }
583
685
 
584
686
  function buildFailureFollowup(
@@ -724,8 +826,8 @@ async function runManagedProcess(options: {
724
826
  // force-resolve to prevent the runner from hanging indefinitely.
725
827
  // Only starts AFTER termination is requested — normal long tasks are unaffected.
726
828
  fallbackTimer = setTimeout(() => {
727
- try { forceKill(child); } catch {}
728
- try { child.kill("SIGKILL"); } catch {}
829
+ try { forceKill(child); } catch {} // cleanup failure is safe to ignore
830
+ try { child.kill("SIGKILL"); } catch {} // cleanup failure is safe to ignore
729
831
  // Resolve the exit promise via the shared finish handle
730
832
  if (childExitFinish) childExitFinish(null);
731
833
  }, 10000);
@@ -1153,7 +1255,7 @@ function gracefulKill(child: ChildProcess): void {
1153
1255
  try {
1154
1256
  if (process.platform !== "win32" && child.pid) process.kill(-child.pid, "SIGTERM");
1155
1257
  else child.kill("SIGTERM");
1156
- } catch {}
1258
+ } catch {} // cleanup failure is safe to ignore
1157
1259
  }
1158
1260
 
1159
1261
  function forceKill(child: ChildProcess): void {
@@ -1170,7 +1272,7 @@ function forceKill(child: ChildProcess): void {
1170
1272
  process.kill(-child.pid, "SIGKILL");
1171
1273
  }
1172
1274
  } catch {
1173
- try { child.kill("SIGKILL"); } catch {}
1275
+ try { child.kill("SIGKILL"); } catch {} // cleanup failure is safe to ignore
1174
1276
  }
1175
1277
  }
1176
1278