patchwarden 1.6.1 → 1.6.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 (91) hide show
  1. package/README.en.md +17 -12
  2. package/README.md +14 -11
  3. package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
  4. package/dist/assessments/assessmentDiagnostics.js +132 -0
  5. package/dist/assessments/assessmentStore.d.ts +39 -0
  6. package/dist/assessments/assessmentStore.js +166 -12
  7. package/dist/assessments/securitySnapshot.d.ts +50 -0
  8. package/dist/assessments/securitySnapshot.js +158 -0
  9. package/dist/control/routes/status.d.ts +14 -0
  10. package/dist/control/routes/status.js +22 -0
  11. package/dist/control/runtime.js +2 -10
  12. package/dist/control/server.js +1 -0
  13. package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
  14. package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
  15. package/dist/direct/directSessionStore.js +5 -4
  16. package/dist/doctor.js +3 -5
  17. package/dist/runner/runTask.d.ts +1 -0
  18. package/dist/runner/runTask.js +47 -6
  19. package/dist/runner/taskRuntime.d.ts +2 -0
  20. package/dist/runner/taskStatusStore.js +12 -1
  21. package/dist/runner/watch.js +64 -1
  22. package/dist/smoke-test.js +3 -3
  23. package/dist/tools/definitions/toolDefs.js +3 -3
  24. package/dist/tools/diagnostics/auditTask.d.ts +1 -0
  25. package/dist/tools/diagnostics/auditTask.js +35 -7
  26. package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
  27. package/dist/tools/diagnostics/healthCheck.js +29 -1
  28. package/dist/tools/tasks/cancelTask.js +2 -1
  29. package/dist/tools/tasks/createTask.d.ts +2 -2
  30. package/dist/tools/tasks/createTask.js +70 -9
  31. package/dist/tools/tasks/diagnoseTask.js +4 -8
  32. package/dist/tools/tasks/getTaskStatus.js +6 -1
  33. package/dist/tools/tasks/getTaskSummary.js +2 -10
  34. package/dist/tools/tasks/listTasks.js +2 -1
  35. package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
  36. package/dist/tools/tasks/reconcileTasks.js +185 -39
  37. package/dist/tools/tasks/runTaskLoop.js +5 -11
  38. package/dist/tools/tasks/taskOutputs.d.ts +2 -2
  39. package/dist/tools/tasks/taskStates.d.ts +6 -0
  40. package/dist/tools/tasks/taskStates.js +52 -0
  41. package/dist/tools/tasks/waitForTask.js +3 -11
  42. package/dist/tools/workspace/listAgents.d.ts +6 -0
  43. package/dist/tools/workspace/listAgents.js +12 -2
  44. package/dist/utils/atomicFile.js +20 -1
  45. package/dist/version.d.ts +1 -1
  46. package/dist/version.js +1 -1
  47. package/docs/CODE_WIKI.md +4 -4
  48. package/docs/open-source-application.md +11 -12
  49. package/docs/release-evidence.md +33 -47
  50. package/package.json +1 -1
  51. package/scripts/checks/control-center-smoke.js +11 -4
  52. package/scripts/checks/lifecycle-smoke.js +5 -2
  53. package/scripts/checks/mcp-smoke.js +31 -1
  54. package/scripts/checks/watcher-supervisor-smoke.js +29 -1
  55. package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
  56. package/scripts/e2e/demo-runtime.mjs +153 -0
  57. package/scripts/release/desktop-preflight.js +1 -1
  58. package/src/assessments/assessmentDiagnostics.ts +172 -0
  59. package/src/assessments/assessmentStore.ts +237 -12
  60. package/src/assessments/securitySnapshot.ts +231 -0
  61. package/src/control/routes/status.ts +37 -0
  62. package/src/control/runtime.ts +2 -10
  63. package/src/control/server.ts +1 -0
  64. package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
  65. package/src/direct/directSessionStore.ts +6 -4
  66. package/src/doctor.ts +3 -5
  67. package/src/runner/runTask.ts +59 -6
  68. package/src/runner/taskRuntime.ts +2 -0
  69. package/src/runner/taskStatusStore.ts +16 -1
  70. package/src/runner/watch.ts +67 -1
  71. package/src/smoke-test.ts +2 -2
  72. package/src/tools/definitions/toolDefs.ts +3 -3
  73. package/src/tools/diagnostics/auditTask.ts +37 -7
  74. package/src/tools/diagnostics/healthCheck.ts +29 -1
  75. package/src/tools/tasks/cancelTask.ts +2 -1
  76. package/src/tools/tasks/createTask.ts +113 -8
  77. package/src/tools/tasks/diagnoseTask.ts +4 -8
  78. package/src/tools/tasks/getTaskStatus.ts +6 -1
  79. package/src/tools/tasks/getTaskSummary.ts +2 -11
  80. package/src/tools/tasks/listTasks.ts +2 -1
  81. package/src/tools/tasks/reconcileTasks.ts +162 -13
  82. package/src/tools/tasks/runTaskLoop.ts +4 -12
  83. package/src/tools/tasks/taskStates.ts +54 -0
  84. package/src/tools/tasks/waitForTask.ts +3 -12
  85. package/src/tools/workspace/listAgents.ts +17 -3
  86. package/src/utils/atomicFile.ts +17 -1
  87. package/src/version.ts +1 -1
  88. package/ui/pages/dashboard.html +4 -0
  89. package/ui/pages/logs.html +29 -0
  90. package/ui/pages/settings.html +3 -3
  91. package/ui/settings.js +26 -17
@@ -5,6 +5,7 @@ export interface ReconcileTasksInput {
5
5
  max_age_minutes?: number;
6
6
  mode?: ReconcileMode;
7
7
  include_done_candidates?: boolean;
8
+ task_ids?: string[];
8
9
  }
9
10
  export interface ReconcileTaskReport {
10
11
  task_id: string;
@@ -15,7 +16,7 @@ export interface ReconcileTaskReport {
15
16
  reasons: string[];
16
17
  safe_actions: SafeAction[];
17
18
  age_seconds: number | null;
18
- action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent";
19
+ action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent" | "marked_canceled" | "marked_timed_out";
19
20
  previous_status: string | null;
20
21
  new_status: string | null;
21
22
  applied_at: string | null;
@@ -4,8 +4,10 @@ import { getTasksDir, getConfig } from "../../config.js";
4
4
  import { diagnoseTask } from "./diagnoseTask.js";
5
5
  import { syncSubgoalOnTaskDone, readTaskGoalMeta } from "../../goal/subgoalSync.js";
6
6
  import { mutateTaskStatus } from "../../runner/taskStatusStore.js";
7
- import { atomicWriteFileSync } from "../../utils/atomicFile.js";
7
+ import { writeTaskRuntime } from "../../runner/taskRuntime.js";
8
+ import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../../utils/atomicFile.js";
8
9
  import { appendBoundedTextFileSync } from "../../utils/boundedFile.js";
10
+ import { isActiveTaskStatus } from "./taskStates.js";
9
11
  // ── Defaults ────────────────────────────────────────────────────────
10
12
  const DEFAULT_MAX_AGE_MINUTES = 30;
11
13
  const RECONCILE_LOG_NAME = "reconcile.log";
@@ -16,6 +18,9 @@ export function reconcileTasks(input = {}, config = getConfig()) {
16
18
  ? Math.min(input.max_age_minutes, 24 * 60)
17
19
  : DEFAULT_MAX_AGE_MINUTES;
18
20
  const includeDoneCandidates = input.include_done_candidates !== false; // default true
21
+ const requestedTaskIds = input.task_ids?.length
22
+ ? new Set(input.task_ids.filter((taskId) => /^task[-_][a-zA-Z0-9_-]+$/.test(taskId)))
23
+ : null;
19
24
  const maxAgeSeconds = maxAgeMinutes * 60;
20
25
  const tasksDir = getTasksDir(config);
21
26
  const reports = [];
@@ -55,8 +60,10 @@ export function reconcileTasks(input = {}, config = getConfig()) {
55
60
  }
56
61
  const taskDirs = entries.filter((e) => e.isDirectory());
57
62
  for (const entry of taskDirs) {
58
- scanned += 1;
59
63
  const taskId = entry.name;
64
+ if (requestedTaskIds && !requestedTaskIds.has(taskId))
65
+ continue;
66
+ scanned += 1;
60
67
  const taskDir = resolve(tasksDir, taskId);
61
68
  const statusFile = join(taskDir, "status.json");
62
69
  if (!existsSync(statusFile))
@@ -69,17 +76,16 @@ export function reconcileTasks(input = {}, config = getConfig()) {
69
76
  continue; // corrupted status, skip
70
77
  }
71
78
  const statusStr = typeof statusData.status === "string" ? statusData.status : "unknown";
72
- // Filter: only running / collecting_artifacts / (optionally) done_by_agent candidates
73
- const isCandidate = statusStr === "running" ||
74
- statusStr === "collecting_artifacts" ||
79
+ // Scan every persisted non-terminal lifecycle spelling, including legacy
80
+ // phase-as-status records left by older Watchers.
81
+ const isCandidate = isActiveTaskStatus(statusStr) ||
75
82
  (includeDoneCandidates && statusStr === "done_by_agent");
76
83
  if (!isCandidate)
77
84
  continue;
78
- // Filter by age — use the oldest reliable timestamp on the task
85
+ // Use the oldest reliable timestamp for ordinary stale detection. Explicit
86
+ // cancel, persisted deadline, and Watcher-instance mismatch bypass this
87
+ // age gate so a restart cannot extend the task's total budget.
79
88
  const ageSeconds = taskAgeSeconds(taskDir, statusData, nowMs);
80
- if (ageSeconds !== null && ageSeconds < maxAgeSeconds)
81
- continue;
82
- candidates += 1;
83
89
  // Diagnose the task
84
90
  let diagnosis;
85
91
  try {
@@ -88,6 +94,14 @@ export function reconcileTasks(input = {}, config = getConfig()) {
88
94
  catch {
89
95
  continue; // diagnosis failed — skip
90
96
  }
97
+ const cancellationRequested = statusData.cancel_requested === true || statusStr === "cancel_requested";
98
+ const deadlineExceeded = taskDeadlineExceeded(statusData, nowMs);
99
+ const urgentRecovery = cancellationRequested
100
+ || deadlineExceeded
101
+ || diagnosis.diagnosis === "orphaned_running";
102
+ if (ageSeconds !== null && ageSeconds < maxAgeSeconds && !urgentRecovery)
103
+ continue;
104
+ candidates += 1;
91
105
  const report = {
92
106
  task_id: taskId,
93
107
  status: diagnosis.status,
@@ -120,7 +134,9 @@ export function reconcileTasks(input = {}, config = getConfig()) {
120
134
  // 4. The diagnosis type maps to a reconcilable action.
121
135
  //
122
136
  // Anything else is left_unchanged and recorded for audit.
123
- if (mode === "safe_fix" && diagnosis.confidence === "high") {
137
+ const deterministicRecovery = !diagnosis.evidence.watcher_owns_task
138
+ && (cancellationRequested || deadlineExceeded);
139
+ if (mode === "safe_fix" && (diagnosis.confidence === "high" || deterministicRecovery)) {
124
140
  if (diagnosis.evidence.watcher_owns_task) {
125
141
  // Hard rule: do not touch tasks the live watcher still owns.
126
142
  skippedActiveWatcher += 1;
@@ -184,35 +200,45 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
184
200
  const previousStatus = typeof currentStatus.status === "string" ? currentStatus.status : "unknown";
185
201
  let newStatus = null;
186
202
  let actionTaken = "left_unchanged";
187
- switch (diagnosis) {
188
- case "stale_running":
189
- newStatus = "failed_stale";
190
- actionTaken = "marked_failed_stale";
191
- break;
192
- case "orphaned_running":
193
- newStatus = "orphaned";
194
- actionTaken = "marked_orphaned";
195
- break;
196
- case "done_candidate":
197
- newStatus = "done_by_agent";
198
- actionTaken = "marked_done_by_agent";
199
- break;
200
- case "artifact_collection_stuck":
201
- // Treat as failed_stale — artifact collection should not hang.
202
- newStatus = "failed_stale";
203
- actionTaken = "marked_failed_stale";
204
- break;
205
- default:
206
- // active_running, possibly_stale_running, unknown, terminal — not eligible
207
- return {
208
- applied: false,
209
- action_taken: "left_unchanged",
210
- previous_status: previousStatus,
211
- new_status: previousStatus,
212
- applied_at: new Date().toISOString(),
213
- skip_reason: `diagnosis "${diagnosis}" has no automatic safe_fix action`,
214
- };
203
+ const cancellationRequested = currentStatus.cancel_requested === true || previousStatus === "cancel_requested";
204
+ if (!evidence.watcher_owns_task && cancellationRequested) {
205
+ newStatus = "canceled";
206
+ actionTaken = "marked_canceled";
207
+ }
208
+ else if (!evidence.watcher_owns_task && taskDeadlineExceeded(currentStatus)) {
209
+ newStatus = "timeout";
210
+ actionTaken = "marked_timed_out";
215
211
  }
212
+ else
213
+ switch (diagnosis) {
214
+ case "stale_running":
215
+ newStatus = "failed_stale";
216
+ actionTaken = "marked_failed_stale";
217
+ break;
218
+ case "orphaned_running":
219
+ newStatus = "orphaned";
220
+ actionTaken = "marked_orphaned";
221
+ break;
222
+ case "done_candidate":
223
+ newStatus = "done_by_agent";
224
+ actionTaken = "marked_done_by_agent";
225
+ break;
226
+ case "artifact_collection_stuck":
227
+ // Treat as failed_stale — artifact collection should not hang.
228
+ newStatus = "failed_stale";
229
+ actionTaken = "marked_failed_stale";
230
+ break;
231
+ default:
232
+ // active_running, possibly_stale_running, unknown, terminal — not eligible
233
+ return {
234
+ applied: false,
235
+ action_taken: "left_unchanged",
236
+ previous_status: previousStatus,
237
+ new_status: previousStatus,
238
+ applied_at: new Date().toISOString(),
239
+ skip_reason: `diagnosis "${diagnosis}" has no automatic safe_fix action`,
240
+ };
241
+ }
216
242
  const appliedAt = new Date().toISOString();
217
243
  const statusFile = join(taskDir, "status.json");
218
244
  const backupFile = join(taskDir, "status.json.bak");
@@ -247,14 +273,42 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
247
273
  current_watcher_instance_id: evidence.current_watcher_instance_id,
248
274
  },
249
275
  },
276
+ process_cleanup_required: !evidence.watcher_owns_task,
277
+ process_cleanup_reason: !evidence.watcher_owns_task
278
+ ? "The previous runner is no longer owned by this Watcher; child-process termination could not be confirmed and no untrusted PID was killed."
279
+ : null,
250
280
  };
281
+ if (newStatus === "canceled") {
282
+ next.canceled_at = appliedAt;
283
+ next.cancel_reason = "Cancellation converged after the original task runner stopped responding.";
284
+ next.termination_reason = "canceled";
285
+ next.error_code = "runner_lost_during_cancel";
286
+ }
287
+ else if (actionTaken === "marked_timed_out") {
288
+ next.error = `Task exceeded its configured timeout of ${Number(current.timeout_seconds)} seconds after the original runner stopped responding.`;
289
+ next.termination_reason = "timeout";
290
+ next.error_code = "task_timeout";
291
+ }
292
+ else if (diagnosis === "artifact_collection_stuck") {
293
+ next.error = "Artifact collection was interrupted and could not be safely resumed after the original runner stopped responding.";
294
+ next.error_code = "artifact_collection_interrupted";
295
+ }
296
+ else if (diagnosis === "orphaned_running") {
297
+ next.error = "The original task runner was lost and its child process ownership could not be re-established.";
298
+ next.error_code = "agent_lost";
299
+ }
300
+ else if (diagnosis === "stale_running") {
301
+ next.error = "The task runner heartbeat expired and no current Runner ownership could be proven.";
302
+ next.error_code = "agent_lost";
303
+ }
251
304
  if (newStatus === "done_by_agent") {
252
305
  next.acceptance_status = "pending";
253
306
  next.legacy_status = "done";
254
307
  }
255
308
  // The backup is created while the same lock protects the exact record
256
309
  // being replaced, so it cannot capture an unrelated newer state.
257
- atomicWriteFileSync(backupFile, readFileSync(statusFile, "utf-8"));
310
+ if (!existsSync(backupFile))
311
+ atomicWriteFileSync(backupFile, readFileSync(statusFile, "utf-8"));
258
312
  return {
259
313
  next,
260
314
  result: { applied: true },
@@ -281,6 +335,20 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
281
335
  skip_reason: "task status could not be locked, backed up, and updated",
282
336
  };
283
337
  }
338
+ if (newStatus !== "done_by_agent") {
339
+ writeRecoveryArtifacts(taskDir, taskId, currentStatus, newStatus, actionTaken, appliedAt);
340
+ }
341
+ // Keep the persisted runtime view terminal as well. get_task_status merges
342
+ // runtime.json over status.json, so leaving an old executing phase here
343
+ // would make a successfully reconciled task appear active again.
344
+ writeTaskRuntime(taskDir, {
345
+ phase: newStatus,
346
+ current_command: null,
347
+ last_heartbeat_at: appliedAt,
348
+ child_pid: undefined,
349
+ child_started_at: undefined,
350
+ child_owned_by_runner_instance_id: undefined,
351
+ });
284
352
  // v0.8.0: 当状态变为 done_by_agent 时,同步关联 subgoal 状态(running → done_by_agent)
285
353
  if (newStatus === "done_by_agent") {
286
354
  const goalMeta = readTaskGoalMeta(taskDir);
@@ -297,6 +365,84 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
297
365
  skip_reason: null,
298
366
  };
299
367
  }
368
+ function writeRecoveryArtifacts(taskDir, taskId, status, newStatus, actionTaken, recoveredAt) {
369
+ const timeoutSeconds = Number(status.timeout_seconds);
370
+ const summary = actionTaken === "marked_timed_out"
371
+ ? `Task exceeded its configured timeout of ${timeoutSeconds} seconds after the original runner stopped responding.`
372
+ : actionTaken === "marked_canceled"
373
+ ? "Cancellation converged after the original task runner stopped responding."
374
+ : `Task was reconciled to ${newStatus} after the original runner stopped responding.`;
375
+ const errorCode = actionTaken === "marked_timed_out"
376
+ ? "task_timeout"
377
+ : actionTaken === "marked_canceled"
378
+ ? "runner_lost_during_cancel"
379
+ : newStatus === "orphaned"
380
+ ? "agent_lost"
381
+ : newStatus === "failed_stale" && String(status.phase || status.status) === "collecting_artifacts"
382
+ ? "artifact_collection_interrupted"
383
+ : "agent_lost";
384
+ const requestedCommands = Array.isArray(status.verify_commands)
385
+ ? status.verify_commands.map(String)
386
+ : [];
387
+ const artifacts = {
388
+ "verify.json": {
389
+ status: "failed",
390
+ requested_commands: requestedCommands,
391
+ commands: [],
392
+ failure_reason: "runner_recovery",
393
+ error_code: errorCode,
394
+ },
395
+ "result.json": {
396
+ task_id: taskId,
397
+ status: newStatus,
398
+ agent: status.agent || "",
399
+ repo_path: status.repo_path || "",
400
+ resolved_repo_path: status.resolved_repo_path || "",
401
+ summary,
402
+ artifact_status: "partial",
403
+ changed_files: [],
404
+ diff_available: false,
405
+ verify_status: "failed",
406
+ failure_reason: "runner_recovery",
407
+ error_code: errorCode,
408
+ termination_reason: actionTaken === "marked_timed_out"
409
+ ? "timeout"
410
+ : actionTaken === "marked_canceled"
411
+ ? "canceled"
412
+ : "runner_stale",
413
+ recovered_at: recoveredAt,
414
+ warnings: ["The original runner stopped before complete change and verification evidence could be collected."],
415
+ },
416
+ };
417
+ for (const [name, value] of Object.entries(artifacts)) {
418
+ const path = join(taskDir, name);
419
+ if (!existsSync(path))
420
+ atomicWriteJsonFileSync(path, value);
421
+ }
422
+ const textArtifacts = {
423
+ "result.md": `# PatchWarden Task Result\n\n## Status\n${newStatus}\n\n## Summary\n${summary}\n\n## Artifact Status\npartial\n`,
424
+ "test.log": `(not run)\nExit code: not run\n${summary}\n`,
425
+ "git.diff": `(unavailable: ${summary})\n`,
426
+ "diff.patch": `(unavailable: ${summary})\n`,
427
+ };
428
+ for (const [name, content] of Object.entries(textArtifacts)) {
429
+ const path = join(taskDir, name);
430
+ if (!existsSync(path))
431
+ atomicWriteFileSync(path, content);
432
+ }
433
+ }
434
+ function taskDeadlineExceeded(status, nowMs = Date.now()) {
435
+ const timeoutSeconds = Number(status.timeout_seconds);
436
+ const startedAt = typeof status.started_at === "string"
437
+ ? Date.parse(status.started_at)
438
+ : typeof status.created_at === "string"
439
+ ? Date.parse(status.created_at)
440
+ : NaN;
441
+ return Number.isFinite(startedAt)
442
+ && Number.isFinite(timeoutSeconds)
443
+ && timeoutSeconds > 0
444
+ && nowMs >= startedAt + timeoutSeconds * 1000;
445
+ }
300
446
  // ── reconcile.log writer ───────────────────────────────────────────
301
447
  function writeReconcileLog(tasksDir, config, appliedReports) {
302
448
  // The reconcile.log lives at the .patchwarden/ root (parent of tasksDir),
@@ -8,6 +8,7 @@ import { recommendAgentForTask } from "../workspace/recommendAgentForTask.js";
8
8
  import { runDirectVerificationBundle } from "../direct/runDirectVerificationBundle.js";
9
9
  import { waitForTask } from "./waitForTask.js";
10
10
  import { safeAudit, safeAuditDirectSession, safeFinalizeDirectSession, safeResult, safeTestSummary } from "../diagnostics/safeViews.js";
11
+ import { isTerminalTaskStatus } from "./taskStates.js";
11
12
  import { createLineageId, writeTaskLineage, } from "./taskLineage.js";
12
13
  const DEFAULT_DEPS = {
13
14
  createTask,
@@ -26,15 +27,6 @@ const DEFAULT_DEPS = {
26
27
  now: () => new Date(),
27
28
  sleep,
28
29
  };
29
- const TERMINAL_STATUSES = new Set([
30
- "done",
31
- "done_by_agent",
32
- "failed",
33
- "failed_verification",
34
- "failed_scope_violation",
35
- "failed_policy_violation",
36
- "canceled",
37
- ]);
38
30
  export async function runTaskLoop(input) {
39
31
  return runTaskLoopWithDeps(input, DEFAULT_DEPS);
40
32
  }
@@ -186,7 +178,7 @@ async function waitUntilTerminal(taskId, timeoutSeconds, deps) {
186
178
  const deadline = Date.now() + timeoutSeconds * 1000;
187
179
  while (Date.now() < deadline) {
188
180
  const waited = await deps.waitForTask(taskId, 30);
189
- if (waited.terminal || TERMINAL_STATUSES.has(String(waited.status))) {
181
+ if (waited.terminal || isTerminalTaskStatus(String(waited.status))) {
190
182
  return { next_action: waited.next_action || "safe_audit" };
191
183
  }
192
184
  if (waited.next_tool_call?.name === "health_check" || waited.continuation_required === false) {
@@ -241,7 +233,7 @@ function isSuccessfulRound(round) {
241
233
  round.audit_verdict === "pass");
242
234
  }
243
235
  function isHardStop(round, resultValue, auditValue, stopOnHighRisk) {
244
- if (["failed_scope_violation", "failed_policy_violation", "canceled"].includes(round.status))
236
+ if (["failed_scope_violation", "failed_policy_violation", "canceled", "timeout"].includes(round.status))
245
237
  return true;
246
238
  if (!stopOnHighRisk)
247
239
  return false;
@@ -255,6 +247,8 @@ function hardStopReason(round, resultValue) {
255
247
  const result = asRecord(resultValue);
256
248
  if (round.status === "failed_scope_violation" || round.status === "failed_policy_violation")
257
249
  return "policy_blocked";
250
+ if (round.status === "timeout")
251
+ return "agent_timeout";
258
252
  if (String(result.failure_reason || "").toLowerCase().includes("timeout"))
259
253
  return "agent_timeout";
260
254
  return "high_risk_blocked";
@@ -14,7 +14,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
14
14
  lines?: number;
15
15
  redact?: boolean;
16
16
  }): {
17
- file: "test" | "stdout" | "stderr" | "verify";
17
+ file: "stdout" | "stderr" | "test" | "verify";
18
18
  lines: number;
19
19
  total_bytes: number;
20
20
  task_id: string;
@@ -37,7 +37,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
37
37
  truncated?: undefined;
38
38
  } | {
39
39
  task_id: string;
40
- file: "test" | "stdout" | "stderr" | "verify";
40
+ file: "stdout" | "stderr" | "test" | "verify";
41
41
  filename: string;
42
42
  content: string;
43
43
  available: boolean;
@@ -0,0 +1,6 @@
1
+ export declare const ACTIVE_TASK_STATUSES: Set<string>;
2
+ export declare const TERMINAL_TASK_STATUSES: Set<string>;
3
+ export declare function isTerminalTaskStatus(status: string): boolean;
4
+ export declare function isActiveTaskStatus(status: string): boolean;
5
+ /** Terminal states are immutable; active states may only advance or terminate. */
6
+ export declare function isAllowedTaskStatusTransition(from: string, to: string): boolean;
@@ -0,0 +1,52 @@
1
+ export const ACTIVE_TASK_STATUSES = new Set([
2
+ "pending",
3
+ "running",
4
+ "executing_agent",
5
+ "verifying",
6
+ "collecting_artifacts",
7
+ "cancel_requested",
8
+ ]);
9
+ export const TERMINAL_TASK_STATUSES = new Set([
10
+ "done",
11
+ "done_by_agent",
12
+ "accepted",
13
+ "rejected",
14
+ "needs_fix",
15
+ "blocked",
16
+ "failed",
17
+ "failed_verification",
18
+ "failed_scope_violation",
19
+ "failed_policy_violation",
20
+ "failed_stale",
21
+ "orphaned",
22
+ "timeout",
23
+ "canceled",
24
+ ]);
25
+ export function isTerminalTaskStatus(status) {
26
+ return TERMINAL_TASK_STATUSES.has(status);
27
+ }
28
+ export function isActiveTaskStatus(status) {
29
+ return ACTIVE_TASK_STATUSES.has(status);
30
+ }
31
+ /** Terminal states are immutable; active states may only advance or terminate. */
32
+ export function isAllowedTaskStatusTransition(from, to) {
33
+ if (from === to)
34
+ return true;
35
+ if (isTerminalTaskStatus(from))
36
+ return false;
37
+ if (!isActiveTaskStatus(from) || !isActiveTaskStatus(to) && !isTerminalTaskStatus(to))
38
+ return false;
39
+ if (from === "pending") {
40
+ return to === "running" || isTerminalTaskStatus(to);
41
+ }
42
+ if (to === "pending")
43
+ return false;
44
+ const forwardActive = {
45
+ running: new Set(["executing_agent", "verifying", "collecting_artifacts", "cancel_requested"]),
46
+ executing_agent: new Set(["verifying", "collecting_artifacts", "cancel_requested"]),
47
+ verifying: new Set(["collecting_artifacts", "cancel_requested"]),
48
+ collecting_artifacts: new Set(["cancel_requested"]),
49
+ cancel_requested: new Set(),
50
+ };
51
+ return isTerminalTaskStatus(to) || Boolean(forwardActive[from]?.has(to));
52
+ }
@@ -1,15 +1,7 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { getTaskStatus } from "./getTaskStatus.js";
3
3
  import { getTaskSummary } from "./getTaskSummary.js";
4
- const TERMINAL_STATUSES = new Set([
5
- "done",
6
- "done_by_agent",
7
- "failed",
8
- "failed_verification",
9
- "failed_scope_violation",
10
- "failed_policy_violation",
11
- "canceled",
12
- ]);
4
+ import { isTerminalTaskStatus } from "./taskStates.js";
13
5
  export async function waitForTask(taskId, waitSeconds = 25) {
14
6
  if (!Number.isInteger(waitSeconds) || waitSeconds < 1 || waitSeconds > 30) {
15
7
  throw new Error("wait_seconds must be an integer from 1 to 30.");
@@ -17,11 +9,11 @@ export async function waitForTask(taskId, waitSeconds = 25) {
17
9
  const started = Date.now();
18
10
  const deadline = started + waitSeconds * 1000;
19
11
  let status = getTaskStatus(taskId);
20
- while (!TERMINAL_STATUSES.has(status.status) && Date.now() < deadline) {
12
+ while (!isTerminalTaskStatus(status.status) && Date.now() < deadline) {
21
13
  await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
22
14
  status = getTaskStatus(taskId);
23
15
  }
24
- const terminal = TERMINAL_STATUSES.has(status.status);
16
+ const terminal = isTerminalTaskStatus(status.status);
25
17
  const executionBlocked = !terminal && status.execution_blocked;
26
18
  const elapsed = Math.round((Date.now() - started) / 1000);
27
19
  const progressSummary = {
@@ -10,8 +10,14 @@ export interface AgentAvailability {
10
10
  capabilities: {
11
11
  model_override: boolean;
12
12
  };
13
+ availability_scope: "executable_only";
14
+ provider_status: "not_checked";
15
+ invocation_ready?: boolean;
16
+ model_argument_present?: boolean;
13
17
  }
14
18
  export declare function listAgents(): {
15
19
  agents: AgentAvailability[];
16
20
  total: number;
21
+ config_path: string;
22
+ workspace_root: string;
17
23
  };
@@ -1,14 +1,20 @@
1
1
  import { existsSync, statSync } from "node:fs";
2
- import { basename, delimiter, extname, isAbsolute, join } from "node:path";
2
+ import { basename, delimiter, extname, isAbsolute, join, resolve } from "node:path";
3
3
  import { getConfig } from "../../config.js";
4
4
  import { sanitizeTrustedPath } from "../../runner/processSecurity.js";
5
5
  export function listAgents() {
6
6
  const config = getConfig();
7
7
  const checkedAt = new Date().toISOString();
8
+ const configPath = process.env.PATCHWARDEN_CONFIG
9
+ ? resolve(process.env.PATCHWARDEN_CONFIG)
10
+ : resolve(process.cwd(), "patchwarden.config.json");
8
11
  const agents = Object.entries(config.agents)
9
12
  .sort(([left], [right]) => left.localeCompare(right))
10
13
  .map(([name, agent]) => {
11
14
  const available = commandExists(agent.command, config.workspaceRoot);
15
+ const modelArgumentPresent = typeof agent.model === "string"
16
+ ? agent.args.some((arg, index) => arg === "--model" && agent.args[index + 1] === agent.model)
17
+ : true;
12
18
  return {
13
19
  name,
14
20
  configured: true,
@@ -19,9 +25,13 @@ export function listAgents() {
19
25
  adapter: typeof agent.adapter === "string" ? agent.adapter : ["codex", "opencode"].includes(name) ? name : null,
20
26
  model: typeof agent.model === "string" ? agent.model : null,
21
27
  capabilities: { model_override: typeof agent.adapter === "string" || ["codex", "opencode"].includes(name) },
28
+ availability_scope: "executable_only",
29
+ provider_status: "not_checked",
30
+ invocation_ready: available && modelArgumentPresent,
31
+ model_argument_present: modelArgumentPresent,
22
32
  };
23
33
  });
24
- return { agents, total: agents.length };
34
+ return { agents, total: agents.length, config_path: configPath, workspace_root: config.workspaceRoot };
25
35
  }
26
36
  function commandExists(command, workspaceRoot) {
27
37
  if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
@@ -1,5 +1,5 @@
1
1
  import { randomBytes } from "node:crypto";
2
- import { renameSync, rmSync, writeFileSync } from "node:fs";
2
+ import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
3
3
  const transientWindowsRenameErrors = new Set(["EACCES", "EBUSY", "EPERM"]);
4
4
  const renameRetryDeadlineMs = 1_000;
5
5
  const renameRetryDelayMs = 10;
@@ -13,6 +13,25 @@ export function atomicWriteFileSync(path, content, options = {}) {
13
13
  flag: "wx",
14
14
  ...(options.mode === undefined ? {} : { mode: options.mode }),
15
15
  });
16
+ const descriptor = openSync(temporaryPath, "r");
17
+ try {
18
+ try {
19
+ fsyncSync(descriptor);
20
+ }
21
+ catch (error) {
22
+ const code = error && typeof error === "object" && "code" in error
23
+ ? String(error.code || "")
24
+ : "";
25
+ // Some Windows filesystems and restricted runtimes reject fsync even
26
+ // after a successful complete write. Keep the same-directory atomic
27
+ // rename guarantee there; unexpected I/O errors still fail closed.
28
+ if (!new Set(["EPERM", "EINVAL", "ENOSYS", "ENOTSUP"]).has(code))
29
+ throw error;
30
+ }
31
+ }
32
+ finally {
33
+ closeSync(descriptor);
34
+ }
16
35
  replaceFileSync(temporaryPath, path);
17
36
  }
18
37
  catch (error) {
package/dist/version.d.ts CHANGED
@@ -1,3 +1,3 @@
1
- export declare const PATCHWARDEN_VERSION = "1.6.1";
1
+ export declare const PATCHWARDEN_VERSION = "1.6.2";
2
2
  export declare const TOOL_SCHEMA_EPOCH = "2026-07-19-v15";
3
3
  export declare const ARTIFACT_SCHEMA_VERSION = "2026-07-19-v15";
package/dist/version.js CHANGED
@@ -1,3 +1,3 @@
1
- export const PATCHWARDEN_VERSION = "1.6.1";
1
+ export const PATCHWARDEN_VERSION = "1.6.2";
2
2
  export const TOOL_SCHEMA_EPOCH = "2026-07-19-v15";
3
3
  export const ARTIFACT_SCHEMA_VERSION = TOOL_SCHEMA_EPOCH;
package/docs/CODE_WIKI.md CHANGED
@@ -1,7 +1,7 @@
1
1
  # PatchWarden Code Wiki
2
2
 
3
3
  > 本文档是对 PatchWarden 仓库的结构化代码导览,覆盖项目整体架构、主要模块职责、关键类与函数说明、依赖关系、运行方式,以及现有缺陷分析。
4
- > 源码版本:**v1.6.1** · Schema Epoch:`2026-07-19-v15` · 复核日期:2026-07-20 · License:MIT
4
+ > 源码版本:**v1.6.2** · Schema Epoch:`2026-07-19-v15` · 复核日期:2026-07-25 · License:MIT
5
5
 
6
6
  ## 目录
7
7
 
@@ -254,7 +254,7 @@ PatchWarden/
254
254
  │ └── release/ # 发布打包
255
255
  ├── docs/ # 文档
256
256
  ├── examples/ # 配置与 Tunnel 示例
257
- ├── package.json # v1.6.1,单一直接运行时依赖
257
+ ├── package.json # v1.6.2,单一直接运行时依赖
258
258
  ├── tsconfig.json
259
259
  └── PatchWarden.cmd # Windows 统一控制入口
260
260
  ```
@@ -278,7 +278,7 @@ PatchWarden/
278
278
  | --- | --- |
279
279
  | [src/config.ts](../src/config.ts) | 加载并校验 `patchwarden.config.json`,提供 `loadConfig`/`getConfig`/`getTasksDir`/`getPlansDir`/`resolveWorkspaceRoot`/`getRepoAllowedTestCommands`/`getRepoDirectAllowedCommands` 等路径解析;严格校验 `workspaceRoot`、`agents`、`allowedTestCommands`、`watcherStaleSeconds`、`toolProfile`、`tunnelProxy`、Direct 数值范围等字段;含 `normalizeRepoKey` 与 `comparablePath` 工具 |
280
280
  | [src/errors.ts](../src/errors.ts) | 定义 `PatchWardenError`(含 `reason`/`suggestion`/`blocked`/`details`)与 `errorPayload` 序列化 |
281
- | [src/version.ts](../src/version.ts) | 导出 `PATCHWARDEN_VERSION = "1.6.1"` 与 `TOOL_SCHEMA_EPOCH = "2026-07-19-v15"` |
281
+ | [src/version.ts](../src/version.ts) | 导出 `PATCHWARDEN_VERSION = "1.6.2"` 与 `TOOL_SCHEMA_EPOCH = "2026-07-19-v15"` |
282
282
  | [src/logging.ts](../src/logging.ts) | `Logger` 类输出 stderr JSON 日志,记录 `audit`/`info`/`warn`/`error`;`logToolInvocation` 仅写参数 digest,不写原参数,并通过跨进程锁有界追加到 5 MiB;`installGlobalHandlers` 捕获未处理异常但不吞错 |
283
283
 
284
284
  ### 4.3 安全模块(src/security/)
@@ -1555,7 +1555,7 @@ npm.cmd run desktop:package # 打包:先 stage 主仓 dist/,再 electron-b
1555
1555
  | 主包 `@types/node` | `^26.1.0` | `^18.19.0`(与 Node.js 18 最低运行时对齐) |
1556
1556
  | `chatgpt_direct` 工具数 | 15 | 14 |
1557
1557
 
1558
- > 注:根目录 `README.md` 仍写"v1.6.0",需在下一次 README 维护时同步到 v1.6.1。
1558
+ > 根目录中英文 README 的下载链接、安装器哈希和公开版本事实已同步到 v1.6.1。
1559
1559
 
1560
1560
  #### ✅ 11.7.2 v1.5.1/v1.6.0/v1.6.1 新功能导览 — 已修复
1561
1561