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
@@ -11,8 +11,11 @@ import { getTasksDir, getConfig, type PatchWardenConfig } from "../../config.js"
11
11
  import { diagnoseTask, type DiagnosisType, type DiagnosisConfidence, type SafeAction } from "./diagnoseTask.js";
12
12
  import { syncSubgoalOnTaskDone, readTaskGoalMeta } from "../../goal/subgoalSync.js";
13
13
  import { mutateTaskStatus } from "../../runner/taskStatusStore.js";
14
- import { atomicWriteFileSync } from "../../utils/atomicFile.js";
14
+ import { writeTaskRuntime } from "../../runner/taskRuntime.js";
15
+ import type { TaskPhase } from "./createTask.js";
16
+ import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../../utils/atomicFile.js";
15
17
  import { appendBoundedTextFileSync } from "../../utils/boundedFile.js";
18
+ import { isActiveTaskStatus } from "./taskStates.js";
16
19
 
17
20
  // ── v0.7.0: reconcile_tasks types ──────────────────────────────────
18
21
 
@@ -22,6 +25,7 @@ export interface ReconcileTasksInput {
22
25
  max_age_minutes?: number;
23
26
  mode?: ReconcileMode;
24
27
  include_done_candidates?: boolean;
28
+ task_ids?: string[];
25
29
  }
26
30
 
27
31
  export interface ReconcileTaskReport {
@@ -33,7 +37,7 @@ export interface ReconcileTaskReport {
33
37
  reasons: string[];
34
38
  safe_actions: SafeAction[];
35
39
  age_seconds: number | null;
36
- action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent";
40
+ action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent" | "marked_canceled" | "marked_timed_out";
37
41
  previous_status: string | null;
38
42
  new_status: string | null;
39
43
  applied_at: string | null;
@@ -75,6 +79,9 @@ export function reconcileTasks(
75
79
  ? Math.min(input.max_age_minutes, 24 * 60)
76
80
  : DEFAULT_MAX_AGE_MINUTES;
77
81
  const includeDoneCandidates = input.include_done_candidates !== false; // default true
82
+ const requestedTaskIds = input.task_ids?.length
83
+ ? new Set(input.task_ids.filter((taskId) => /^task[-_][a-zA-Z0-9_-]+$/.test(taskId)))
84
+ : null;
78
85
  const maxAgeSeconds = maxAgeMinutes * 60;
79
86
 
80
87
  const tasksDir = getTasksDir(config);
@@ -118,8 +125,9 @@ export function reconcileTasks(
118
125
  const taskDirs = entries.filter((e) => e.isDirectory());
119
126
 
120
127
  for (const entry of taskDirs) {
121
- scanned += 1;
122
128
  const taskId = entry.name;
129
+ if (requestedTaskIds && !requestedTaskIds.has(taskId)) continue;
130
+ scanned += 1;
123
131
  const taskDir = resolve(tasksDir, taskId);
124
132
  const statusFile = join(taskDir, "status.json");
125
133
  if (!existsSync(statusFile)) continue;
@@ -133,18 +141,17 @@ export function reconcileTasks(
133
141
 
134
142
  const statusStr = typeof statusData.status === "string" ? statusData.status : "unknown";
135
143
 
136
- // Filter: only running / collecting_artifacts / (optionally) done_by_agent candidates
144
+ // Scan every persisted non-terminal lifecycle spelling, including legacy
145
+ // phase-as-status records left by older Watchers.
137
146
  const isCandidate =
138
- statusStr === "running" ||
139
- statusStr === "collecting_artifacts" ||
147
+ isActiveTaskStatus(statusStr) ||
140
148
  (includeDoneCandidates && statusStr === "done_by_agent");
141
149
  if (!isCandidate) continue;
142
150
 
143
- // Filter by age — use the oldest reliable timestamp on the task
151
+ // Use the oldest reliable timestamp for ordinary stale detection. Explicit
152
+ // cancel, persisted deadline, and Watcher-instance mismatch bypass this
153
+ // age gate so a restart cannot extend the task's total budget.
144
154
  const ageSeconds = taskAgeSeconds(taskDir, statusData, nowMs);
145
- if (ageSeconds !== null && ageSeconds < maxAgeSeconds) continue;
146
-
147
- candidates += 1;
148
155
 
149
156
  // Diagnose the task
150
157
  let diagnosis;
@@ -153,6 +160,14 @@ export function reconcileTasks(
153
160
  } catch {
154
161
  continue; // diagnosis failed — skip
155
162
  }
163
+ const cancellationRequested = statusData.cancel_requested === true || statusStr === "cancel_requested";
164
+ const deadlineExceeded = taskDeadlineExceeded(statusData, nowMs);
165
+ const urgentRecovery = cancellationRequested
166
+ || deadlineExceeded
167
+ || diagnosis.diagnosis === "orphaned_running";
168
+ if (ageSeconds !== null && ageSeconds < maxAgeSeconds && !urgentRecovery) continue;
169
+
170
+ candidates += 1;
156
171
 
157
172
  const report: ReconcileTaskReport = {
158
173
  task_id: taskId,
@@ -188,7 +203,9 @@ export function reconcileTasks(
188
203
  //
189
204
  // Anything else is left_unchanged and recorded for audit.
190
205
 
191
- if (mode === "safe_fix" && diagnosis.confidence === "high") {
206
+ const deterministicRecovery = !diagnosis.evidence.watcher_owns_task
207
+ && (cancellationRequested || deadlineExceeded);
208
+ if (mode === "safe_fix" && (diagnosis.confidence === "high" || deterministicRecovery)) {
192
209
  if (diagnosis.evidence.watcher_owns_task) {
193
210
  // Hard rule: do not touch tasks the live watcher still owns.
194
211
  skippedActiveWatcher += 1;
@@ -284,7 +301,14 @@ function applySafeFix(
284
301
  let newStatus: string | null = null;
285
302
  let actionTaken: ReconcileTaskReport["action_taken"] = "left_unchanged";
286
303
 
287
- switch (diagnosis) {
304
+ const cancellationRequested = currentStatus.cancel_requested === true || previousStatus === "cancel_requested";
305
+ if (!evidence.watcher_owns_task && cancellationRequested) {
306
+ newStatus = "canceled";
307
+ actionTaken = "marked_canceled";
308
+ } else if (!evidence.watcher_owns_task && taskDeadlineExceeded(currentStatus)) {
309
+ newStatus = "timeout";
310
+ actionTaken = "marked_timed_out";
311
+ } else switch (diagnosis) {
288
312
  case "stale_running":
289
313
  newStatus = "failed_stale";
290
314
  actionTaken = "marked_failed_stale";
@@ -350,8 +374,32 @@ function applySafeFix(
350
374
  current_watcher_instance_id: evidence.current_watcher_instance_id,
351
375
  },
352
376
  },
377
+ process_cleanup_required: !evidence.watcher_owns_task,
378
+ process_cleanup_reason: !evidence.watcher_owns_task
379
+ ? "The previous runner is no longer owned by this Watcher; child-process termination could not be confirmed and no untrusted PID was killed."
380
+ : null,
353
381
  };
354
382
 
383
+ if (newStatus === "canceled") {
384
+ next.canceled_at = appliedAt;
385
+ next.cancel_reason = "Cancellation converged after the original task runner stopped responding.";
386
+ next.termination_reason = "canceled";
387
+ next.error_code = "runner_lost_during_cancel";
388
+ } else if (actionTaken === "marked_timed_out") {
389
+ next.error = `Task exceeded its configured timeout of ${Number(current.timeout_seconds)} seconds after the original runner stopped responding.`;
390
+ next.termination_reason = "timeout";
391
+ next.error_code = "task_timeout";
392
+ } else if (diagnosis === "artifact_collection_stuck") {
393
+ next.error = "Artifact collection was interrupted and could not be safely resumed after the original runner stopped responding.";
394
+ next.error_code = "artifact_collection_interrupted";
395
+ } else if (diagnosis === "orphaned_running") {
396
+ next.error = "The original task runner was lost and its child process ownership could not be re-established.";
397
+ next.error_code = "agent_lost";
398
+ } else if (diagnosis === "stale_running") {
399
+ next.error = "The task runner heartbeat expired and no current Runner ownership could be proven.";
400
+ next.error_code = "agent_lost";
401
+ }
402
+
355
403
  if (newStatus === "done_by_agent") {
356
404
  next.acceptance_status = "pending";
357
405
  next.legacy_status = "done";
@@ -359,7 +407,7 @@ function applySafeFix(
359
407
 
360
408
  // The backup is created while the same lock protects the exact record
361
409
  // being replaced, so it cannot capture an unrelated newer state.
362
- atomicWriteFileSync(backupFile, readFileSync(statusFile, "utf-8"));
410
+ if (!existsSync(backupFile)) atomicWriteFileSync(backupFile, readFileSync(statusFile, "utf-8"));
363
411
  return {
364
412
  next,
365
413
  result: { applied: true as const },
@@ -386,6 +434,22 @@ function applySafeFix(
386
434
  };
387
435
  }
388
436
 
437
+ if (newStatus !== "done_by_agent") {
438
+ writeRecoveryArtifacts(taskDir, taskId, currentStatus, newStatus, actionTaken, appliedAt);
439
+ }
440
+
441
+ // Keep the persisted runtime view terminal as well. get_task_status merges
442
+ // runtime.json over status.json, so leaving an old executing phase here
443
+ // would make a successfully reconciled task appear active again.
444
+ writeTaskRuntime(taskDir, {
445
+ phase: newStatus as TaskPhase,
446
+ current_command: null,
447
+ last_heartbeat_at: appliedAt,
448
+ child_pid: undefined,
449
+ child_started_at: undefined,
450
+ child_owned_by_runner_instance_id: undefined,
451
+ });
452
+
389
453
  // v0.8.0: 当状态变为 done_by_agent 时,同步关联 subgoal 状态(running → done_by_agent)
390
454
  if (newStatus === "done_by_agent") {
391
455
  const goalMeta = readTaskGoalMeta(taskDir);
@@ -404,6 +468,91 @@ function applySafeFix(
404
468
  };
405
469
  }
406
470
 
471
+ function writeRecoveryArtifacts(
472
+ taskDir: string,
473
+ taskId: string,
474
+ status: Record<string, unknown>,
475
+ newStatus: string,
476
+ actionTaken: ReconcileTaskReport["action_taken"],
477
+ recoveredAt: string,
478
+ ): void {
479
+ const timeoutSeconds = Number(status.timeout_seconds);
480
+ const summary = actionTaken === "marked_timed_out"
481
+ ? `Task exceeded its configured timeout of ${timeoutSeconds} seconds after the original runner stopped responding.`
482
+ : actionTaken === "marked_canceled"
483
+ ? "Cancellation converged after the original task runner stopped responding."
484
+ : `Task was reconciled to ${newStatus} after the original runner stopped responding.`;
485
+ const errorCode = actionTaken === "marked_timed_out"
486
+ ? "task_timeout"
487
+ : actionTaken === "marked_canceled"
488
+ ? "runner_lost_during_cancel"
489
+ : newStatus === "orphaned"
490
+ ? "agent_lost"
491
+ : newStatus === "failed_stale" && String(status.phase || status.status) === "collecting_artifacts"
492
+ ? "artifact_collection_interrupted"
493
+ : "agent_lost";
494
+ const requestedCommands = Array.isArray(status.verify_commands)
495
+ ? status.verify_commands.map(String)
496
+ : [];
497
+ const artifacts = {
498
+ "verify.json": {
499
+ status: "failed",
500
+ requested_commands: requestedCommands,
501
+ commands: [],
502
+ failure_reason: "runner_recovery",
503
+ error_code: errorCode,
504
+ },
505
+ "result.json": {
506
+ task_id: taskId,
507
+ status: newStatus,
508
+ agent: status.agent || "",
509
+ repo_path: status.repo_path || "",
510
+ resolved_repo_path: status.resolved_repo_path || "",
511
+ summary,
512
+ artifact_status: "partial",
513
+ changed_files: [],
514
+ diff_available: false,
515
+ verify_status: "failed",
516
+ failure_reason: "runner_recovery",
517
+ error_code: errorCode,
518
+ termination_reason: actionTaken === "marked_timed_out"
519
+ ? "timeout"
520
+ : actionTaken === "marked_canceled"
521
+ ? "canceled"
522
+ : "runner_stale",
523
+ recovered_at: recoveredAt,
524
+ warnings: ["The original runner stopped before complete change and verification evidence could be collected."],
525
+ },
526
+ };
527
+ for (const [name, value] of Object.entries(artifacts)) {
528
+ const path = join(taskDir, name);
529
+ if (!existsSync(path)) atomicWriteJsonFileSync(path, value);
530
+ }
531
+ const textArtifacts: Record<string, string> = {
532
+ "result.md": `# PatchWarden Task Result\n\n## Status\n${newStatus}\n\n## Summary\n${summary}\n\n## Artifact Status\npartial\n`,
533
+ "test.log": `(not run)\nExit code: not run\n${summary}\n`,
534
+ "git.diff": `(unavailable: ${summary})\n`,
535
+ "diff.patch": `(unavailable: ${summary})\n`,
536
+ };
537
+ for (const [name, content] of Object.entries(textArtifacts)) {
538
+ const path = join(taskDir, name);
539
+ if (!existsSync(path)) atomicWriteFileSync(path, content);
540
+ }
541
+ }
542
+
543
+ function taskDeadlineExceeded(status: Record<string, unknown>, nowMs = Date.now()): boolean {
544
+ const timeoutSeconds = Number(status.timeout_seconds);
545
+ const startedAt = typeof status.started_at === "string"
546
+ ? Date.parse(status.started_at)
547
+ : typeof status.created_at === "string"
548
+ ? Date.parse(status.created_at)
549
+ : NaN;
550
+ return Number.isFinite(startedAt)
551
+ && Number.isFinite(timeoutSeconds)
552
+ && timeoutSeconds > 0
553
+ && nowMs >= startedAt + timeoutSeconds * 1000;
554
+ }
555
+
407
556
  // ── reconcile.log writer ───────────────────────────────────────────
408
557
 
409
558
  function writeReconcileLog(
@@ -9,6 +9,7 @@ import { runDirectVerificationBundle } from "../direct/runDirectVerificationBund
9
9
  import { waitForTask } from "./waitForTask.js";
10
10
  import { safeAudit, safeAuditDirectSession, safeFinalizeDirectSession, safeResult, safeTestSummary } from "../diagnostics/safeViews.js";
11
11
  import type { TaskTemplateName } from "../taskTemplates.js";
12
+ import { isTerminalTaskStatus } from "./taskStates.js";
12
13
  import {
13
14
  createLineageId,
14
15
  writeTaskLineage,
@@ -86,16 +87,6 @@ const DEFAULT_DEPS: RunTaskLoopDeps = {
86
87
  sleep,
87
88
  };
88
89
 
89
- const TERMINAL_STATUSES = new Set([
90
- "done",
91
- "done_by_agent",
92
- "failed",
93
- "failed_verification",
94
- "failed_scope_violation",
95
- "failed_policy_violation",
96
- "canceled",
97
- ]);
98
-
99
90
  export async function runTaskLoop(input: RunTaskLoopInput): Promise<RunTaskLoopOutput> {
100
91
  return runTaskLoopWithDeps(input, DEFAULT_DEPS);
101
92
  }
@@ -273,7 +264,7 @@ async function waitUntilTerminal(
273
264
  const deadline = Date.now() + timeoutSeconds * 1000;
274
265
  while (Date.now() < deadline) {
275
266
  const waited = await deps.waitForTask(taskId, 30);
276
- if (waited.terminal || TERMINAL_STATUSES.has(String(waited.status))) {
267
+ if (waited.terminal || isTerminalTaskStatus(String(waited.status))) {
277
268
  return { next_action: waited.next_action || "safe_audit" };
278
269
  }
279
270
  if (waited.next_tool_call?.name === "health_check" || waited.continuation_required === false) {
@@ -340,7 +331,7 @@ function isSuccessfulRound(round: TaskLineageRound): boolean {
340
331
  }
341
332
 
342
333
  function isHardStop(round: TaskLineageRound, resultValue: unknown, auditValue: unknown, stopOnHighRisk: boolean): boolean {
343
- if (["failed_scope_violation", "failed_policy_violation", "canceled"].includes(round.status)) return true;
334
+ if (["failed_scope_violation", "failed_policy_violation", "canceled", "timeout"].includes(round.status)) return true;
344
335
  if (!stopOnHighRisk) return false;
345
336
  const checkNames = [...round.fail_checks, ...round.warn_checks].join(" ").toLowerCase();
346
337
  const result = asRecord(resultValue);
@@ -352,6 +343,7 @@ function isHardStop(round: TaskLineageRound, resultValue: unknown, auditValue: u
352
343
  function hardStopReason(round: TaskLineageRound, resultValue: unknown): TaskLoopStopReason {
353
344
  const result = asRecord(resultValue);
354
345
  if (round.status === "failed_scope_violation" || round.status === "failed_policy_violation") return "policy_blocked";
346
+ if (round.status === "timeout") return "agent_timeout";
355
347
  if (String(result.failure_reason || "").toLowerCase().includes("timeout")) return "agent_timeout";
356
348
  return "high_risk_blocked";
357
349
  }
@@ -0,0 +1,54 @@
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
+
10
+ export const TERMINAL_TASK_STATUSES = new Set([
11
+ "done",
12
+ "done_by_agent",
13
+ "accepted",
14
+ "rejected",
15
+ "needs_fix",
16
+ "blocked",
17
+ "failed",
18
+ "failed_verification",
19
+ "failed_scope_violation",
20
+ "failed_policy_violation",
21
+ "failed_stale",
22
+ "orphaned",
23
+ "timeout",
24
+ "canceled",
25
+ ]);
26
+
27
+ export function isTerminalTaskStatus(status: string): boolean {
28
+ return TERMINAL_TASK_STATUSES.has(status);
29
+ }
30
+
31
+ export function isActiveTaskStatus(status: string): boolean {
32
+ return ACTIVE_TASK_STATUSES.has(status);
33
+ }
34
+
35
+ /** Terminal states are immutable; active states may only advance or terminate. */
36
+ export function isAllowedTaskStatusTransition(from: string, to: string): boolean {
37
+ if (from === to) return true;
38
+ if (isTerminalTaskStatus(from)) return false;
39
+ if (!isActiveTaskStatus(from) || !isActiveTaskStatus(to) && !isTerminalTaskStatus(to)) return false;
40
+
41
+ if (from === "pending") {
42
+ return to === "running" || isTerminalTaskStatus(to);
43
+ }
44
+ if (to === "pending") return false;
45
+
46
+ const forwardActive: Record<string, Set<string>> = {
47
+ running: new Set(["executing_agent", "verifying", "collecting_artifacts", "cancel_requested"]),
48
+ executing_agent: new Set(["verifying", "collecting_artifacts", "cancel_requested"]),
49
+ verifying: new Set(["collecting_artifacts", "cancel_requested"]),
50
+ collecting_artifacts: new Set(["cancel_requested"]),
51
+ cancel_requested: new Set(),
52
+ };
53
+ return isTerminalTaskStatus(to) || Boolean(forwardActive[from]?.has(to));
54
+ }
@@ -1,16 +1,7 @@
1
1
  import { setTimeout as sleep } from "node:timers/promises";
2
2
  import { getTaskStatus } from "./getTaskStatus.js";
3
3
  import { getTaskSummary, type TaskSummaryResult } from "./getTaskSummary.js";
4
-
5
- const TERMINAL_STATUSES = new Set([
6
- "done",
7
- "done_by_agent",
8
- "failed",
9
- "failed_verification",
10
- "failed_scope_violation",
11
- "failed_policy_violation",
12
- "canceled",
13
- ]);
4
+ import { isTerminalTaskStatus } from "./taskStates.js";
14
5
 
15
6
  export interface WaitForTaskProgressSummary {
16
7
  phase: string;
@@ -45,12 +36,12 @@ export async function waitForTask(taskId: string, waitSeconds = 25): Promise<Wai
45
36
  const deadline = started + waitSeconds * 1000;
46
37
  let status = getTaskStatus(taskId);
47
38
 
48
- while (!TERMINAL_STATUSES.has(status.status) && Date.now() < deadline) {
39
+ while (!isTerminalTaskStatus(status.status) && Date.now() < deadline) {
49
40
  await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
50
41
  status = getTaskStatus(taskId);
51
42
  }
52
43
 
53
- const terminal = TERMINAL_STATUSES.has(status.status);
44
+ const terminal = isTerminalTaskStatus(status.status);
54
45
  const executionBlocked = !terminal && status.execution_blocked;
55
46
  const elapsed = Math.round((Date.now() - started) / 1000);
56
47
 
@@ -1,5 +1,5 @@
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
 
@@ -13,15 +13,25 @@ export interface AgentAvailability {
13
13
  adapter: string | null;
14
14
  model: string | null;
15
15
  capabilities: { model_override: boolean };
16
+ availability_scope: "executable_only";
17
+ provider_status: "not_checked";
18
+ invocation_ready?: boolean;
19
+ model_argument_present?: boolean;
16
20
  }
17
21
 
18
- export function listAgents(): { agents: AgentAvailability[]; total: number } {
22
+ export function listAgents(): { agents: AgentAvailability[]; total: number; config_path: string; workspace_root: string } {
19
23
  const config = getConfig();
20
24
  const checkedAt = new Date().toISOString();
25
+ const configPath = process.env.PATCHWARDEN_CONFIG
26
+ ? resolve(process.env.PATCHWARDEN_CONFIG)
27
+ : resolve(process.cwd(), "patchwarden.config.json");
21
28
  const agents = Object.entries(config.agents)
22
29
  .sort(([left], [right]) => left.localeCompare(right))
23
30
  .map(([name, agent]) => {
24
31
  const available = commandExists(agent.command, config.workspaceRoot);
32
+ const modelArgumentPresent = typeof agent.model === "string"
33
+ ? agent.args.some((arg, index) => arg === "--model" && agent.args[index + 1] === agent.model)
34
+ : true;
25
35
  return {
26
36
  name,
27
37
  configured: true as const,
@@ -32,9 +42,13 @@ export function listAgents(): { agents: AgentAvailability[]; total: number } {
32
42
  adapter: typeof agent.adapter === "string" ? agent.adapter : ["codex", "opencode"].includes(name) ? name : null,
33
43
  model: typeof agent.model === "string" ? agent.model : null,
34
44
  capabilities: { model_override: typeof agent.adapter === "string" || ["codex", "opencode"].includes(name) },
45
+ availability_scope: "executable_only" as const,
46
+ provider_status: "not_checked" as const,
47
+ invocation_ready: available && modelArgumentPresent,
48
+ model_argument_present: modelArgumentPresent,
35
49
  };
36
50
  });
37
- return { agents, total: agents.length };
51
+ return { agents, total: agents.length, config_path: configPath, workspace_root: config.workspaceRoot };
38
52
  }
39
53
 
40
54
  function commandExists(command: string, workspaceRoot: string): boolean {
@@ -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
 
4
4
  export interface AtomicWriteOptions {
5
5
  encoding?: BufferEncoding;
@@ -24,6 +24,22 @@ export function atomicWriteFileSync(
24
24
  flag: "wx",
25
25
  ...(options.mode === undefined ? {} : { mode: options.mode }),
26
26
  });
27
+ const descriptor = openSync(temporaryPath, "r");
28
+ try {
29
+ try {
30
+ fsyncSync(descriptor);
31
+ } catch (error) {
32
+ const code = error && typeof error === "object" && "code" in error
33
+ ? String((error as NodeJS.ErrnoException).code || "")
34
+ : "";
35
+ // Some Windows filesystems and restricted runtimes reject fsync even
36
+ // after a successful complete write. Keep the same-directory atomic
37
+ // rename guarantee there; unexpected I/O errors still fail closed.
38
+ if (!new Set(["EPERM", "EINVAL", "ENOSYS", "ENOTSUP"]).has(code)) throw error;
39
+ }
40
+ } finally {
41
+ closeSync(descriptor);
42
+ }
27
43
  replaceFileSync(temporaryPath, path);
28
44
  } catch (error) {
29
45
  try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
package/src/version.ts 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;
@@ -1132,6 +1132,7 @@ html.dark {
1132
1132
  const svc = (status && status[prefix]) || {};
1133
1133
  const tunnel = (status && status.tunnel && status.tunnel[prefix]) || {};
1134
1134
  const tools = (status && status.tools && status.tools[prefix]) || {};
1135
+ const connection = (status && status.connections && status.connections[prefix]) || {};
1135
1136
 
1136
1137
  const available = !!svc.available;
1137
1138
  const kind = available ? 'success' : 'error';
@@ -1140,6 +1141,9 @@ html.dark {
1140
1141
  const reasonText = svc.reason ? escapeHtml(svc.reason) : '—';
1141
1142
 
1142
1143
  const rows = [
1144
+ { label: 'profile', html: connection.profile ? escapeHtml(connection.profile) : '&mdash;' },
1145
+ { label: 'tunnel_id', html: connection.tunnel_id_masked ? escapeHtml(connection.tunnel_id_masked) : '&mdash;' },
1146
+ { label: 'reconnect', html: connection.reconnect_guidance ? escapeHtml(connection.reconnect_guidance) : '&mdash;' },
1143
1147
  { label: 'PID', html: tunnel.pid != null ? escapeHtml(tunnel.pid) : '—' },
1144
1148
  { label: 'tool_profile', html: tools.tool_profile ? escapeHtml(tools.tool_profile) : '—' },
1145
1149
  { label: 'tool_count', html: tools.tool_count != null ? escapeHtml(tools.tool_count) : '—' },
@@ -97,6 +97,35 @@ html.dark {
97
97
  <script src="/vendor/tailwindcss-browser.js"></script>
98
98
  <script src="/vendor/lucide.js"></script>
99
99
  <script src="/log-parser.js"></script>
100
+ <script>
101
+ // Keep the log page usable when an older packaged Control Center omits the
102
+ // parser asset. The server serves the canonical parser; this is only a
103
+ // bounded display fallback and never changes log contents.
104
+ if (!window.PatchWardenLogParser || typeof window.PatchWardenLogParser.parseLine !== 'function') {
105
+ window.PatchWardenLogParser = {
106
+ parseLine: function (rawLine, stream) {
107
+ var line = String(rawLine || '');
108
+ var source = String(stream || 'log');
109
+ var entry = null;
110
+ try {
111
+ var parsed = JSON.parse(line);
112
+ if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) entry = parsed;
113
+ } catch (_) {}
114
+ var level = entry ? String(entry.level || entry.severity || '').toLowerCase() : '';
115
+ if (level === 'warning') level = 'warn';
116
+ return {
117
+ raw: line,
118
+ time: entry ? String(entry.time || entry.timestamp || '') : '',
119
+ level: level,
120
+ component: entry ? String(entry.component || entry.scope || entry.source || source) : source,
121
+ summary: entry ? String(entry.summary || entry.msg || entry.message || line) : line,
122
+ detail: entry ? JSON.stringify(entry, null, 2) : line,
123
+ structured: entry !== null
124
+ };
125
+ }
126
+ };
127
+ }
128
+ </script>
100
129
  <style>
101
130
  .no-scrollbar::-webkit-scrollbar { display: none; }
102
131
  .no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
@@ -45,7 +45,7 @@
45
45
  <section>
46
46
  <h2 data-i18n="settings.mcpTunnel"></h2>
47
47
  <div class="setting-row">
48
- <div class="min-w-0"><strong>tunnel-client.exe</strong><p id="tunnelClientPath" class="mono break-path" data-i18n="settings.tunnelUndetected"></p></div>
48
+ <div class="min-w-0"><strong>tunnel-client.exe</strong><p id="tunnelClientPath" class="mono break-path"></p></div>
49
49
  <div class="inline-actions"><button id="detectTunnel"><i data-lucide="scan-search"></i><span data-i18n="settings.detect"></span></button><button id="chooseTunnel"><i data-lucide="file-search"></i><span data-i18n="settings.choose"></span></button></div>
50
50
  </div>
51
51
  <div class="setting-row">
@@ -82,8 +82,8 @@
82
82
  </section>
83
83
  <section>
84
84
  <h2 data-i18n="settings.workspaceDiagnostics"></h2>
85
- <div class="setting-row"><div class="min-w-0"><strong data-i18n="settings.currentConfig"></strong><p id="configPath" class="mono truncate" data-i18n="settings.loading"></p></div><button id="openConfig" data-i18n-title="settings.openConfig"><i data-lucide="external-link"></i></button></div>
86
- <div class="setting-row"><div><strong data-i18n="settings.workspace"></strong><p id="workspacePath" class="mono" data-i18n="settings.workspaceHelp"></p></div><button id="changeWorkspace" data-i18n="settings.change"></button></div>
85
+ <div class="setting-row"><div class="min-w-0"><strong data-i18n="settings.currentConfig"></strong><p id="configPath" class="mono truncate"></p></div><button id="openConfig" data-i18n-title="settings.openConfig"><i data-lucide="external-link"></i></button></div>
86
+ <div class="setting-row"><div><strong data-i18n="settings.workspace"></strong><p id="workspacePath" class="mono"></p></div><button id="changeWorkspace" data-i18n="settings.change"></button></div>
87
87
  <div class="setting-actions"><button id="openLogs"><i data-lucide="folder-open"></i><span data-i18n="settings.openLogs"></span></button><button id="runDoctor"><i data-lucide="stethoscope"></i><span data-i18n="settings.runDoctor"></span></button></div>
88
88
  <pre id="doctorOutput" class="hidden"></pre>
89
89
  </section>
package/ui/settings.js CHANGED
@@ -108,28 +108,37 @@
108
108
  syncProxyControls();
109
109
  }
110
110
 
111
- api.getState().then(function (state) {
112
- document.getElementById("configPath").textContent = state.configPath || tr("settings.notConfigured");
113
- theme.value = state.preferences.theme;
114
- language.value = state.preferences.language || "system";
115
- closeBehavior.value = state.preferences.closeBehavior;
116
- if (state.runtimeSettings) renderRuntime(state.runtimeSettings);
117
- });
118
- api.getRuntimeSettings().then(function (settings) {
119
- renderRuntime(settings);
120
- if (!settings.tunnelClientPath) {
121
- setRuntimeMessage("settings.autoDetecting");
122
- return api.detectTunnelClient().then(function (result) {
123
- if (result.available) {
124
- selectedTunnelPath = result.path;
125
- tunnelClientPath.textContent = tr("settings.autoDetectedPath", { path: result.path, source: result.source });
111
+ async function initializeSettings() {
112
+ try {
113
+ var state = await api.getState();
114
+ var settings = state.runtimeSettings || await api.getRuntimeSettings();
115
+ renderRuntime(settings);
116
+ document.getElementById("configPath").textContent = state.configPath || tr("settings.notConfigured");
117
+ document.getElementById("workspacePath").textContent = state.workspaceRoot || tr("settings.workspaceHelp");
118
+ theme.value = state.preferences.theme;
119
+ language.value = state.preferences.language || "system";
120
+ closeBehavior.value = state.preferences.closeBehavior;
121
+
122
+ if (!settings.tunnelClientPath && state.tunnelClient && state.tunnelClient.available) {
123
+ selectedTunnelPath = state.tunnelClient.path;
124
+ tunnelClientPath.textContent = tr("settings.autoDetectedPath", { path: state.tunnelClient.path, source: state.tunnelClient.source });
125
+ setRuntimeMessage("settings.autoDetected");
126
+ } else if (!settings.tunnelClientPath) {
127
+ setRuntimeMessage("settings.autoDetecting");
128
+ var detected = await api.detectTunnelClient();
129
+ if (detected.available) {
130
+ selectedTunnelPath = detected.path;
131
+ tunnelClientPath.textContent = tr("settings.autoDetectedPath", { path: detected.path, source: detected.source });
126
132
  setRuntimeMessage("settings.autoDetected");
127
133
  } else {
128
134
  setRuntimeMessage("settings.tunnelNotFound");
129
135
  }
130
- });
136
+ }
137
+ } catch (error) {
138
+ runtimeStatus.textContent = error && error.message ? error.message : tr("settings.loadFailed");
131
139
  }
132
- }).catch(function (error) { runtimeStatus.textContent = error.message; });
140
+ }
141
+ void initializeSettings();
133
142
  void loadAgents(false);
134
143
  document.getElementById("detectAgents").addEventListener("click", function () { void loadAgents(true); });
135
144
  document.getElementById("saveAgents").addEventListener("click", async function () {