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
@@ -38,6 +38,11 @@ import { mutateTaskStatus } from "./taskStatusStore.js";
38
38
  import { logger } from "../logging.js";
39
39
  import { redactSensitiveContent } from "../security/contentRedaction.js";
40
40
  import { atomicWriteFileSync } from "../utils/atomicFile.js";
41
+ import { reconcileTasks } from "../tools/tasks/reconcileTasks.js";
42
+ import {
43
+ buildAssessmentValidationFailureDiagnostic,
44
+ recordAssessmentValidationFailure,
45
+ } from "../assessments/assessmentDiagnostics.js";
41
46
  // Note: `runTask` is imported lazily inside `tick()` via `await import("./runTask.js")`.
42
47
  // This keeps `import { acquireWatcherLock }` side-effect-free for unit tests and
43
48
  // avoids eagerly pulling in the full tools/dispatch graph at module load.
@@ -60,6 +65,11 @@ const WATCHER_LAUNCHER_PID = Number.isInteger(Number(process.env.PATCHWARDEN_WAT
60
65
  // Track executed tasks to prevent re-execution
61
66
  const executedTasks = new Set<string>();
62
67
  let consecutiveTickFailures = 0;
68
+ let activeTaskId: string | null = null;
69
+ let activeTaskPhase: string | null = null;
70
+ let tickRunning = false;
71
+ let tickStartedAt: string | null = null;
72
+ let lastTickCompletedAt: string | null = null;
63
73
 
64
74
  // ── Single-instance lock ─────────────────────────────────────────
65
75
  // watch.ts is a long-running poller; without a lock, two watchers started
@@ -212,6 +222,8 @@ export function createNonOverlappingRunner(
212
222
  // ── Main loop ─────────────────────────────────────────────────────
213
223
 
214
224
  async function tick() {
225
+ tickRunning = true;
226
+ tickStartedAt = new Date().toISOString();
215
227
  try {
216
228
  writeWatcherHeartbeat("running");
217
229
  // Ensure tasks directory exists
@@ -221,6 +233,8 @@ async function tick() {
221
233
  return;
222
234
  }
223
235
 
236
+ reconcileInterruptedTasks();
237
+
224
238
  const entries = readdirSync(tasksDir, { withFileTypes: true });
225
239
  const taskDirs = entries.filter((e) => e.isDirectory());
226
240
 
@@ -280,7 +294,9 @@ async function tick() {
280
294
  const preExecSnapshot = await captureRepoSnapshot(resolvedRepoPath);
281
295
  const validation = validateAssessmentFreshness(statusData.assessment_id, preExecSnapshot);
282
296
  if (!validation.valid) {
283
- throw new Error(`assessment validation failed: ${validation.failure_reason}`);
297
+ recordAssessmentValidationFailure(validation);
298
+ const diagnostic = buildAssessmentValidationFailureDiagnostic(validation);
299
+ throw new Error(`assessment validation failed: ${JSON.stringify(diagnostic)}`);
284
300
  }
285
301
  }
286
302
  } catch (err) {
@@ -312,13 +328,20 @@ async function tick() {
312
328
  // ── Execute ──
313
329
  logger.info(`[watcher] Executing: ${taskId}`);
314
330
  executedTasks.add(taskId);
331
+ activeTaskId = taskId;
332
+ activeTaskPhase = "executing_agent";
315
333
 
316
334
  try {
317
335
  const { runTask } = await import("./runTask.js");
318
336
  const result = await runTask(taskId);
337
+ activeTaskPhase = result.status;
319
338
  logger.info(`[watcher] ${taskId} → ${result.status}`);
320
339
  } catch (err) {
340
+ activeTaskPhase = "error";
321
341
  logger.error(`[watcher] ${taskId} → error: ${err instanceof Error ? err.message : String(err)}`);
342
+ } finally {
343
+ activeTaskId = null;
344
+ activeTaskPhase = null;
322
345
  }
323
346
  }
324
347
  consecutiveTickFailures = 0;
@@ -334,6 +357,26 @@ async function tick() {
334
357
  error: heartbeatError instanceof Error ? heartbeatError.message : String(heartbeatError),
335
358
  });
336
359
  }
360
+ } finally {
361
+ tickRunning = false;
362
+ lastTickCompletedAt = new Date().toISOString();
363
+ }
364
+ }
365
+
366
+ function reconcileInterruptedTasks(): void {
367
+ const result = reconcileTasks({
368
+ mode: "safe_fix",
369
+ max_age_minutes: Math.max(config.watcherStaleSeconds / 60, 1 / 60),
370
+ include_done_candidates: true,
371
+ }, config);
372
+ if (result.reconciled > 0) {
373
+ logger.warn("[watcher] Reconciled tasks left by a previous runner", {
374
+ reconciled: result.reconciled,
375
+ task_ids: result.reports
376
+ .filter((report) => report.action_taken !== "left_unchanged")
377
+ .map((report) => report.task_id)
378
+ .slice(0, 20),
379
+ });
337
380
  }
338
381
  }
339
382
 
@@ -347,9 +390,30 @@ function writeWatcherHeartbeat(status: "running" | "degraded", lastError?: strin
347
390
  launcher_pid: WATCHER_LAUNCHER_PID,
348
391
  consecutive_failures: consecutiveTickFailures,
349
392
  last_error: lastError ? lastError.slice(0, 500) : null,
393
+ heartbeat_source: "independent_interval",
394
+ tick_running: tickRunning,
395
+ tick_started_at: tickStartedAt,
396
+ last_tick_completed_at: lastTickCompletedAt,
397
+ active_task_id: activeTaskId,
398
+ active_task_phase: activeTaskPhase,
350
399
  }, null, 2));
351
400
  }
352
401
 
402
+ function startWatcherHeartbeatLoop(): ReturnType<typeof setInterval> {
403
+ const intervalMs = Math.min(2000, Math.max(1000, Math.floor(config.watcherStaleSeconds * 1000 / 3)));
404
+ const timer = setInterval(() => {
405
+ try {
406
+ writeWatcherHeartbeat("running");
407
+ } catch (error) {
408
+ logger.warn("[watcher] Independent heartbeat write failed", {
409
+ error: error instanceof Error ? error.message : String(error),
410
+ });
411
+ }
412
+ }, intervalMs);
413
+ timer.unref?.();
414
+ return timer;
415
+ }
416
+
353
417
  // ── Start (only when executed as a script) ─────────────────────────
354
418
  // The bootstrap below has process-wide side effects (mkdir, signal handlers,
355
419
  // polling interval, lock acquisition). Guarding it with a main-module check
@@ -378,6 +442,7 @@ if (isMainModule) {
378
442
  }
379
443
 
380
444
  logger.info("[watcher] Started");
445
+ const heartbeatTimer = startWatcherHeartbeatLoop();
381
446
  const scheduledTick = createNonOverlappingRunner(tick, () => {
382
447
  logger.warn("[watcher] Previous tick is still running; skipping overlapping poll");
383
448
  });
@@ -389,6 +454,7 @@ if (isMainModule) {
389
454
  // Graceful shutdown
390
455
  function shutdown(): void {
391
456
  logger.info("[watcher] Stopped");
457
+ clearInterval(heartbeatTimer);
392
458
  try { releaseWatcherLock(WATCHER_LOCK_FILE); } catch (error) {
393
459
  logger.warn("[watcher] Failed to release watcher lock", {
394
460
  error: error instanceof Error ? error.message : String(error),
package/src/smoke-test.ts CHANGED
@@ -1338,7 +1338,7 @@ await test("K8. assessment not found rejected", async () => {
1338
1338
  agent: "codex",
1339
1339
  repo_path: ".",
1340
1340
  execution_mode: "execute",
1341
- assessment_id: "assessment_20260101_" + "0".repeat(32),
1341
+ assessment_id: "assessment_20260101_000000_" + "0".repeat(32),
1342
1342
  });
1343
1343
  throw new Error("Should have rejected unknown assessment");
1344
1344
  } catch (e: any) {
@@ -1569,7 +1569,7 @@ await test("K16. assessment_short_id cannot execute", async () => {
1569
1569
  });
1570
1570
  throw new Error("Should have rejected short ID");
1571
1571
  } catch (e: any) {
1572
- if (!e.message?.includes("not found")) throw new Error(`Expected not found for short ID, got: ${e.message}`);
1572
+ if (e.reason !== "assessment_id_invalid") throw new Error(`Expected assessment_id_invalid for short ID, got: ${e.reason || e.message}`);
1573
1573
  }
1574
1574
  });
1575
1575
 
@@ -62,7 +62,7 @@ export function getToolDefs(): ToolDef[] {
62
62
  {
63
63
  name: "health_check",
64
64
  description:
65
- "Check MCP catalog consistency, watcher freshness/supervisor state, workspace readiness, and configured agents. Use detail=self_diagnostic for expanded read-only evidence.",
65
+ "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.",
66
66
  inputSchema: {
67
67
  type: "object",
68
68
  properties: {
@@ -78,7 +78,7 @@ export function getToolDefs(): ToolDef[] {
78
78
  {
79
79
  name: "list_agents",
80
80
  description:
81
- "List configured local agents and check whether each executable currently exists. This does not start an agent or contact its model provider.",
81
+ "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.",
82
82
  inputSchema: {
83
83
  type: "object",
84
84
  properties: {},
@@ -416,7 +416,7 @@ export function getToolDefs(): ToolDef[] {
416
416
  properties: {
417
417
  status: {
418
418
  type: "string",
419
- description: "Filter by status: pending, running, done, failed, failed_verification, failed_scope_violation, failed_policy_violation, canceled",
419
+ description: "Filter by status: pending, running, done, failed, failed_verification, failed_scope_violation, failed_policy_violation, timeout, canceled",
420
420
  },
421
421
  limit: {
422
422
  type: "number",
@@ -140,6 +140,40 @@ function findMdFiles(
140
140
  return { files: results, truncated };
141
141
  }
142
142
 
143
+ export function extractNpmRunScriptNames(content: string): string[] {
144
+ const names = new Set<string>();
145
+ const collectCommands = (text: string) => {
146
+ const pattern = /\bnpm(?:\.cmd)?\s+run\s+([a-zA-Z0-9:_-]+)/gi;
147
+ let match: RegExpExecArray | null;
148
+ while ((match = pattern.exec(text)) !== null) {
149
+ if (match[1]) names.add(match[1]);
150
+ }
151
+ };
152
+
153
+ // Markdown code blocks are explicit command-bearing regions. Keep their
154
+ // content separate from prose so token boundaries cannot be concatenated.
155
+ const withoutFences = content.replace(/(?:```|~~~)[^\r\n]*\r?\n([\s\S]*?)(?:```|~~~)/g, (_whole, body: string) => {
156
+ collectCommands(body);
157
+ return "\n";
158
+ });
159
+
160
+ // Inline code is also explicit. Remove it from the prose pass afterward to
161
+ // avoid double-counting and accidental cross-node matches.
162
+ const withoutInlineCode = withoutFences.replace(/`([^`\r\n]+)`/g, (_whole, body: string) => {
163
+ collectCommands(body);
164
+ return " ";
165
+ });
166
+
167
+ // In ordinary text, accept only a standalone command line (optionally a
168
+ // list item, shell prompt, or Run:/Command: label). Narrative mentions such
169
+ // as "this guide discusses npm run deploy" are not executable evidence.
170
+ 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;
171
+ for (const line of withoutInlineCode.split(/\r?\n/)) {
172
+ if (explicitPlainCommand.test(line)) collectCommands(line);
173
+ }
174
+ return [...names];
175
+ }
176
+
143
177
  function readAuditEvidence(path: string): { content: string; truncated: boolean } {
144
178
  const half = Math.floor(MAX_AUDIT_EVIDENCE_BYTES / 2);
145
179
  const prefix = readTextFilePrefixSync(path, half);
@@ -515,7 +549,7 @@ export function auditTask(taskId: string): AuditTaskOutput {
515
549
  };
516
550
  // ── 1. Task status ──
517
551
  const taskStatus = statusData.status || "unknown";
518
- const failedStatuses = new Set(["failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled"]);
552
+ const failedStatuses = new Set(["failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled", "timeout"]);
519
553
  const doneStatuses = new Set(["done", "done_by_agent"]);
520
554
  checks.push({
521
555
  name: "task_status",
@@ -729,12 +763,8 @@ export function auditTask(taskId: string): AuditTaskOutput {
729
763
  continue;
730
764
  }
731
765
  // Extract npm run xxx
732
- const refs = content.match(/npm(?:\.cmd)?\s+run\s+([a-zA-Z0-9:_-]+)/gi) || [];
733
- for (const ref of refs) {
734
- const scriptName = ref.replace(/npm\s+run\s+/i, "").replace(/[^a-zA-Z0-9:_-]/g, "");
735
- if (scriptName)
736
- allNpmRunRefs.add(scriptName);
737
- }
766
+ for (const scriptName of extractNpmRunScriptNames(content))
767
+ allNpmRunRefs.add(scriptName);
738
768
  // Check release claims
739
769
  const claims = scanForReleaseClaims(content);
740
770
  for (const c of claims)
@@ -9,6 +9,7 @@ import type { ToolCatalogSnapshot } from "../catalog/toolCatalog.js";
9
9
  import { CHATGPT_CORE_TOOL_NAMES, CHATGPT_DIRECT_TOOL_NAMES, CHATGPT_SEARCH_TOOL_NAMES, resolveToolProfile } from "../catalog/toolCatalog.js";
10
10
  import { readWatcherStatus } from "../../watcherStatus.js";
11
11
  import { listTasks } from "../tasks/listTasks.js";
12
+ import { readLastAssessmentValidationFailure } from "../../assessments/assessmentDiagnostics.js";
12
13
 
13
14
  const SERVER_STARTED_AT = Date.now();
14
15
 
@@ -130,8 +131,23 @@ export function healthCheck(catalog?: ToolCatalogSnapshot, input: HealthCheckInp
130
131
  workspace_root: workspace,
131
132
  tasks_dir: tasks,
132
133
  tunnel,
134
+ config_path: agents.config_path,
135
+ runtime_build_layout: typeof watcher.supervisor?.runtime_build_layout === "string"
136
+ ? watcher.supervisor.runtime_build_layout
137
+ : null,
138
+ watcher_entry_path: typeof watcher.supervisor?.watcher_entry_path === "string"
139
+ ? watcher.supervisor.watcher_entry_path
140
+ : null,
141
+ validator_module_path: typeof watcher.supervisor?.validator_module_path === "string"
142
+ ? watcher.supervisor.validator_module_path
143
+ : null,
133
144
  agents: agents.agents,
134
145
  agent_status: Object.fromEntries(agents.agents.map((agent) => [agent.name, agent.available ? "ok" : "missing"])),
146
+ agent_readiness: Object.fromEntries(agents.agents.map((agent) => [agent.name, {
147
+ executable_available: agent.available,
148
+ provider_status: agent.provider_status,
149
+ availability_scope: agent.availability_scope,
150
+ }])),
135
151
  ...(input.detail === "self_diagnostic" ? { self_diagnostic: buildSelfDiagnostic(config) } : {}),
136
152
  last_error: tunnel.last_error || (!watcher.available ? watcher.reason : null) || mismatchReport[0] || null,
137
153
  };
@@ -140,7 +156,7 @@ export function healthCheck(catalog?: ToolCatalogSnapshot, input: HealthCheckInp
140
156
  function buildSelfDiagnostic(config: ReturnType<typeof getConfig>) {
141
157
  const recent = listTasks({ limit: 20 });
142
158
  const failures = recent.tasks
143
- .filter((task) => task.status.startsWith("failed") || task.status === "canceled")
159
+ .filter((task) => task.status.startsWith("failed") || task.status === "canceled" || task.status === "timeout")
144
160
  .slice(0, 10)
145
161
  .map((task) => ({
146
162
  task_id: task.task_id,
@@ -156,6 +172,7 @@ function buildSelfDiagnostic(config: ReturnType<typeof getConfig>) {
156
172
  configured_agents: Object.keys(config.agents),
157
173
  recent_tasks_returned: recent.tasks.length,
158
174
  recent_failures: failures,
175
+ last_assessment_validation_failure: readLastAssessmentValidationFailure(),
159
176
  };
160
177
  const safe = redactSensitiveValue(diagnostic);
161
178
  return {
@@ -182,6 +199,11 @@ function readWatcherSupervisorStatus(): Record<string, unknown> {
182
199
  restart_attempts: Number.isInteger(Number(raw.restart_attempts)) ? Number(raw.restart_attempts) : 0,
183
200
  last_error: safeText(raw.last_error, 500) || null,
184
201
  checked_at: safeText(raw.checked_at, 80) || null,
202
+ project_root: safeText(raw.project_root, 500) || null,
203
+ config_path: safeText(raw.config_path, 500) || null,
204
+ watcher_entry_path: safeText(raw.watcher_entry_path, 500) || null,
205
+ validator_module_path: safeText(raw.validator_module_path, 500) || null,
206
+ runtime_build_layout: safeText(raw.runtime_build_layout, 80) || null,
185
207
  };
186
208
  } catch {
187
209
  return { observed: true, managed: false, status: "invalid_status_file" };
@@ -242,6 +264,12 @@ function readTunnelStatus(): Record<string, unknown> & { last_error: string | nu
242
264
  : [],
243
265
  tool_manifest_sha256: safeText(raw.tool_manifest_sha256, 80) || null,
244
266
  core_tools_ready: Boolean(raw.core_tools_ready),
267
+ project_root: safeText(raw.project_root, 500) || null,
268
+ config_path: safeText(raw.config_path, 500) || null,
269
+ mcp_entry_path: safeText(raw.mcp_entry_path, 500) || null,
270
+ watcher_entry_path: safeText(raw.watcher_entry_path, 500) || null,
271
+ validator_module_path: safeText(raw.validator_module_path, 500) || null,
272
+ runtime_build_layout: safeText(raw.runtime_build_layout, 80) || null,
245
273
  };
246
274
  const redacted = redactSensitiveContent(JSON.stringify(allowed));
247
275
  return JSON.parse(redacted.content);
@@ -5,6 +5,7 @@ import { guardReadPath } from "../../security/pathGuard.js";
5
5
  import { writeTaskProgress } from "../../runner/taskProgress.js";
6
6
  import { mutateTaskStatus } from "../../runner/taskStatusStore.js";
7
7
  import type { TaskStatus } from "./createTask.js";
8
+ import { isTerminalTaskStatus } from "./taskStates.js";
8
9
 
9
10
  export interface TaskTerminationResponse {
10
11
  task_id: string;
@@ -38,7 +39,7 @@ export function requestTaskTermination(taskId: string, force: boolean) {
38
39
  const now = new Date().toISOString();
39
40
  const outcome = mutateTaskStatus<TaskTerminationOutcome>(statusFile, (current) => {
40
41
  const currentStatus = current.status as TaskStatus;
41
- if (["done", "done_by_agent", "failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled"].includes(currentStatus)) {
42
+ if (isTerminalTaskStatus(currentStatus)) {
42
43
  return { result: {
43
44
  response: {
44
45
  task_id: taskId,
@@ -12,12 +12,15 @@ import {
12
12
  createAssessment,
13
13
  readAssessment,
14
14
  validateAssessmentFreshness,
15
+ markAssessmentUsed,
15
16
  generateAssessmentId,
16
17
  createAssessmentDir,
17
18
  type AssessmentRecord,
18
19
  type AgentAssessmentSummary,
19
20
  } from "../../assessments/assessmentStore.js";
20
21
  import { runAgentAssessment } from "../../assessments/agentAssessor.js";
22
+ import { recordAssessmentValidationFailure } from "../../assessments/assessmentDiagnostics.js";
23
+ import { ASSESSMENT_SECURITY_SNAPSHOT_VERSION } from "../../assessments/securitySnapshot.js";
21
24
  import { captureRepoSnapshot } from "../../runner/changeCapture.js";
22
25
  import { writeTaskProgress } from "../../runner/taskProgress.js";
23
26
  import { PatchWardenError } from "../../errors.js";
@@ -28,7 +31,7 @@ import {
28
31
  type ChangePolicy,
29
32
  type TaskTemplateName,
30
33
  } from "../taskTemplates.js";
31
- import { PATCHWARDEN_VERSION, ARTIFACT_SCHEMA_VERSION } from "../../version.js";
34
+ import { PATCHWARDEN_VERSION, ARTIFACT_SCHEMA_VERSION, TOOL_SCHEMA_EPOCH } from "../../version.js";
32
35
  import { getLastToolCatalogSnapshot, resolveToolProfile } from "../catalog/toolCatalog.js";
33
36
  import {
34
37
  derivePendingReason,
@@ -43,7 +46,10 @@ import { atomicWriteJsonFileSync } from "../../utils/atomicFile.js";
43
46
  export type TaskStatus =
44
47
  | "pending"
45
48
  | "running"
49
+ | "executing_agent"
50
+ | "verifying"
46
51
  | "collecting_artifacts"
52
+ | "cancel_requested"
47
53
  | "done_by_agent" // v0.7.0: agent self-reported done or status reconciled to done; acceptance_status defaults to "pending"
48
54
  | "accepted" // v0.7.2: audit_task confirmed acceptance
49
55
  | "rejected" // v0.7.2: audit_task confirmed rejection
@@ -56,6 +62,7 @@ export type TaskStatus =
56
62
  | "failed_policy_violation"
57
63
  | "failed_stale" // v0.7.0: process dead / heartbeat expired
58
64
  | "orphaned" // v0.7.0: watcher no longer owns the task
65
+ | "timeout"
59
66
  | "canceled";
60
67
 
61
68
  /**
@@ -83,6 +90,7 @@ export type TaskPhase =
83
90
  | "failed_policy_violation"
84
91
  | "failed_stale" // v0.7.0
85
92
  | "orphaned" // v0.7.0
93
+ | "timeout"
86
94
  | "done_by_agent" // v0.7.0
87
95
  | "accepted" // v0.7.2
88
96
  | "rejected" // v0.7.2
@@ -205,6 +213,15 @@ export async function createTask(input: CreateTaskInput): Promise<CreateTaskResu
205
213
  let effectiveInput = input;
206
214
  if (executionMode === "execute" && input.assessment_id) {
207
215
  assessmentRecord = readAssessment(input.assessment_id);
216
+ if (assessmentRecord.used_at) {
217
+ throw new PatchWardenError(
218
+ "assessment_used",
219
+ `Assessment "${input.assessment_id}" has already been used.`,
220
+ "Run create_task with execution_mode=assess_only again to obtain a new assessment_id.",
221
+ true,
222
+ { assessment_id: input.assessment_id, used_at: assessmentRecord.used_at },
223
+ );
224
+ }
208
225
  // Parameter mismatch check: if ChatGPT passes params that differ from the assessment, reject
209
226
  effectiveInput = mergeAssessmentIntoInput(input, assessmentRecord);
210
227
  }
@@ -490,6 +507,10 @@ export async function createTask(input: CreateTaskInput): Promise<CreateTaskResu
490
507
  agent: effectiveInput.agent,
491
508
  timeout_seconds: timeoutSeconds,
492
509
  change_policy: changePolicy,
510
+ scope: effectiveInput.scope,
511
+ forbidden: effectiveInput.forbidden,
512
+ verification: effectiveInput.verification,
513
+ done_evidence: effectiveInput.done_evidence,
493
514
  snapshot,
494
515
  ...(preGeneratedAssessmentId ? { assessment_id: preGeneratedAssessmentId } : {}),
495
516
  ...(preGeneratedAssessmentDir ? { assessment_dir: preGeneratedAssessmentDir } : {}),
@@ -585,14 +606,42 @@ export async function createTask(input: CreateTaskInput): Promise<CreateTaskResu
585
606
  const snapshot = await captureRepoSnapshot(safeRepoPath);
586
607
  const validation = validateAssessmentFreshness(input.assessment_id!, snapshot);
587
608
  if (!validation.valid) {
609
+ recordAssessmentValidationFailure(validation);
588
610
  throw new PatchWardenError(
589
611
  validation.failure_reason || "assessment_validation_failed",
590
612
  `Assessment "${input.assessment_id}" is no longer valid: ${validation.failure_reason}`,
591
613
  "Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.",
592
614
  true,
593
- { assessment_id: input.assessment_id, failure_reason: validation.failure_reason }
615
+ {
616
+ assessment_id: input.assessment_id,
617
+ reason_code: validation.failure_reason,
618
+ failure_reason: validation.failure_reason,
619
+ ...(validation.expected_hash ? { expected_hash: validation.expected_hash } : {}),
620
+ ...(validation.actual_hash ? { actual_hash: validation.actual_hash } : {}),
621
+ ...(validation.expected_hash ? { assessment_config_sha256: validation.expected_hash } : {}),
622
+ ...(validation.actual_hash ? { current_config_sha256: validation.actual_hash } : {}),
623
+ snapshot_version: validation.assessment?.assessment_security_snapshot_version || null,
624
+ assessment_created_at: validation.assessment?.created_at || null,
625
+ assessment_expires_at: validation.assessment?.expires_at || null,
626
+ config_changed: validation.failure_reason === "assessment_stale_config",
627
+ ...(validation.config_change_categories
628
+ ? {
629
+ config_change_categories: validation.config_change_categories,
630
+ changed_field_names: validation.config_change_categories,
631
+ changed_config_sections: validation.config_change_categories,
632
+ }
633
+ : {}),
634
+ assessment_fingerprint_version: validation.assessment?.assessment_security_snapshot_version || null,
635
+ current_fingerprint_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
636
+ assessment_schema_epoch: validation.assessment?.assessment_schema_epoch || null,
637
+ current_schema_epoch: TOOL_SCHEMA_EPOCH,
638
+ validator_module_path: "dist/assessments/assessmentStore.js",
639
+ validator_build_id: `patchwarden-${PATCHWARDEN_VERSION}:${TOOL_SCHEMA_EPOCH}:${ASSESSMENT_SECURITY_SNAPSHOT_VERSION}`,
640
+ watcher_instance_id: process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || null,
641
+ }
594
642
  );
595
643
  }
644
+ markAssessmentUsed(input.assessment_id!);
596
645
  }
597
646
 
598
647
  const { taskId, taskDir } = createTaskDirectory(tasksDir, config.workspaceRoot, config.tasksDir);
@@ -700,18 +749,22 @@ function mergeAssessmentIntoInput(
700
749
  // Do NOT set template/inline_plan alongside plan_id — createTask requires exactly one plan source.
701
750
  const merged: CreateTaskInput = {
702
751
  ...input,
703
- plan_id: record.plan_id || input.plan_id,
752
+ plan_id: record.plan_id || undefined,
704
753
  template: undefined,
705
754
  inline_plan: undefined,
706
- goal: record.goal || input.goal,
755
+ goal: record.goal || undefined,
707
756
  agent: record.agent,
708
757
  repo_path: record.repo_path,
709
758
  test_command: record.test_command || undefined,
710
- verify_commands: record.verify_commands || input.verify_commands,
711
- timeout_seconds: record.timeout_seconds || input.timeout_seconds,
759
+ verify_commands: record.verify_commands || [],
760
+ timeout_seconds: record.timeout_seconds,
761
+ scope: record.scope || [],
762
+ forbidden: record.forbidden || [],
763
+ verification: record.verification || [],
764
+ done_evidence: record.done_evidence || [],
712
765
  };
713
766
  // Parameter mismatch check: if caller passed explicit params that differ from assessment
714
- if (input.template && record.template && input.template !== record.template) {
767
+ if (input.template && input.template !== record.template) {
715
768
  throw new PatchWardenError(
716
769
  "assessment_parameter_mismatch",
717
770
  `template mismatch: caller passed "${input.template}" but assessment has "${record.template}".`,
@@ -720,7 +773,7 @@ function mergeAssessmentIntoInput(
720
773
  { field: "template", assessment_value: record.template, caller_value: input.template }
721
774
  );
722
775
  }
723
- if (input.goal && record.goal && input.goal !== record.goal) {
776
+ if (input.goal && input.goal !== record.goal) {
724
777
  throw new PatchWardenError(
725
778
  "assessment_parameter_mismatch",
726
779
  `goal mismatch: caller passed a different goal than the assessment.`,
@@ -738,6 +791,58 @@ function mergeAssessmentIntoInput(
738
791
  { field: "repo_path", assessment_value: record.repo_path, caller_value: input.repo_path }
739
792
  );
740
793
  }
794
+ if (input.agent && input.agent !== record.agent) {
795
+ throw new PatchWardenError(
796
+ "assessment_parameter_mismatch",
797
+ `agent mismatch: caller passed "${input.agent}" but assessment has "${record.agent}".`,
798
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
799
+ true,
800
+ { field: "agent" },
801
+ );
802
+ }
803
+ if (
804
+ input.verify_commands
805
+ && JSON.stringify(input.verify_commands) !== JSON.stringify(record.verify_commands || [])
806
+ ) {
807
+ throw new PatchWardenError(
808
+ "assessment_parameter_mismatch",
809
+ "verify_commands mismatch: caller passed commands different from the assessment.",
810
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
811
+ true,
812
+ { field: "verify_commands" },
813
+ );
814
+ }
815
+ if (input.test_command && input.test_command !== record.test_command) {
816
+ throw new PatchWardenError(
817
+ "assessment_parameter_mismatch",
818
+ "test_command mismatch: caller passed a command different from the assessment.",
819
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
820
+ true,
821
+ { field: "test_command" },
822
+ );
823
+ }
824
+ if (input.timeout_seconds !== undefined && input.timeout_seconds !== record.timeout_seconds) {
825
+ throw new PatchWardenError(
826
+ "assessment_parameter_mismatch",
827
+ "timeout_seconds mismatch: caller passed a timeout different from the assessment.",
828
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
829
+ true,
830
+ { field: "timeout_seconds" },
831
+ );
832
+ }
833
+ for (const field of ["scope", "forbidden", "verification", "done_evidence"] as const) {
834
+ const callerValue = input[field];
835
+ const assessmentValue = record[field] || [];
836
+ if (callerValue && JSON.stringify(callerValue) !== JSON.stringify(assessmentValue)) {
837
+ throw new PatchWardenError(
838
+ "assessment_parameter_mismatch",
839
+ `${field} mismatch: caller passed values different from the assessment.`,
840
+ "Do not override assessment-locked parameters. Use the same assessment_id as-is.",
841
+ true,
842
+ { field },
843
+ );
844
+ }
845
+ }
741
846
  return merged;
742
847
  }
743
848
 
@@ -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
 
11
12
  // ── v0.7.0: Task diagnosis types ──────────────────────────────────
12
13
 
@@ -153,17 +154,12 @@ export function diagnoseTask(
153
154
  const phaseStr = (runtime.phase || (typeof status.phase === "string" ? status.phase : "queued")) as string;
154
155
 
155
156
  // ── Terminal tasks: no diagnosis needed ──
156
- const terminalStatuses = new Set([
157
- "done", "done_by_agent", "failed", "failed_verification",
158
- "failed_scope_violation", "failed_policy_violation",
159
- "failed_stale", "orphaned", "canceled",
160
- ]);
161
- if (terminalStatuses.has(statusStr)) {
157
+ if (isTerminalTaskStatus(statusStr)) {
162
158
  return buildTerminalDiagnosis(taskId, statusStr, phaseStr);
163
159
  }
164
160
 
165
- // ── Only diagnose running / collecting_artifacts / pending ──
166
- if (statusStr !== "running" && statusStr !== "collecting_artifacts" && statusStr !== "pending") {
161
+ // Diagnose both current status spellings and legacy phase-as-status records.
162
+ if (!isActiveTaskStatus(statusStr)) {
167
163
  return buildUnknownDiagnosis(taskId, statusStr, phaseStr, [`unexpected status "${statusStr}" for diagnosis`]);
168
164
  }
169
165
 
@@ -12,6 +12,7 @@ import {
12
12
  type PendingReason,
13
13
  type WatcherStatusSnapshot,
14
14
  } from "../../watcherStatus.js";
15
+ import { isTerminalTaskStatus } from "./taskStates.js";
15
16
 
16
17
  export interface GetTaskStatusOutput {
17
18
  task_id: string;
@@ -69,7 +70,11 @@ export function getTaskStatus(taskId: string): GetTaskStatusOutput {
69
70
  const raw = readFileSync(statusFile, "utf-8");
70
71
  const status = JSON.parse(raw) as GetTaskStatusOutput;
71
72
  const runtime = readTaskRuntime(taskDir);
72
- const phase = runtime.phase || status.phase || "queued";
73
+ // A terminal status is authoritative. A stale runtime phase from a crashed
74
+ // Runner must never make a reconciled task look active again.
75
+ const phase = isTerminalTaskStatus(String(status.status))
76
+ ? status.phase || "queued"
77
+ : runtime.phase || status.phase || "queued";
73
78
  const watcher = readWatcherStatus(config);
74
79
  const pendingReason = derivePendingReason({ status: status.status, phase }, watcher);
75
80
  return {
@@ -8,16 +8,7 @@ import {
8
8
  readTextFilePrefixSync,
9
9
  readTextFileTailLinesSync,
10
10
  } from "../../utils/boundedFile.js";
11
-
12
- const TERMINAL_STATUSES = new Set([
13
- "done",
14
- "done_by_agent",
15
- "failed",
16
- "failed_verification",
17
- "failed_scope_violation",
18
- "failed_policy_violation",
19
- "canceled",
20
- ]);
11
+ import { isTerminalTaskStatus } from "./taskStates.js";
21
12
 
22
13
  export interface TaskSummaryOutput {
23
14
  task_id: string;
@@ -143,7 +134,7 @@ export function getTaskSummary(taskId: string, options: GetTaskSummaryOptions =
143
134
  const verifyRead = tryReadJson(join(taskDir, "verify.json"));
144
135
  const result = resultRead.data;
145
136
  const verify = verifyRead.data;
146
- const terminal = TERMINAL_STATUSES.has(String(status.status));
137
+ const terminal = isTerminalTaskStatus(String(status.status));
147
138
  // Phase 4: Use new_out_of_scope_changes (task-caused) for acceptance status.
148
139
  // Pre-existing external dirty files that didn't change should NOT fail acceptance.
149
140
  const outOfScope = asArray(
@@ -11,6 +11,7 @@ import {
11
11
  type WatcherState,
12
12
  type WatcherStatusSnapshot,
13
13
  } from "../../watcherStatus.js";
14
+ import { isActiveTaskStatus } from "./taskStates.js";
14
15
 
15
16
  export interface TaskEntry {
16
17
  task_id: string;
@@ -97,7 +98,7 @@ export function listTasks(input?: ListTasksInput): ListTasksOutput {
97
98
  : null;
98
99
  if (taskAcceptance !== filterAcceptance) continue;
99
100
  }
100
- if (input?.active_only && !["pending", "running", "collecting_artifacts"].includes(String(data.status))) continue;
101
+ if (input?.active_only && !isActiveTaskStatus(String(data.status))) continue;
101
102
  const normalizedRepo = String(data.repo_path || ".").replace(/\\/g, "/");
102
103
  const normalizedResolvedRepo = String(data.resolved_repo_path || "").replace(/\\/g, "/");
103
104
  if (filterRepo && normalizedRepo !== filterRepo && normalizedResolvedRepo !== filterRepo) continue;