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
@@ -0,0 +1,231 @@
1
+ import { createHash } from "node:crypto";
2
+ import { resolve, win32 } from "node:path";
3
+ import type { PatchWardenConfig } from "../config.js";
4
+ import { getRepoAllowedTestCommands, getRepoDirectAllowedCommands } from "../config.js";
5
+ import { stableJsonStringify } from "../utils/stableJson.js";
6
+ import type { ProjectPolicy } from "../policy/projectPolicy.js";
7
+
8
+ /**
9
+ * Version the security snapshot independently from the server/schema version.
10
+ * Changing the fields or their canonicalization should invalidate old records.
11
+ */
12
+ export const ASSESSMENT_SECURITY_SNAPSHOT_VERSION = "assessment-security-v2";
13
+
14
+ export type SecuritySnapshotCategory =
15
+ | "schema_epoch"
16
+ | "tool_profile"
17
+ | "tool_manifest"
18
+ | "workspace_root"
19
+ | "repo_boundary"
20
+ | "agent_launch"
21
+ | "allowed_commands"
22
+ | "repo_allowed_test_commands"
23
+ | "direct_allowed_commands"
24
+ | "repo_direct_allowed_commands"
25
+ | "sensitive_path_rules"
26
+ | "protected_paths"
27
+ | "project_policy"
28
+ | "risk_rules"
29
+ | "confirmation_policy"
30
+ | "task_parameters"
31
+ | "release_protection"
32
+ | "direct_profile"
33
+ | "assessment_ttl"
34
+ | "timeout_policy";
35
+
36
+ export interface AssessmentSecuritySnapshotInput {
37
+ config: PatchWardenConfig;
38
+ schemaEpoch: string;
39
+ toolProfile: string;
40
+ toolManifestSha256: string;
41
+ agent: string;
42
+ repoPath: string;
43
+ changePolicy?: string | null;
44
+ template?: string | null;
45
+ verifyCommands?: string[];
46
+ testCommand?: string | null;
47
+ taskTimeoutSeconds?: number | null;
48
+ scope?: string[];
49
+ forbidden?: string[];
50
+ verification?: string[];
51
+ doneEvidence?: string[];
52
+ riskRulesVersion?: string;
53
+ projectPolicy?: ProjectPolicy | null;
54
+ projectPolicyValid?: boolean | null;
55
+ projectPolicyIssues?: Array<{ code: string; severity: string; field: string }>;
56
+ }
57
+
58
+ export interface AssessmentSecuritySnapshot {
59
+ assessment_security_snapshot_version: string;
60
+ components: Record<SecuritySnapshotCategory, unknown>;
61
+ }
62
+
63
+ export interface AssessmentSecuritySnapshotComparison {
64
+ equal: boolean;
65
+ expected_hash: string;
66
+ actual_hash: string;
67
+ changed_field_names: SecuritySnapshotCategory[];
68
+ }
69
+
70
+ function canonicalPath(value: string): string {
71
+ const rawValue = String(value);
72
+ if (/^[a-z]:[\\/]/i.test(rawValue)) {
73
+ return win32.normalize(rawValue).replace(/\\/g, "/").replace(/\/+$/, "").toLowerCase();
74
+ }
75
+ const normalized = resolve(rawValue).replace(/\\/g, "/").replace(/\/+$/, "");
76
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
77
+ }
78
+
79
+ function sortedUnique(values: readonly string[] | undefined): string[] {
80
+ return [...new Set((values || []).map((value) => String(value).trim()))]
81
+ .filter(Boolean)
82
+ .sort((left, right) => left.localeCompare(right));
83
+ }
84
+
85
+ function sortedUniqueTaskPaths(values: readonly string[] | undefined): string[] {
86
+ return sortedUnique(values?.map((value) => {
87
+ const normalized = String(value).replace(/\\/g, "/").replace(/^\.\//, "").replace(/\/{2,}/g, "/");
88
+ return process.platform === "win32" ? normalized.toLowerCase() : normalized;
89
+ }));
90
+ }
91
+
92
+ function sortedAgentConfig(config: PatchWardenConfig): Record<string, unknown> {
93
+ return Object.fromEntries(Object.entries(config.agents)
94
+ .sort(([left], [right]) => left.localeCompare(right))
95
+ .map(([name, agent]) => [name, {
96
+ command: agent.command,
97
+ args: [...agent.args],
98
+ adapter: agent.adapter || null,
99
+ model: agent.model || null,
100
+ envAllowlist: sortedUnique(agent.envAllowlist),
101
+ }]));
102
+ }
103
+
104
+ /** Build only security-relevant configuration; runtime state is deliberately absent. */
105
+ export function buildAssessmentSecuritySnapshot(input: AssessmentSecuritySnapshotInput): AssessmentSecuritySnapshot {
106
+ const { config } = input;
107
+ const repoPath = canonicalPath(input.repoPath);
108
+ const components: Record<SecuritySnapshotCategory, unknown> = {
109
+ schema_epoch: input.schemaEpoch,
110
+ tool_profile: input.toolProfile,
111
+ tool_manifest: input.toolManifestSha256,
112
+ workspace_root: canonicalPath(config.workspaceRoot),
113
+ repo_boundary: {
114
+ workspace_root: canonicalPath(config.workspaceRoot),
115
+ repo_path: repoPath,
116
+ repo_aliases: Object.fromEntries(Object.entries(config.repoAliases || {})
117
+ .sort(([left], [right]) => left.localeCompare(right))
118
+ .map(([key, value]) => [key, canonicalPath(resolve(config.workspaceRoot, value))])),
119
+ },
120
+ agent_launch: {
121
+ selected_agent: input.agent,
122
+ configured_agents: sortedAgentConfig(config),
123
+ },
124
+ allowed_commands: {
125
+ configured: sortedUnique(config.allowedTestCommands),
126
+ project: sortedUnique(input.projectPolicy?.allowed_commands),
127
+ },
128
+ repo_allowed_test_commands: sortedUnique(getRepoAllowedTestCommands(config, repoPath)),
129
+ direct_allowed_commands: sortedUnique(config.directAllowedCommands),
130
+ repo_direct_allowed_commands: sortedUnique(getRepoDirectAllowedCommands(config, repoPath)),
131
+ sensitive_path_rules: "sensitive-path-rules-v1",
132
+ protected_paths: sortedUnique(input.projectPolicy?.protected_paths || [
133
+ ".env", ".env.*", ".ssh", ".npmrc", ".pypirc", "patchwarden.config.json",
134
+ ]),
135
+ project_policy: {
136
+ valid: input.projectPolicyValid ?? null,
137
+ effective_policy: input.projectPolicy || null,
138
+ issues: [...(input.projectPolicyIssues || [])]
139
+ .map((issue) => ({ code: issue.code, severity: issue.severity, field: issue.field }))
140
+ .sort((left, right) => stableJsonStringify(left).localeCompare(stableJsonStringify(right))),
141
+ },
142
+ risk_rules: {
143
+ implementation: input.riskRulesVersion || "risk-engine-v1",
144
+ project_high_risk_commands: sortedUnique(input.projectPolicy?.high_risk_commands),
145
+ },
146
+ confirmation_policy: {
147
+ change_policy: input.changePolicy || "repo_scoped_changes",
148
+ template: input.template || null,
149
+ },
150
+ task_parameters: {
151
+ test_command: input.testCommand || null,
152
+ verify_commands: sortedUnique(input.verifyCommands),
153
+ timeout_seconds: input.taskTimeoutSeconds ?? config.defaultTaskTimeoutSeconds,
154
+ scope: sortedUniqueTaskPaths(input.scope),
155
+ forbidden: sortedUniqueTaskPaths(input.forbidden),
156
+ verification: sortedUnique(input.verification),
157
+ done_evidence: sortedUniqueTaskPaths(input.doneEvidence),
158
+ },
159
+ release_protection: {
160
+ high_risk_commands: sortedUnique(input.projectPolicy?.high_risk_commands || [
161
+ "npm publish", "git push", "git tag", "gh release create",
162
+ ]),
163
+ release_mode: input.projectPolicy?.release_mode || {
164
+ version_source: "package.json",
165
+ required_commands: ["npm run build", "npm test"],
166
+ },
167
+ artifact_rules: "artifact-rules-v1",
168
+ },
169
+ direct_profile: Boolean(config.enableDirectProfile),
170
+ assessment_ttl: config.assessmentTtlSeconds,
171
+ timeout_policy: {
172
+ default_task_timeout_seconds: config.defaultTaskTimeoutSeconds,
173
+ max_task_timeout_seconds: config.maxTaskTimeoutSeconds,
174
+ },
175
+ };
176
+
177
+ return {
178
+ assessment_security_snapshot_version: ASSESSMENT_SECURITY_SNAPSHOT_VERSION,
179
+ components,
180
+ };
181
+ }
182
+
183
+ /** Recursively sort object keys while preserving array order where it matters. */
184
+ export function canonicalizeAssessmentSecuritySnapshot(snapshot: AssessmentSecuritySnapshot): AssessmentSecuritySnapshot {
185
+ const canonicalize = (value: unknown): unknown => {
186
+ if (Array.isArray(value)) return value.map(canonicalize);
187
+ if (value !== null && typeof value === "object") {
188
+ return Object.fromEntries(Object.entries(value as Record<string, unknown>)
189
+ .sort(([left], [right]) => left.localeCompare(right))
190
+ .map(([key, entry]) => [key, canonicalize(entry)]));
191
+ }
192
+ return value;
193
+ };
194
+ return canonicalize(snapshot) as AssessmentSecuritySnapshot;
195
+ }
196
+
197
+ export function hashAssessmentSecuritySnapshot(snapshot: AssessmentSecuritySnapshot): string {
198
+ return createHash("sha256")
199
+ .update(stableJsonStringify(canonicalizeAssessmentSecuritySnapshot(snapshot)))
200
+ .digest("hex");
201
+ }
202
+
203
+ export function getAssessmentSecuritySnapshotComponentHashes(
204
+ snapshot: AssessmentSecuritySnapshot,
205
+ ): Record<SecuritySnapshotCategory, string> {
206
+ return Object.fromEntries(Object.entries(snapshot.components).map(([name, value]) => [
207
+ name,
208
+ createHash("sha256").update(stableJsonStringify(value)).digest("hex"),
209
+ ])) as Record<SecuritySnapshotCategory, string>;
210
+ }
211
+
212
+ export function compareAssessmentSecuritySnapshots(
213
+ expected: AssessmentSecuritySnapshot,
214
+ actual: AssessmentSecuritySnapshot,
215
+ ): AssessmentSecuritySnapshotComparison {
216
+ const expectedHash = hashAssessmentSecuritySnapshot(expected);
217
+ const actualHash = hashAssessmentSecuritySnapshot(actual);
218
+ const expectedComponents = getAssessmentSecuritySnapshotComponentHashes(expected);
219
+ const actualComponents = getAssessmentSecuritySnapshotComponentHashes(actual);
220
+ const changed = (Object.keys(actualComponents) as SecuritySnapshotCategory[])
221
+ .filter((name) => expectedComponents[name] !== actualComponents[name]);
222
+ if (expected.assessment_security_snapshot_version !== actual.assessment_security_snapshot_version) {
223
+ changed.unshift("schema_epoch");
224
+ }
225
+ return {
226
+ equal: expectedHash === actualHash,
227
+ expected_hash: expectedHash,
228
+ actual_hash: actualHash,
229
+ changed_field_names: [...new Set(changed)],
230
+ };
231
+ }
@@ -52,6 +52,39 @@ interface Suggestion {
52
52
  link?: string;
53
53
  }
54
54
 
55
+ export interface ConnectionSummary {
56
+ profile: string;
57
+ tool_profile: string | null;
58
+ ready: boolean;
59
+ tool_count: number | null;
60
+ tool_manifest_sha256: string | null;
61
+ tunnel_id_masked: string | null;
62
+ reconnect_guidance: string;
63
+ }
64
+
65
+ export function buildConnectionSummary(
66
+ mode: "core" | "direct",
67
+ tunnel: Record<string, unknown>,
68
+ tools: { tool_profile: string | null; tool_count: number | null; tool_manifest_sha256: string | null },
69
+ ): ConnectionSummary {
70
+ const masked = typeof tunnel.tunnel_id_masked === "string" && tunnel.tunnel_id_masked.includes("***")
71
+ ? tunnel.tunnel_id_masked
72
+ : null;
73
+ return {
74
+ profile: typeof tunnel.profile === "string"
75
+ ? tunnel.profile
76
+ : mode === "direct" ? "patchwarden-direct" : "patchwarden",
77
+ tool_profile: tools.tool_profile,
78
+ ready: tunnel.ready === true,
79
+ tool_count: tools.tool_count,
80
+ tool_manifest_sha256: tools.tool_manifest_sha256,
81
+ tunnel_id_masked: masked,
82
+ reconnect_guidance: mode === "direct"
83
+ ? "If ChatGPT still uses an older Direct Tunnel, update the connector binding and test health_check in a new chat."
84
+ : "If ChatGPT still uses an older Core Tunnel, update the connector binding and test health_check in a new chat.",
85
+ };
86
+ }
87
+
55
88
  interface StatusSnapshotForSuggestions {
56
89
  core: RuntimeHealth;
57
90
  direct: RuntimeHealth;
@@ -290,6 +323,10 @@ export async function handleStatus(res: ServerResponse): Promise<void> {
290
323
  watcher,
291
324
  tunnel: { core: tunnelCore, direct: tunnelDirect },
292
325
  tools: { core: toolsCore, direct: toolsDirect },
326
+ connections: {
327
+ core: buildConnectionSummary("core", tunnelCore, toolsCore),
328
+ direct: buildConnectionSummary("direct", tunnelDirect, toolsDirect),
329
+ },
293
330
  agents,
294
331
  workspace_root: workspaceRoot,
295
332
  tasks,
@@ -19,6 +19,7 @@ import { isValidDirectSessionId as isValidStoredDirectSessionId } from "../direc
19
19
  import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../utils/atomicFile.js";
20
20
  import { withFileLockSync } from "../utils/lockedJsonFile.js";
21
21
  import { logger } from "../logging.js";
22
+ import { TERMINAL_TASK_STATUSES as SHARED_TERMINAL_TASK_STATUSES } from "../tools/tasks/taskStates.js";
22
23
  import {
23
24
  config,
24
25
  controlCenterEventsPath,
@@ -204,16 +205,7 @@ export interface StaleClassification {
204
205
  stale_reasons: string[];
205
206
  }
206
207
 
207
- export const TERMINAL_TASK_STATUSES = new Set([
208
- "done",
209
- "done_by_agent",
210
- "failed",
211
- "failed_verification",
212
- "failed_scope_violation",
213
- "failed_policy_violation",
214
- "canceled",
215
- "timeout",
216
- ]);
208
+ export const TERMINAL_TASK_STATUSES = SHARED_TERMINAL_TASK_STATUSES;
217
209
 
218
210
  /**
219
211
  * Classify a task as stale based on Phase 2 rules:
@@ -68,6 +68,7 @@ async function handleRequest(req: IncomingMessage, res: ServerResponse): Promise
68
68
  (pathname === "/colors_and_type.css" ||
69
69
  pathname === "/desktop.css" ||
70
70
  pathname === "/desktop-bridge.js" ||
71
+ pathname === "/log-parser.js" ||
71
72
  pathname === "/i18n.js" ||
72
73
  pathname === "/getting-started.js" ||
73
74
  pathname === "/settings.js" ||
@@ -0,0 +1,12 @@
1
+ const DANGEROUS_ALLOWED_TEST_COMMAND_PATTERNS = [
2
+ /(^|\s)rm\s+-rf(?:\s|$)/i,
3
+ /(^|\s)del\s+\/s(?:\s|$)/i,
4
+ /(^|\s)format(?:\.exe)?(?:\s|$)/i,
5
+ /(^|\s)shutdown(?:\.exe)?(?:\s|$)/i,
6
+ /curl\s+[^\r\n|]*\|/i,
7
+ /wget\s+[^\r\n|]*\|/i,
8
+ ];
9
+
10
+ export function isDangerousAllowedTestCommand(command: string): boolean {
11
+ return DANGEROUS_ALLOWED_TEST_COMMAND_PATTERNS.some((pattern) => pattern.test(command));
12
+ }
@@ -27,6 +27,11 @@ import {
27
27
  type ChangeArtifacts,
28
28
  } from "../runner/changeCapture.js";
29
29
 
30
+ // Session history writes are metadata-only and may queue behind concurrent
31
+ // Direct workers. Keep the wait bounded, while workspace mutation locks remain
32
+ // fail-fast so an active patch is never hidden by retrying.
33
+ const DIRECT_SESSION_RECORD_LOCK_WAIT_MS = 10_000;
34
+
30
35
  // ── Types ──────────────────────────────────────────────────────────
31
36
 
32
37
  export interface DirectSessionOperation {
@@ -423,10 +428,7 @@ function mutateDirectSessionRecord(
423
428
  return { next, result: next };
424
429
  },
425
430
  {
426
- // Session-record appends are short serialized writes. Give Windows
427
- // antivirus-delayed lock detachment enough time to finish instead of
428
- // dropping concurrent operation or verification evidence.
429
- waitMs: 10_000,
431
+ waitMs: DIRECT_SESSION_RECORD_LOCK_WAIT_MS,
430
432
  busyError: () => new PatchWardenError(
431
433
  "direct_session_busy",
432
434
  `Direct session "${sessionId}" is currently being updated.`,
package/src/doctor.ts CHANGED
@@ -26,6 +26,7 @@ import { logger } from "./logging.js";
26
26
  import { unsafeWorkspaceRootLabel } from "./security/workspaceRootGuard.js";
27
27
  import { runSimpleProcessSync } from "./runner/simpleProcess.js";
28
28
  import { resolveAgentLaunch } from "./runner/agentInvocation.js";
29
+ import { isDangerousAllowedTestCommand } from "./diagnostics/allowedTestCommandSafety.js";
29
30
 
30
31
  // ── Types ──────────────────────────────────────────────────────────
31
32
 
@@ -655,12 +656,9 @@ const checkAllowedTestCommandsSafety: DoctorCheck = {
655
656
  const testCmds = context.config.allowedTestCommands || [];
656
657
  results.push(checkResult("allowedTestCommands is non-empty", testCmds.length > 0, testCmds.length > 0 ? `${testCmds.length} commands` : "No test commands configured"));
657
658
 
658
- const dangerous = ["rm -rf", "del /s", "format", "shutdown", "curl |", "wget |"];
659
659
  for (const cmdStr of testCmds) {
660
- for (const danger of dangerous) {
661
- if (cmdStr.toLowerCase().includes(danger)) {
662
- results.push(warnResult(`allowedTestCommands contains dangerous pattern: "${cmdStr}"`));
663
- }
660
+ if (isDangerousAllowedTestCommand(cmdStr)) {
661
+ results.push(warnResult(`allowedTestCommands contains dangerous pattern: "${cmdStr}"`));
664
662
  }
665
663
  }
666
664
  return results;
@@ -1,4 +1,5 @@
1
1
  import { spawn, spawnSync, type ChildProcess } from "node:child_process";
2
+ import { randomUUID } from "node:crypto";
2
3
  import {
3
4
  existsSync,
4
5
  readFileSync,
@@ -18,6 +19,7 @@ import {
18
19
  type TaskStatusRecord,
19
20
  } from "./taskStatusStore.js";
20
21
  import { validateAssessmentFreshness } from "../assessments/assessmentStore.js";
22
+ import { recordAssessmentValidationFailure } from "../assessments/assessmentDiagnostics.js";
21
23
  import { buildAgentInvocation, buildExecutionPrompt } from "./agentInvocation.js";
22
24
  import type { TaskPhase, TaskStatus } from "../tools/tasks/createTask.js";
23
25
  import {
@@ -117,6 +119,7 @@ interface TaskContext {
117
119
  timeoutSeconds: number;
118
120
  startedAtMs: number;
119
121
  deadlineMs: number;
122
+ runnerInstanceId: string;
120
123
  beforeSnapshot: RepoSnapshot;
121
124
  beforeWorkspaceSnapshot: RepoSnapshot;
122
125
  externalDirtyBaseline: ExternalDirtyFile[];
@@ -124,6 +127,7 @@ interface TaskContext {
124
127
 
125
128
  interface ExecutionState {
126
129
  agentResult: ManagedProcessResult | null;
130
+ agentFailureCategory: string | null;
127
131
  testResult: TestExecutionResult;
128
132
  verifyResults: TestExecutionResult[];
129
133
  finalStatus: TaskStatus;
@@ -168,6 +172,7 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
168
172
  if (!existsSync(statusFile)) throw new Error(`Task not found: "${taskId}"`);
169
173
 
170
174
  const startedAtMs = Date.now();
175
+ const runnerInstanceId = randomUUID().replace(/-/g, "");
171
176
  const claim = claimPendingTask(statusFile, {
172
177
  status: "running",
173
178
  phase: "preparing",
@@ -220,7 +225,11 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
220
225
  const preExecSnapshot = await captureRepoSnapshot(repoPath);
221
226
  const validation = validateAssessmentFreshness(assessmentId, preExecSnapshot);
222
227
  if (!validation.valid) {
223
- const message = `assessment validation failed: ${validation.failure_reason}. Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
228
+ recordAssessmentValidationFailure(validation);
229
+ const changedFields = validation.config_change_categories?.length
230
+ ? ` Changed fields: ${validation.config_change_categories.join(", ")}.`
231
+ : "";
232
+ const message = `assessment validation failed: ${validation.failure_reason}.${changedFields} Re-run create_task with execution_mode=assess_only to get a fresh assessment_id.`;
224
233
  return failBeforeExecution(taskId, taskDir, message);
225
234
  }
226
235
  } catch (error) {
@@ -233,6 +242,8 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
233
242
  writeTaskRuntime(taskDir, {
234
243
  task_started_at: new Date(startedAtMs).toISOString(),
235
244
  watcher_instance_id: process.env.PATCHWARDEN_WATCHER_INSTANCE_ID || undefined,
245
+ runner_pid: process.pid,
246
+ runner_instance_id: runnerInstanceId,
236
247
  });
237
248
  setTaskPhase(taskDir, "preparing", null, "Capturing pre-task repository state.");
238
249
 
@@ -253,13 +264,14 @@ async function prepareTask(taskId: string): Promise<TaskContext | TaskRunResult>
253
264
  return {
254
265
  taskId, taskDir, statusFile, repoPath, wsRoot, config, plansDir, initialStatus,
255
266
  planId, agentName, testCommand, changePolicy, verifyCommands, timeoutSeconds,
256
- startedAtMs, deadlineMs, beforeSnapshot, beforeWorkspaceSnapshot, externalDirtyBaseline,
267
+ startedAtMs, deadlineMs, runnerInstanceId, beforeSnapshot, beforeWorkspaceSnapshot, externalDirtyBaseline,
257
268
  };
258
269
  }
259
270
 
260
271
  async function executeAgent(ctx: TaskContext): Promise<ExecutionState> {
261
272
  const state: ExecutionState = {
262
273
  agentResult: null,
274
+ agentFailureCategory: null,
263
275
  testResult: skippedTest(ctx.testCommand, ctx.repoPath, "Agent did not complete successfully."),
264
276
  verifyResults: [],
265
277
  finalStatus: "failed",
@@ -284,6 +296,7 @@ async function executeAgent(ctx: TaskContext): Promise<ExecutionState> {
284
296
  phase: "executing_agent",
285
297
  currentCommand: invocation.commandLabel,
286
298
  deadlineMs: ctx.deadlineMs,
299
+ runnerInstanceId: ctx.runnerInstanceId,
287
300
  stdoutPath: join(ctx.taskDir, "stdout.log"),
288
301
  stderrPath: join(ctx.taskDir, "stderr.log"),
289
302
  environmentVariableNames: invocation.environmentVariableNames,
@@ -297,11 +310,15 @@ async function executeAgent(ctx: TaskContext): Promise<ExecutionState> {
297
310
  ? "Task was terminated by kill_task."
298
311
  : "Task was canceled by user request.";
299
312
  } else if (agentResult.terminationReason === "timeout") {
313
+ state.finalStatus = "timeout";
300
314
  state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during agent execution.`;
301
315
  } else if (agentResult.spawnError) {
302
316
  state.finalError = `Agent spawn failed: ${agentResult.spawnError}`;
303
317
  } else if (agentResult.exitCode !== 0) {
304
- state.finalError = `Agent exited with code ${agentResult.exitCode}.`;
318
+ state.agentFailureCategory = classifyAgentFailure(agentResult.stderr);
319
+ state.finalError = state.agentFailureCategory
320
+ ? `Agent provider failure (${state.agentFailureCategory}); process exited with code ${agentResult.exitCode}.`
321
+ : `Agent exited with code ${agentResult.exitCode}.`;
305
322
  }
306
323
  } catch (error) {
307
324
  state.lastCaughtError = error;
@@ -318,7 +335,14 @@ async function runVerification(ctx: TaskContext, state: ExecutionState): Promise
318
335
  if (ctx.verifyCommands.length > 0) {
319
336
  for (const command of ctx.verifyCommands) {
320
337
  setTaskPhase(ctx.taskDir, "running_tests", command);
321
- const verification = await runTrustedTestCommand(command, ctx.repoPath, ctx.taskDir, ctx.statusFile, ctx.deadlineMs);
338
+ const verification = await runTrustedTestCommand(
339
+ command,
340
+ ctx.repoPath,
341
+ ctx.taskDir,
342
+ ctx.statusFile,
343
+ ctx.deadlineMs,
344
+ ctx.runnerInstanceId,
345
+ );
322
346
  state.verifyResults.push(verification);
323
347
  if (verification.terminationReason || Date.now() >= ctx.deadlineMs) break;
324
348
  }
@@ -331,6 +355,7 @@ async function runVerification(ctx: TaskContext, state: ExecutionState): Promise
331
355
  ? "Task was terminated by kill_task during verification."
332
356
  : "Task was canceled during verification.";
333
357
  } else if (interrupted?.terminationReason === "timeout" || Date.now() >= ctx.deadlineMs) {
358
+ state.finalStatus = "timeout";
334
359
  state.finalError = `Task timed out after ${ctx.timeoutSeconds} seconds during verification.`;
335
360
  } else if (failedVerification) {
336
361
  state.finalStatus = "failed_verification";
@@ -542,7 +567,7 @@ function finalizeTask(ctx: TaskContext, state: ExecutionState, evidence: Artifac
542
567
  atomicWriteJsonFileSync(join(ctx.taskDir, "verify.json"), verifyJson);
543
568
  atomicWriteFileSync(join(ctx.taskDir, "verify.log"), buildVerifyLog(verifyJson.commands));
544
569
 
545
- if (!["canceled", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(state.finalStatus)) state.finalStatus = "failed";
570
+ if (!["canceled", "timeout", "done_by_agent", "failed_verification", "failed_scope_violation", "failed_policy_violation"].includes(state.finalStatus)) state.finalStatus = "failed";
546
571
  const finalPhase: TaskPhase = state.finalStatus === "done_by_agent" ? "done_by_agent" : (state.finalStatus as TaskPhase);
547
572
  const followup = buildFailureFollowup(state.finalStatus, state.finalError, verifyJson.commands);
548
573
 
@@ -600,6 +625,10 @@ function finalizeTask(ctx: TaskContext, state: ExecutionState, evidence: Artifac
600
625
  last_heartbeat_at: finishedAt,
601
626
  finished_at: finishedAt,
602
627
  error: state.finalError,
628
+ termination_reason: state.finalStatus === "timeout"
629
+ ? "timeout"
630
+ : state.finalStatus === "canceled" ? "canceled" : null,
631
+ agent_failure_category: state.agentFailureCategory,
603
632
  changed_files: changes.changed_files.map(({ path, change }) => ({ path, change })),
604
633
  artifact_hygiene_counts: changes.artifact_hygiene.counts,
605
634
  artifact_status: artifactStatus,
@@ -662,6 +691,10 @@ function buildResultJson(input: {
662
691
  template: ctx.initialStatus.template || null,
663
692
  change_policy: ctx.changePolicy,
664
693
  summary: state.finalError || "Agent execution and configured verification completed successfully.",
694
+ termination_reason: state.finalStatus === "timeout"
695
+ ? "timeout"
696
+ : state.finalStatus === "canceled" ? "canceled" : null,
697
+ agent_failure_category: state.agentFailureCategory,
665
698
  changed_files: changes.changed_files,
666
699
  changed_file_groups: {
667
700
  source_changes: changedFileGroups.source_changes.length,
@@ -792,6 +825,7 @@ async function runManagedProcess(options: {
792
825
  environmentVariableNames?: string[];
793
826
  blockedEnvironmentVariableNames?: string[];
794
827
  maxLogBytes?: number;
828
+ runnerInstanceId: string;
795
829
  }): Promise<ManagedProcessResult> {
796
830
  if (Date.now() >= options.deadlineMs) {
797
831
  return { exitCode: null, stdout: "", stderr: "", spawnError: null, terminationReason: "timeout" };
@@ -825,6 +859,9 @@ async function runManagedProcess(options: {
825
859
  writeTaskRuntime(options.taskDir, {
826
860
  child_pid: child.pid,
827
861
  child_started_at: new Date().toISOString(),
862
+ runner_pid: process.pid,
863
+ runner_instance_id: options.runnerInstanceId,
864
+ child_owned_by_runner_instance_id: options.runnerInstanceId,
828
865
  });
829
866
 
830
867
  let stdout = "";
@@ -939,7 +976,8 @@ async function runTrustedTestCommand(
939
976
  repoPath: string,
940
977
  taskDir: string,
941
978
  statusFile: string,
942
- deadlineMs: number
979
+ deadlineMs: number,
980
+ runnerInstanceId: string,
943
981
  ): Promise<TestExecutionResult> {
944
982
  const config = getConfig();
945
983
  const trusted = guardTestCommand(testCommand, config, repoPath);
@@ -962,6 +1000,7 @@ async function runTrustedTestCommand(
962
1000
  phase: "running_tests",
963
1001
  currentCommand: trusted,
964
1002
  deadlineMs,
1003
+ runnerInstanceId,
965
1004
  stdoutPath: join(taskDir, "test.stdout.log"),
966
1005
  stderrPath: join(taskDir, "test.stderr.log"),
967
1006
  });
@@ -1337,3 +1376,17 @@ function appendBounded(current: string, next: string): string {
1337
1376
  function errorMessage(error: unknown): string {
1338
1377
  return error instanceof Error ? error.message : String(error);
1339
1378
  }
1379
+
1380
+ export function classifyAgentFailure(stderr: string): string | null {
1381
+ const normalized = stderr.toLowerCase();
1382
+ if (normalized.includes("insufficient balance") || normalized.includes("insufficient credits")) {
1383
+ return "provider_insufficient_balance";
1384
+ }
1385
+ if (normalized.includes("unauthorized") || normalized.includes("authentication failed") || normalized.includes("invalid api key")) {
1386
+ return "provider_authentication_failed";
1387
+ }
1388
+ if (normalized.includes("model not found") || normalized.includes("model access") || normalized.includes("permission denied")) {
1389
+ return "provider_model_unavailable";
1390
+ }
1391
+ return null;
1392
+ }
@@ -8,6 +8,8 @@ export interface TaskRuntimeData {
8
8
  last_heartbeat_at: string;
9
9
  current_command: string | null;
10
10
  runner_pid?: number;
11
+ runner_instance_id?: string;
12
+ child_owned_by_runner_instance_id?: string;
11
13
  child_pid?: number;
12
14
  /**
13
15
  * v0.7.0: ISO timestamp when the child process was spawned.
@@ -4,6 +4,7 @@ import {
4
4
  readJsonObjectFileSync,
5
5
  type LockedJsonMutation,
6
6
  } from "../utils/lockedJsonFile.js";
7
+ import { isAllowedTaskStatusTransition } from "../tools/tasks/taskStates.js";
7
8
 
8
9
  export type TaskStatusRecord = Record<string, unknown>;
9
10
 
@@ -22,7 +23,21 @@ export function mutateTaskStatus<T>(
22
23
  statusFile: string,
23
24
  mutation: (current: TaskStatusRecord) => TaskStatusMutation<T>,
24
25
  ): T {
25
- return mutateLockedJsonFileSync(statusFile, mutation, {
26
+ return mutateLockedJsonFileSync<TaskStatusRecord, T>(statusFile, (current) => {
27
+ const outcome = mutation(current);
28
+ if (outcome.next) {
29
+ const from = String(current.status || "");
30
+ const to = String(outcome.next.status || from);
31
+ if (!isAllowedTaskStatusTransition(from, to)) {
32
+ throw new PatchWardenError(
33
+ "invalid_task_status_transition",
34
+ `Task status cannot transition from "${from || "unknown"}" to "${to || "unknown"}".`,
35
+ "Refresh the task status and apply only a forward lifecycle transition.",
36
+ );
37
+ }
38
+ }
39
+ return outcome;
40
+ }, {
26
41
  busyError: () => new PatchWardenError(
27
42
  "task_status_busy",
28
43
  "Task status is currently being updated by another PatchWarden process.",