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
@@ -28,6 +28,8 @@ import { mutateTaskStatus } from "./taskStatusStore.js";
28
28
  import { logger } from "../logging.js";
29
29
  import { redactSensitiveContent } from "../security/contentRedaction.js";
30
30
  import { atomicWriteFileSync } from "../utils/atomicFile.js";
31
+ import { reconcileTasks } from "../tools/tasks/reconcileTasks.js";
32
+ import { buildAssessmentValidationFailureDiagnostic, recordAssessmentValidationFailure, } from "../assessments/assessmentDiagnostics.js";
31
33
  // Note: `runTask` is imported lazily inside `tick()` via `await import("./runTask.js")`.
32
34
  // This keeps `import { acquireWatcherLock }` side-effect-free for unit tests and
33
35
  // avoids eagerly pulling in the full tools/dispatch graph at module load.
@@ -47,6 +49,11 @@ const WATCHER_LAUNCHER_PID = Number.isInteger(Number(process.env.PATCHWARDEN_WAT
47
49
  // Track executed tasks to prevent re-execution
48
50
  const executedTasks = new Set();
49
51
  let consecutiveTickFailures = 0;
52
+ let activeTaskId = null;
53
+ let activeTaskPhase = null;
54
+ let tickRunning = false;
55
+ let tickStartedAt = null;
56
+ let lastTickCompletedAt = null;
50
57
  // ── Single-instance lock ─────────────────────────────────────────
51
58
  // watch.ts is a long-running poller; without a lock, two watchers started
52
59
  // against the same workspace would race on the same pending tasks. The lock
@@ -184,6 +191,8 @@ export function createNonOverlappingRunner(operation, onSkipped = () => undefine
184
191
  }
185
192
  // ── Main loop ─────────────────────────────────────────────────────
186
193
  async function tick() {
194
+ tickRunning = true;
195
+ tickStartedAt = new Date().toISOString();
187
196
  try {
188
197
  writeWatcherHeartbeat("running");
189
198
  // Ensure tasks directory exists
@@ -192,6 +201,7 @@ async function tick() {
192
201
  writeWatcherHeartbeat("running");
193
202
  return;
194
203
  }
204
+ reconcileInterruptedTasks();
195
205
  const entries = readdirSync(tasksDir, { withFileTypes: true });
196
206
  const taskDirs = entries.filter((e) => e.isDirectory());
197
207
  for (const entry of taskDirs) {
@@ -245,7 +255,9 @@ async function tick() {
245
255
  const preExecSnapshot = await captureRepoSnapshot(resolvedRepoPath);
246
256
  const validation = validateAssessmentFreshness(statusData.assessment_id, preExecSnapshot);
247
257
  if (!validation.valid) {
248
- throw new Error(`assessment validation failed: ${validation.failure_reason}`);
258
+ recordAssessmentValidationFailure(validation);
259
+ const diagnostic = buildAssessmentValidationFailureDiagnostic(validation);
260
+ throw new Error(`assessment validation failed: ${JSON.stringify(diagnostic)}`);
249
261
  }
250
262
  }
251
263
  }
@@ -276,14 +288,22 @@ async function tick() {
276
288
  // ── Execute ──
277
289
  logger.info(`[watcher] Executing: ${taskId}`);
278
290
  executedTasks.add(taskId);
291
+ activeTaskId = taskId;
292
+ activeTaskPhase = "executing_agent";
279
293
  try {
280
294
  const { runTask } = await import("./runTask.js");
281
295
  const result = await runTask(taskId);
296
+ activeTaskPhase = result.status;
282
297
  logger.info(`[watcher] ${taskId} → ${result.status}`);
283
298
  }
284
299
  catch (err) {
300
+ activeTaskPhase = "error";
285
301
  logger.error(`[watcher] ${taskId} → error: ${err instanceof Error ? err.message : String(err)}`);
286
302
  }
303
+ finally {
304
+ activeTaskId = null;
305
+ activeTaskPhase = null;
306
+ }
287
307
  }
288
308
  consecutiveTickFailures = 0;
289
309
  writeWatcherHeartbeat("running");
@@ -301,6 +321,26 @@ async function tick() {
301
321
  });
302
322
  }
303
323
  }
324
+ finally {
325
+ tickRunning = false;
326
+ lastTickCompletedAt = new Date().toISOString();
327
+ }
328
+ }
329
+ function reconcileInterruptedTasks() {
330
+ const result = reconcileTasks({
331
+ mode: "safe_fix",
332
+ max_age_minutes: Math.max(config.watcherStaleSeconds / 60, 1 / 60),
333
+ include_done_candidates: true,
334
+ }, config);
335
+ if (result.reconciled > 0) {
336
+ logger.warn("[watcher] Reconciled tasks left by a previous runner", {
337
+ reconciled: result.reconciled,
338
+ task_ids: result.reports
339
+ .filter((report) => report.action_taken !== "left_unchanged")
340
+ .map((report) => report.task_id)
341
+ .slice(0, 20),
342
+ });
343
+ }
304
344
  }
305
345
  function writeWatcherHeartbeat(status, lastError) {
306
346
  atomicWriteFileSync(WATCHER_HEARTBEAT_FILE, JSON.stringify({
@@ -312,8 +352,29 @@ function writeWatcherHeartbeat(status, lastError) {
312
352
  launcher_pid: WATCHER_LAUNCHER_PID,
313
353
  consecutive_failures: consecutiveTickFailures,
314
354
  last_error: lastError ? lastError.slice(0, 500) : null,
355
+ heartbeat_source: "independent_interval",
356
+ tick_running: tickRunning,
357
+ tick_started_at: tickStartedAt,
358
+ last_tick_completed_at: lastTickCompletedAt,
359
+ active_task_id: activeTaskId,
360
+ active_task_phase: activeTaskPhase,
315
361
  }, null, 2));
316
362
  }
363
+ function startWatcherHeartbeatLoop() {
364
+ const intervalMs = Math.min(2000, Math.max(1000, Math.floor(config.watcherStaleSeconds * 1000 / 3)));
365
+ const timer = setInterval(() => {
366
+ try {
367
+ writeWatcherHeartbeat("running");
368
+ }
369
+ catch (error) {
370
+ logger.warn("[watcher] Independent heartbeat write failed", {
371
+ error: error instanceof Error ? error.message : String(error),
372
+ });
373
+ }
374
+ }, intervalMs);
375
+ timer.unref?.();
376
+ return timer;
377
+ }
317
378
  // ── Start (only when executed as a script) ─────────────────────────
318
379
  // The bootstrap below has process-wide side effects (mkdir, signal handlers,
319
380
  // polling interval, lock acquisition). Guarding it with a main-module check
@@ -338,6 +399,7 @@ if (isMainModule) {
338
399
  throw err;
339
400
  }
340
401
  logger.info("[watcher] Started");
402
+ const heartbeatTimer = startWatcherHeartbeatLoop();
341
403
  const scheduledTick = createNonOverlappingRunner(tick, () => {
342
404
  logger.warn("[watcher] Previous tick is still running; skipping overlapping poll");
343
405
  });
@@ -347,6 +409,7 @@ if (isMainModule) {
347
409
  // Graceful shutdown
348
410
  function shutdown() {
349
411
  logger.info("[watcher] Stopped");
412
+ clearInterval(heartbeatTimer);
350
413
  try {
351
414
  releaseWatcherLock(WATCHER_LOCK_FILE);
352
415
  }
@@ -1269,7 +1269,7 @@ await test("K8. assessment not found rejected", async () => {
1269
1269
  agent: "codex",
1270
1270
  repo_path: ".",
1271
1271
  execution_mode: "execute",
1272
- assessment_id: "assessment_20260101_" + "0".repeat(32),
1272
+ assessment_id: "assessment_20260101_000000_" + "0".repeat(32),
1273
1273
  });
1274
1274
  throw new Error("Should have rejected unknown assessment");
1275
1275
  }
@@ -1509,8 +1509,8 @@ await test("K16. assessment_short_id cannot execute", async () => {
1509
1509
  throw new Error("Should have rejected short ID");
1510
1510
  }
1511
1511
  catch (e) {
1512
- if (!e.message?.includes("not found"))
1513
- throw new Error(`Expected not found for short ID, got: ${e.message}`);
1512
+ if (e.reason !== "assessment_id_invalid")
1513
+ throw new Error(`Expected assessment_id_invalid for short ID, got: ${e.reason || e.message}`);
1514
1514
  }
1515
1515
  });
1516
1516
  await test("K17. blocked assessment cannot execute", async () => {
@@ -44,7 +44,7 @@ export function getToolDefs() {
44
44
  },
45
45
  {
46
46
  name: "health_check",
47
- description: "Check MCP catalog consistency, watcher freshness/supervisor state, workspace readiness, and configured agents. Use detail=self_diagnostic for expanded read-only evidence.",
47
+ description: "Check MCP catalog consistency, watcher freshness/supervisor state, workspace readiness, and configured agents. Agent availability is executable-only unless an explicit provider probe is requested. Use detail=self_diagnostic for expanded read-only evidence.",
48
48
  inputSchema: {
49
49
  type: "object",
50
50
  properties: {
@@ -59,7 +59,7 @@ export function getToolDefs() {
59
59
  },
60
60
  {
61
61
  name: "list_agents",
62
- description: "List configured local agents and check whether each executable currently exists. This does not start an agent or contact its model provider.",
62
+ description: "List agents from the active runtime config, verify executable and model-argument wiring, and report the config path used by this process. provider_status remains not_checked because this read-only check does not contact a model provider or test account balance.",
63
63
  inputSchema: {
64
64
  type: "object",
65
65
  properties: {},
@@ -388,7 +388,7 @@ export function getToolDefs() {
388
388
  properties: {
389
389
  status: {
390
390
  type: "string",
391
- description: "Filter by status: pending, running, done, failed, failed_verification, failed_scope_violation, failed_policy_violation, canceled",
391
+ description: "Filter by status: pending, running, done, failed, failed_verification, failed_scope_violation, failed_policy_violation, timeout, canceled",
392
392
  },
393
393
  limit: {
394
394
  type: "number",
@@ -33,6 +33,7 @@ export interface AuditTaskOutput {
33
33
  recommended_next_actions: string[];
34
34
  }
35
35
  import type { ChangedFile } from "../../runner/changeCapture.js";
36
+ export declare function extractNpmRunScriptNames(content: string): string[];
36
37
  /**
37
38
  * Check 1: forbidden_scope_violation (fail level).
38
39
  * Returns null when no forbidden patterns are configured (skip).
@@ -73,6 +73,38 @@ function findMdFiles(dir, maxDepth = 3, maxFiles = MAX_AUDIT_DOC_FILES) {
73
73
  truncated ||= pending.length > 0 || results.length >= maxFiles || visitedDirectories >= maxDirectories;
74
74
  return { files: results, truncated };
75
75
  }
76
+ export function extractNpmRunScriptNames(content) {
77
+ const names = new Set();
78
+ const collectCommands = (text) => {
79
+ const pattern = /\bnpm(?:\.cmd)?\s+run\s+([a-zA-Z0-9:_-]+)/gi;
80
+ let match;
81
+ while ((match = pattern.exec(text)) !== null) {
82
+ if (match[1])
83
+ names.add(match[1]);
84
+ }
85
+ };
86
+ // Markdown code blocks are explicit command-bearing regions. Keep their
87
+ // content separate from prose so token boundaries cannot be concatenated.
88
+ const withoutFences = content.replace(/(?:```|~~~)[^\r\n]*\r?\n([\s\S]*?)(?:```|~~~)/g, (_whole, body) => {
89
+ collectCommands(body);
90
+ return "\n";
91
+ });
92
+ // Inline code is also explicit. Remove it from the prose pass afterward to
93
+ // avoid double-counting and accidental cross-node matches.
94
+ const withoutInlineCode = withoutFences.replace(/`([^`\r\n]+)`/g, (_whole, body) => {
95
+ collectCommands(body);
96
+ return " ";
97
+ });
98
+ // In ordinary text, accept only a standalone command line (optionally a
99
+ // list item, shell prompt, or Run:/Command: label). Narrative mentions such
100
+ // as "this guide discusses npm run deploy" are not executable evidence.
101
+ const explicitPlainCommand = /^\s*(?:(?:[-*+]\s+|\d+[.)]\s+|>\s*|\$\s*|PS>\s*)|(?:(?:run|command|命令)\s*[::]\s*))?npm(?:\.cmd)?\s+run\s+[a-zA-Z0-9:_-]+(?:\s+(?:--[^\r\n#]*|#[^\r\n]*))?\s*$/i;
102
+ for (const line of withoutInlineCode.split(/\r?\n/)) {
103
+ if (explicitPlainCommand.test(line))
104
+ collectCommands(line);
105
+ }
106
+ return [...names];
107
+ }
76
108
  function readAuditEvidence(path) {
77
109
  const half = Math.floor(MAX_AUDIT_EVIDENCE_BYTES / 2);
78
110
  const prefix = readTextFilePrefixSync(path, half);
@@ -466,7 +498,7 @@ export function auditTask(taskId) {
466
498
  };
467
499
  // ── 1. Task status ──
468
500
  const taskStatus = statusData.status || "unknown";
469
- const failedStatuses = new Set(["failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled"]);
501
+ const failedStatuses = new Set(["failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled", "timeout"]);
470
502
  const doneStatuses = new Set(["done", "done_by_agent"]);
471
503
  checks.push({
472
504
  name: "task_status",
@@ -677,12 +709,8 @@ export function auditTask(taskId) {
677
709
  continue;
678
710
  }
679
711
  // Extract npm run xxx
680
- const refs = content.match(/npm(?:\.cmd)?\s+run\s+([a-zA-Z0-9:_-]+)/gi) || [];
681
- for (const ref of refs) {
682
- const scriptName = ref.replace(/npm\s+run\s+/i, "").replace(/[^a-zA-Z0-9:_-]/g, "");
683
- if (scriptName)
684
- allNpmRunRefs.add(scriptName);
685
- }
712
+ for (const scriptName of extractNpmRunScriptNames(content))
713
+ allNpmRunRefs.add(scriptName);
686
714
  // Check release claims
687
715
  const claims = scanForReleaseClaims(content);
688
716
  for (const c of claims)
@@ -19,6 +19,7 @@ export declare function healthCheck(catalog?: ToolCatalogSnapshot, input?: Healt
19
19
  pending_reason: import("../../watcherStatus.js").PendingReason;
20
20
  error: string | null;
21
21
  }[];
22
+ last_assessment_validation_failure: import("../../assessments/assessmentDiagnostics.js").AssessmentValidationFailureDiagnostic | null;
22
23
  } | undefined;
23
24
  connector_visibility: {
24
25
  status: string;
@@ -65,10 +66,21 @@ export declare function healthCheck(catalog?: ToolCatalogSnapshot, input?: Healt
65
66
  tunnel: Record<string, unknown> & {
66
67
  last_error: string | null;
67
68
  };
69
+ config_path: string;
70
+ runtime_build_layout: string | null;
71
+ watcher_entry_path: string | null;
72
+ validator_module_path: string | null;
68
73
  agents: import("../workspace/listAgents.js").AgentAvailability[];
69
74
  agent_status: {
70
75
  [k: string]: string;
71
76
  };
77
+ agent_readiness: {
78
+ [k: string]: {
79
+ executable_available: boolean;
80
+ provider_status: "not_checked";
81
+ availability_scope: "executable_only";
82
+ };
83
+ };
72
84
  search_tool_count?: 5 | undefined;
73
85
  direct_tool_count?: number | undefined;
74
86
  status: string;
@@ -8,6 +8,7 @@ import { PATCHWARDEN_VERSION, TOOL_SCHEMA_EPOCH } from "../../version.js";
8
8
  import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES, CHATGPT_SEARCH_TOOL_NAMES, resolveToolProfile } from "../catalog/toolCatalog.js";
9
9
  import { readWatcherStatus } from "../../watcherStatus.js";
10
10
  import { listTasks } from "../tasks/listTasks.js";
11
+ import { readLastAssessmentValidationFailure } from "../../assessments/assessmentDiagnostics.js";
11
12
  const SERVER_STARTED_AT = Date.now();
12
13
  export function healthCheck(catalog, input = {}) {
13
14
  const config = getConfig();
@@ -120,8 +121,23 @@ export function healthCheck(catalog, input = {}) {
120
121
  workspace_root: workspace,
121
122
  tasks_dir: tasks,
122
123
  tunnel,
124
+ config_path: agents.config_path,
125
+ runtime_build_layout: typeof watcher.supervisor?.runtime_build_layout === "string"
126
+ ? watcher.supervisor.runtime_build_layout
127
+ : null,
128
+ watcher_entry_path: typeof watcher.supervisor?.watcher_entry_path === "string"
129
+ ? watcher.supervisor.watcher_entry_path
130
+ : null,
131
+ validator_module_path: typeof watcher.supervisor?.validator_module_path === "string"
132
+ ? watcher.supervisor.validator_module_path
133
+ : null,
123
134
  agents: agents.agents,
124
135
  agent_status: Object.fromEntries(agents.agents.map((agent) => [agent.name, agent.available ? "ok" : "missing"])),
136
+ agent_readiness: Object.fromEntries(agents.agents.map((agent) => [agent.name, {
137
+ executable_available: agent.available,
138
+ provider_status: agent.provider_status,
139
+ availability_scope: agent.availability_scope,
140
+ }])),
125
141
  ...(input.detail === "self_diagnostic" ? { self_diagnostic: buildSelfDiagnostic(config) } : {}),
126
142
  last_error: tunnel.last_error || (!watcher.available ? watcher.reason : null) || mismatchReport[0] || null,
127
143
  };
@@ -129,7 +145,7 @@ export function healthCheck(catalog, input = {}) {
129
145
  function buildSelfDiagnostic(config) {
130
146
  const recent = listTasks({ limit: 20 });
131
147
  const failures = recent.tasks
132
- .filter((task) => task.status.startsWith("failed") || task.status === "canceled")
148
+ .filter((task) => task.status.startsWith("failed") || task.status === "canceled" || task.status === "timeout")
133
149
  .slice(0, 10)
134
150
  .map((task) => ({
135
151
  task_id: task.task_id,
@@ -145,6 +161,7 @@ function buildSelfDiagnostic(config) {
145
161
  configured_agents: Object.keys(config.agents),
146
162
  recent_tasks_returned: recent.tasks.length,
147
163
  recent_failures: failures,
164
+ last_assessment_validation_failure: readLastAssessmentValidationFailure(),
148
165
  };
149
166
  const safe = redactSensitiveValue(diagnostic);
150
167
  return {
@@ -171,6 +188,11 @@ function readWatcherSupervisorStatus() {
171
188
  restart_attempts: Number.isInteger(Number(raw.restart_attempts)) ? Number(raw.restart_attempts) : 0,
172
189
  last_error: safeText(raw.last_error, 500) || null,
173
190
  checked_at: safeText(raw.checked_at, 80) || null,
191
+ project_root: safeText(raw.project_root, 500) || null,
192
+ config_path: safeText(raw.config_path, 500) || null,
193
+ watcher_entry_path: safeText(raw.watcher_entry_path, 500) || null,
194
+ validator_module_path: safeText(raw.validator_module_path, 500) || null,
195
+ runtime_build_layout: safeText(raw.runtime_build_layout, 80) || null,
174
196
  };
175
197
  }
176
198
  catch {
@@ -239,6 +261,12 @@ function readTunnelStatus() {
239
261
  : [],
240
262
  tool_manifest_sha256: safeText(raw.tool_manifest_sha256, 80) || null,
241
263
  core_tools_ready: Boolean(raw.core_tools_ready),
264
+ project_root: safeText(raw.project_root, 500) || null,
265
+ config_path: safeText(raw.config_path, 500) || null,
266
+ mcp_entry_path: safeText(raw.mcp_entry_path, 500) || null,
267
+ watcher_entry_path: safeText(raw.watcher_entry_path, 500) || null,
268
+ validator_module_path: safeText(raw.validator_module_path, 500) || null,
269
+ runtime_build_layout: safeText(raw.runtime_build_layout, 80) || null,
242
270
  };
243
271
  const redacted = redactSensitiveContent(JSON.stringify(allowed));
244
272
  return JSON.parse(redacted.content);
@@ -4,6 +4,7 @@ import { getTasksDir, getConfig } from "../../config.js";
4
4
  import { guardReadPath } from "../../security/pathGuard.js";
5
5
  import { writeTaskProgress } from "../../runner/taskProgress.js";
6
6
  import { mutateTaskStatus } from "../../runner/taskStatusStore.js";
7
+ import { isTerminalTaskStatus } from "./taskStates.js";
7
8
  export function cancelTask(taskId) {
8
9
  return requestTaskTermination(taskId, false);
9
10
  }
@@ -17,7 +18,7 @@ export function requestTaskTermination(taskId, force) {
17
18
  const now = new Date().toISOString();
18
19
  const outcome = mutateTaskStatus(statusFile, (current) => {
19
20
  const currentStatus = current.status;
20
- if (["done", "done_by_agent", "failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled"].includes(currentStatus)) {
21
+ if (isTerminalTaskStatus(currentStatus)) {
21
22
  return { result: {
22
23
  response: {
23
24
  task_id: taskId,
@@ -1,6 +1,6 @@
1
1
  import { type ChangePolicy, type TaskTemplateName } from "../taskTemplates.js";
2
2
  import { type PendingReason, type WatcherStatusSnapshot } from "../../watcherStatus.js";
3
- export type TaskStatus = "pending" | "running" | "collecting_artifacts" | "done_by_agent" | "accepted" | "rejected" | "needs_fix" | "blocked" | "done" | "failed" | "failed_verification" | "failed_scope_violation" | "failed_policy_violation" | "failed_stale" | "orphaned" | "canceled";
3
+ export type TaskStatus = "pending" | "running" | "executing_agent" | "verifying" | "collecting_artifacts" | "cancel_requested" | "done_by_agent" | "accepted" | "rejected" | "needs_fix" | "blocked" | "done" | "failed" | "failed_verification" | "failed_scope_violation" | "failed_policy_violation" | "failed_stale" | "orphaned" | "timeout" | "canceled";
4
4
  /**
5
5
  * v0.7.2 acceptance status — only meaningful for done_by_agent.
6
6
  * - "pending": done_by_agent reached but not yet audited/accepted
@@ -11,7 +11,7 @@ export type TaskStatus = "pending" | "running" | "collecting_artifacts" | "done_
11
11
  * - null: status has no acceptance semantics (running, failed, etc.)
12
12
  */
13
13
  export type AcceptanceStatus = "pending" | "accepted" | "rejected" | "needs_fix" | "blocked" | null;
14
- export type TaskPhase = "queued" | "preparing" | "executing_agent" | "running_tests" | "collecting_artifacts" | "canceling" | "terminating" | "completed" | "failed" | "failed_verification" | "failed_scope_violation" | "failed_policy_violation" | "failed_stale" | "orphaned" | "done_by_agent" | "accepted" | "rejected" | "needs_fix" | "blocked" | "canceled";
14
+ export type TaskPhase = "queued" | "preparing" | "executing_agent" | "running_tests" | "collecting_artifacts" | "canceling" | "terminating" | "completed" | "failed" | "failed_verification" | "failed_scope_violation" | "failed_policy_violation" | "failed_stale" | "orphaned" | "timeout" | "done_by_agent" | "accepted" | "rejected" | "needs_fix" | "blocked" | "canceled";
15
15
  export interface CreateTaskInput {
16
16
  plan_id?: string;
17
17
  inline_plan?: string;
@@ -7,14 +7,16 @@ import { guardTestCommand } from "../../security/commandGuard.js";
7
7
  import { guardRuntimeSelfModification } from "../../security/runtimeGuard.js";
8
8
  import { guardPlanContent } from "../../security/planGuard.js";
9
9
  import { assessRisk } from "../../security/riskEngine.js";
10
- import { createAssessment, readAssessment, validateAssessmentFreshness, generateAssessmentId, createAssessmentDir, } from "../../assessments/assessmentStore.js";
10
+ import { createAssessment, readAssessment, validateAssessmentFreshness, markAssessmentUsed, generateAssessmentId, createAssessmentDir, } from "../../assessments/assessmentStore.js";
11
11
  import { runAgentAssessment } from "../../assessments/agentAssessor.js";
12
+ import { recordAssessmentValidationFailure } from "../../assessments/assessmentDiagnostics.js";
13
+ import { ASSESSMENT_SECURITY_SNAPSHOT_VERSION } from "../../assessments/securitySnapshot.js";
12
14
  import { captureRepoSnapshot } from "../../runner/changeCapture.js";
13
15
  import { writeTaskProgress } from "../../runner/taskProgress.js";
14
16
  import { PatchWardenError } from "../../errors.js";
15
17
  import { savePlan } from "../goals/savePlan.js";
16
18
  import { expandTaskTemplate, TASK_TEMPLATE_NAMES, } from "../taskTemplates.js";
17
- import { PATCHWARDEN_VERSION, ARTIFACT_SCHEMA_VERSION } from "../../version.js";
19
+ import { PATCHWARDEN_VERSION, ARTIFACT_SCHEMA_VERSION, TOOL_SCHEMA_EPOCH } from "../../version.js";
18
20
  import { getLastToolCatalogSnapshot, resolveToolProfile } from "../catalog/toolCatalog.js";
19
21
  import { derivePendingReason, readWatcherStatus, } from "../../watcherStatus.js";
20
22
  import { routeAgent } from "../../agents/agentRouter.js";
@@ -30,6 +32,9 @@ export async function createTask(input) {
30
32
  let effectiveInput = input;
31
33
  if (executionMode === "execute" && input.assessment_id) {
32
34
  assessmentRecord = readAssessment(input.assessment_id);
35
+ if (assessmentRecord.used_at) {
36
+ throw new PatchWardenError("assessment_used", `Assessment "${input.assessment_id}" has already been used.`, "Run create_task with execution_mode=assess_only again to obtain a new assessment_id.", true, { assessment_id: input.assessment_id, used_at: assessmentRecord.used_at });
37
+ }
33
38
  // Parameter mismatch check: if ChatGPT passes params that differ from the assessment, reject
34
39
  effectiveInput = mergeAssessmentIntoInput(input, assessmentRecord);
35
40
  }
@@ -247,6 +252,10 @@ export async function createTask(input) {
247
252
  agent: effectiveInput.agent,
248
253
  timeout_seconds: timeoutSeconds,
249
254
  change_policy: changePolicy,
255
+ scope: effectiveInput.scope,
256
+ forbidden: effectiveInput.forbidden,
257
+ verification: effectiveInput.verification,
258
+ done_evidence: effectiveInput.done_evidence,
250
259
  snapshot,
251
260
  ...(preGeneratedAssessmentId ? { assessment_id: preGeneratedAssessmentId } : {}),
252
261
  ...(preGeneratedAssessmentDir ? { assessment_dir: preGeneratedAssessmentDir } : {}),
@@ -335,8 +344,36 @@ export async function createTask(input) {
335
344
  const snapshot = await captureRepoSnapshot(safeRepoPath);
336
345
  const validation = validateAssessmentFreshness(input.assessment_id, snapshot);
337
346
  if (!validation.valid) {
338
- throw new PatchWardenError(validation.failure_reason || "assessment_validation_failed", `Assessment "${input.assessment_id}" is no longer valid: ${validation.failure_reason}`, "Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.", true, { assessment_id: input.assessment_id, failure_reason: validation.failure_reason });
347
+ recordAssessmentValidationFailure(validation);
348
+ throw new PatchWardenError(validation.failure_reason || "assessment_validation_failed", `Assessment "${input.assessment_id}" is no longer valid: ${validation.failure_reason}`, "Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.", true, {
349
+ assessment_id: input.assessment_id,
350
+ reason_code: validation.failure_reason,
351
+ failure_reason: validation.failure_reason,
352
+ ...(validation.expected_hash ? { expected_hash: validation.expected_hash } : {}),
353
+ ...(validation.actual_hash ? { actual_hash: validation.actual_hash } : {}),
354
+ ...(validation.expected_hash ? { assessment_config_sha256: validation.expected_hash } : {}),
355
+ ...(validation.actual_hash ? { current_config_sha256: validation.actual_hash } : {}),
356
+ snapshot_version: validation.assessment?.assessment_security_snapshot_version || null,
357
+ assessment_created_at: validation.assessment?.created_at || null,
358
+ assessment_expires_at: validation.assessment?.expires_at || null,
359
+ config_changed: validation.failure_reason === "assessment_stale_config",
360
+ ...(validation.config_change_categories
361
+ ? {
362
+ config_change_categories: validation.config_change_categories,
363
+ changed_field_names: validation.config_change_categories,
364
+ changed_config_sections: validation.config_change_categories,
365
+ }
366
+ : {}),
367
+ assessment_fingerprint_version: validation.assessment?.assessment_security_snapshot_version || null,
368
+ current_fingerprint_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
369
+ assessment_schema_epoch: validation.assessment?.assessment_schema_epoch || null,
370
+ current_schema_epoch: TOOL_SCHEMA_EPOCH,
371
+ validator_module_path: "dist/assessments/assessmentStore.js",
372
+ validator_build_id: `patchwarden-${PATCHWARDEN_VERSION}:${TOOL_SCHEMA_EPOCH}:${ASSESSMENT_SECURITY_SNAPSHOT_VERSION}`,
373
+ watcher_instance_id: process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || null,
374
+ });
339
375
  }
376
+ markAssessmentUsed(input.assessment_id);
340
377
  }
341
378
  const { taskId, taskDir } = createTaskDirectory(tasksDir, config.workspaceRoot, config.tasksDir);
342
379
  const status = "pending";
@@ -435,26 +472,50 @@ function mergeAssessmentIntoInput(input, record) {
435
472
  // Do NOT set template/inline_plan alongside plan_id — createTask requires exactly one plan source.
436
473
  const merged = {
437
474
  ...input,
438
- plan_id: record.plan_id || input.plan_id,
475
+ plan_id: record.plan_id || undefined,
439
476
  template: undefined,
440
477
  inline_plan: undefined,
441
- goal: record.goal || input.goal,
478
+ goal: record.goal || undefined,
442
479
  agent: record.agent,
443
480
  repo_path: record.repo_path,
444
481
  test_command: record.test_command || undefined,
445
- verify_commands: record.verify_commands || input.verify_commands,
446
- timeout_seconds: record.timeout_seconds || input.timeout_seconds,
482
+ verify_commands: record.verify_commands || [],
483
+ timeout_seconds: record.timeout_seconds,
484
+ scope: record.scope || [],
485
+ forbidden: record.forbidden || [],
486
+ verification: record.verification || [],
487
+ done_evidence: record.done_evidence || [],
447
488
  };
448
489
  // Parameter mismatch check: if caller passed explicit params that differ from assessment
449
- if (input.template && record.template && input.template !== record.template) {
490
+ if (input.template && input.template !== record.template) {
450
491
  throw new PatchWardenError("assessment_parameter_mismatch", `template mismatch: caller passed "${input.template}" but assessment has "${record.template}".`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "template", assessment_value: record.template, caller_value: input.template });
451
492
  }
452
- if (input.goal && record.goal && input.goal !== record.goal) {
493
+ if (input.goal && input.goal !== record.goal) {
453
494
  throw new PatchWardenError("assessment_parameter_mismatch", `goal mismatch: caller passed a different goal than the assessment.`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "goal" });
454
495
  }
455
496
  if (input.repo_path && record.repo_path && input.repo_path !== record.repo_path) {
456
497
  throw new PatchWardenError("assessment_parameter_mismatch", `repo_path mismatch: caller passed "${input.repo_path}" but assessment has "${record.repo_path}".`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "repo_path", assessment_value: record.repo_path, caller_value: input.repo_path });
457
498
  }
499
+ if (input.agent && input.agent !== record.agent) {
500
+ throw new PatchWardenError("assessment_parameter_mismatch", `agent mismatch: caller passed "${input.agent}" but assessment has "${record.agent}".`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "agent" });
501
+ }
502
+ if (input.verify_commands
503
+ && JSON.stringify(input.verify_commands) !== JSON.stringify(record.verify_commands || [])) {
504
+ throw new PatchWardenError("assessment_parameter_mismatch", "verify_commands mismatch: caller passed commands different from the assessment.", "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "verify_commands" });
505
+ }
506
+ if (input.test_command && input.test_command !== record.test_command) {
507
+ throw new PatchWardenError("assessment_parameter_mismatch", "test_command mismatch: caller passed a command different from the assessment.", "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "test_command" });
508
+ }
509
+ if (input.timeout_seconds !== undefined && input.timeout_seconds !== record.timeout_seconds) {
510
+ throw new PatchWardenError("assessment_parameter_mismatch", "timeout_seconds mismatch: caller passed a timeout different from the assessment.", "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field: "timeout_seconds" });
511
+ }
512
+ for (const field of ["scope", "forbidden", "verification", "done_evidence"]) {
513
+ const callerValue = input[field];
514
+ const assessmentValue = record[field] || [];
515
+ if (callerValue && JSON.stringify(callerValue) !== JSON.stringify(assessmentValue)) {
516
+ throw new PatchWardenError("assessment_parameter_mismatch", `${field} mismatch: caller passed values different from the assessment.`, "Do not override assessment-locked parameters. Use the same assessment_id as-is.", true, { field });
517
+ }
518
+ }
458
519
  return merged;
459
520
  }
460
521
  function createTaskDirectory(tasksDir, workspaceRoot, configuredTasksDir) {
@@ -7,6 +7,7 @@ import { redactSensitiveContent } from "../../security/contentRedaction.js";
7
7
  import { readTextFileTailLinesSync } from "../../utils/boundedFile.js";
8
8
  import { readTaskRuntime } from "../../runner/taskRuntime.js";
9
9
  import { isWatcherOwningTask, readWatcherInstanceId } from "../../watcherStatus.js";
10
+ import { isActiveTaskStatus, isTerminalTaskStatus } from "./taskStates.js";
10
11
  // ── Thresholds (seconds) ──────────────────────────────────────────
11
12
  //
12
13
  // These are intentionally conservative. A task is only "stale" when multiple
@@ -81,16 +82,11 @@ export function diagnoseTask(input, config = getConfig()) {
81
82
  const statusStr = typeof status.status === "string" ? status.status : "unknown";
82
83
  const phaseStr = (runtime.phase || (typeof status.phase === "string" ? status.phase : "queued"));
83
84
  // ── Terminal tasks: no diagnosis needed ──
84
- const terminalStatuses = new Set([
85
- "done", "done_by_agent", "failed", "failed_verification",
86
- "failed_scope_violation", "failed_policy_violation",
87
- "failed_stale", "orphaned", "canceled",
88
- ]);
89
- if (terminalStatuses.has(statusStr)) {
85
+ if (isTerminalTaskStatus(statusStr)) {
90
86
  return buildTerminalDiagnosis(taskId, statusStr, phaseStr);
91
87
  }
92
- // ── Only diagnose running / collecting_artifacts / pending ──
93
- if (statusStr !== "running" && statusStr !== "collecting_artifacts" && statusStr !== "pending") {
88
+ // Diagnose both current status spellings and legacy phase-as-status records.
89
+ if (!isActiveTaskStatus(statusStr)) {
94
90
  return buildUnknownDiagnosis(taskId, statusStr, phaseStr, [`unexpected status "${statusStr}" for diagnosis`]);
95
91
  }
96
92
  // ── Gather evidence ──
@@ -5,6 +5,7 @@ import { guardReadPath } from "../../security/pathGuard.js";
5
5
  import { guardSensitivePath } from "../../security/sensitiveGuard.js";
6
6
  import { readTaskRuntime } from "../../runner/taskRuntime.js";
7
7
  import { derivePendingReason, readWatcherStatus, } from "../../watcherStatus.js";
8
+ import { isTerminalTaskStatus } from "./taskStates.js";
8
9
  export function getTaskStatus(taskId) {
9
10
  const config = getConfig();
10
11
  const tasksDir = getTasksDir(config);
@@ -18,7 +19,11 @@ export function getTaskStatus(taskId) {
18
19
  const raw = readFileSync(statusFile, "utf-8");
19
20
  const status = JSON.parse(raw);
20
21
  const runtime = readTaskRuntime(taskDir);
21
- const phase = runtime.phase || status.phase || "queued";
22
+ // A terminal status is authoritative. A stale runtime phase from a crashed
23
+ // Runner must never make a reconciled task look active again.
24
+ const phase = isTerminalTaskStatus(String(status.status))
25
+ ? status.phase || "queued"
26
+ : runtime.phase || status.phase || "queued";
22
27
  const watcher = readWatcherStatus(config);
23
28
  const pendingReason = derivePendingReason({ status: status.status, phase }, watcher);
24
29
  return {
@@ -5,15 +5,7 @@ import { guardReadPath } from "../../security/pathGuard.js";
5
5
  import { getTaskStatus } from "./getTaskStatus.js";
6
6
  import { redactSensitiveValue } from "../../security/contentRedaction.js";
7
7
  import { readTextFilePrefixSync, readTextFileTailLinesSync, } from "../../utils/boundedFile.js";
8
- const TERMINAL_STATUSES = new Set([
9
- "done",
10
- "done_by_agent",
11
- "failed",
12
- "failed_verification",
13
- "failed_scope_violation",
14
- "failed_policy_violation",
15
- "canceled",
16
- ]);
8
+ import { isTerminalTaskStatus } from "./taskStates.js";
17
9
  export function getTaskSummary(taskId, options = {}) {
18
10
  const config = getConfig();
19
11
  const taskDir = resolve(getTasksDir(config), taskId);
@@ -24,7 +16,7 @@ export function getTaskSummary(taskId, options = {}) {
24
16
  const verifyRead = tryReadJson(join(taskDir, "verify.json"));
25
17
  const result = resultRead.data;
26
18
  const verify = verifyRead.data;
27
- const terminal = TERMINAL_STATUSES.has(String(status.status));
19
+ const terminal = isTerminalTaskStatus(String(status.status));
28
20
  // Phase 4: Use new_out_of_scope_changes (task-caused) for acceptance status.
29
21
  // Pre-existing external dirty files that didn't change should NOT fail acceptance.
30
22
  const outOfScope = asArray(result.new_out_of_scope_changes
@@ -3,6 +3,7 @@ import { join } from "node:path";
3
3
  import { getTasksDir, getPlansDir, getConfig } from "../../config.js";
4
4
  import { readTaskRuntime } from "../../runner/taskRuntime.js";
5
5
  import { derivePendingReason, readWatcherStatus, } from "../../watcherStatus.js";
6
+ import { isActiveTaskStatus } from "./taskStates.js";
6
7
  export function listTasks(input) {
7
8
  const config = getConfig();
8
9
  const tasksDir = getTasksDir(config);
@@ -48,7 +49,7 @@ export function listTasks(input) {
48
49
  if (taskAcceptance !== filterAcceptance)
49
50
  continue;
50
51
  }
51
- if (input?.active_only && !["pending", "running", "collecting_artifacts"].includes(String(data.status)))
52
+ if (input?.active_only && !isActiveTaskStatus(String(data.status)))
52
53
  continue;
53
54
  const normalizedRepo = String(data.repo_path || ".").replace(/\\/g, "/");
54
55
  const normalizedResolvedRepo = String(data.resolved_repo_path || "").replace(/\\/g, "/");