patchwarden 0.6.4 → 1.5.0

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 (254) hide show
  1. package/README.en.md +54 -20
  2. package/README.md +48 -21
  3. package/dist/agents/agentRouter.d.ts +40 -0
  4. package/dist/agents/agentRouter.js +95 -0
  5. package/dist/config.d.ts +1 -1
  6. package/dist/config.js +6 -2
  7. package/dist/controlCenter.js +212 -3
  8. package/dist/direct/directSessionStore.d.ts +2 -0
  9. package/dist/direct/directVerification.js +7 -0
  10. package/dist/doctor.js +32 -1
  11. package/dist/goal/acceptanceEngine.d.ts +62 -0
  12. package/dist/goal/acceptanceEngine.js +103 -0
  13. package/dist/goal/acceptanceTemplate.d.ts +10 -0
  14. package/dist/goal/acceptanceTemplate.js +104 -0
  15. package/dist/goal/goalGraph.d.ts +58 -0
  16. package/dist/goal/goalGraph.js +189 -0
  17. package/dist/goal/goalProgress.d.ts +81 -0
  18. package/dist/goal/goalProgress.js +216 -0
  19. package/dist/goal/goalStatus.d.ts +60 -0
  20. package/dist/goal/goalStatus.js +137 -0
  21. package/dist/goal/goalStore.d.ts +79 -0
  22. package/dist/goal/goalStore.js +211 -0
  23. package/dist/goal/handoffExport.d.ts +34 -0
  24. package/dist/goal/handoffExport.js +183 -0
  25. package/dist/goal/subgoalSync.d.ts +40 -0
  26. package/dist/goal/subgoalSync.js +72 -0
  27. package/dist/goal/worktreeManager.d.ts +96 -0
  28. package/dist/goal/worktreeManager.js +292 -0
  29. package/dist/logging.d.ts +44 -0
  30. package/dist/logging.js +68 -0
  31. package/dist/policy/projectPolicy.d.ts +55 -0
  32. package/dist/policy/projectPolicy.js +286 -0
  33. package/dist/release/releaseGate.d.ts +99 -0
  34. package/dist/release/releaseGate.js +475 -0
  35. package/dist/runner/postTaskCleanup.d.ts +13 -0
  36. package/dist/runner/postTaskCleanup.js +147 -0
  37. package/dist/runner/runTask.js +50 -10
  38. package/dist/security/discoveryTokenStore.d.ts +62 -0
  39. package/dist/security/discoveryTokenStore.js +100 -0
  40. package/dist/security/toolInvocationGuard.d.ts +35 -0
  41. package/dist/security/toolInvocationGuard.js +153 -0
  42. package/dist/smoke-test.js +18 -10
  43. package/dist/taskRuntime.d.ts +17 -0
  44. package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
  45. package/dist/test/unit/acceptance-engine.test.js +228 -0
  46. package/dist/test/unit/agent-router.test.d.ts +1 -0
  47. package/dist/test/unit/agent-router.test.js +287 -0
  48. package/dist/test/unit/audit-checks.test.d.ts +1 -0
  49. package/dist/test/unit/audit-checks.test.js +350 -0
  50. package/dist/test/unit/diagnose-task.test.d.ts +1 -0
  51. package/dist/test/unit/diagnose-task.test.js +457 -0
  52. package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
  53. package/dist/test/unit/discovery-token-store.test.js +139 -0
  54. package/dist/test/unit/evidence-pack.test.d.ts +1 -0
  55. package/dist/test/unit/evidence-pack.test.js +130 -0
  56. package/dist/test/unit/goal-graph.test.d.ts +1 -0
  57. package/dist/test/unit/goal-graph.test.js +298 -0
  58. package/dist/test/unit/goal-progress.test.d.ts +1 -0
  59. package/dist/test/unit/goal-progress.test.js +381 -0
  60. package/dist/test/unit/goal-status.test.d.ts +1 -0
  61. package/dist/test/unit/goal-status.test.js +215 -0
  62. package/dist/test/unit/goal-store.test.d.ts +1 -0
  63. package/dist/test/unit/goal-store.test.js +253 -0
  64. package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
  65. package/dist/test/unit/goal-subgoal-task.test.js +55 -0
  66. package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
  67. package/dist/test/unit/goal-tools-registry.test.js +190 -0
  68. package/dist/test/unit/handoff-export.test.d.ts +1 -0
  69. package/dist/test/unit/handoff-export.test.js +263 -0
  70. package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
  71. package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
  72. package/dist/test/unit/logging.test.js +127 -5
  73. package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
  74. package/dist/test/unit/post-task-cleanup.test.js +48 -0
  75. package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
  76. package/dist/test/unit/project-policy-release-mode.test.js +125 -0
  77. package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
  78. package/dist/test/unit/reconcile-tasks.test.js +456 -0
  79. package/dist/test/unit/release-gate.test.d.ts +1 -0
  80. package/dist/test/unit/release-gate.test.js +242 -0
  81. package/dist/test/unit/run-task-loop.test.d.ts +1 -0
  82. package/dist/test/unit/run-task-loop.test.js +380 -0
  83. package/dist/test/unit/safe-views.test.d.ts +1 -0
  84. package/dist/test/unit/safe-views.test.js +171 -0
  85. package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
  86. package/dist/test/unit/schema-drift-check.test.js +176 -0
  87. package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
  88. package/dist/test/unit/subgoal-sync.test.js +183 -0
  89. package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
  90. package/dist/test/unit/tool-invocation-guard.test.js +432 -0
  91. package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
  92. package/dist/test/unit/tool-usage-stats.test.js +300 -0
  93. package/dist/test/unit/toolSearch.test.d.ts +1 -0
  94. package/dist/test/unit/toolSearch.test.js +571 -0
  95. package/dist/test/unit/worktree-manager.test.d.ts +1 -0
  96. package/dist/test/unit/worktree-manager.test.js +176 -0
  97. package/dist/tools/auditTask.d.ts +103 -1
  98. package/dist/tools/auditTask.js +461 -8
  99. package/dist/tools/cancelTask.js +1 -1
  100. package/dist/tools/checkReleaseGate.d.ts +21 -0
  101. package/dist/tools/checkReleaseGate.js +22 -0
  102. package/dist/tools/createTask.d.ts +18 -2
  103. package/dist/tools/createTask.js +29 -1
  104. package/dist/tools/diagnoseTask.d.ts +45 -0
  105. package/dist/tools/diagnoseTask.js +347 -0
  106. package/dist/tools/discardWorktree.d.ts +24 -0
  107. package/dist/tools/discardWorktree.js +27 -0
  108. package/dist/tools/discoverTools.d.ts +11 -0
  109. package/dist/tools/discoverTools.js +39 -0
  110. package/dist/tools/evidencePack.d.ts +39 -0
  111. package/dist/tools/evidencePack.js +168 -0
  112. package/dist/tools/explainTool.d.ts +11 -0
  113. package/dist/tools/explainTool.js +21 -0
  114. package/dist/tools/getTaskSummary.js +2 -1
  115. package/dist/tools/goalSubgoalTask.d.ts +51 -0
  116. package/dist/tools/goalSubgoalTask.js +110 -0
  117. package/dist/tools/healthCheck.d.ts +1 -0
  118. package/dist/tools/healthCheck.js +5 -1
  119. package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
  120. package/dist/tools/invokeDiscoveredTool.js +112 -0
  121. package/dist/tools/listTasks.d.ts +3 -1
  122. package/dist/tools/listTasks.js +14 -1
  123. package/dist/tools/mergeWorktree.d.ts +24 -0
  124. package/dist/tools/mergeWorktree.js +27 -0
  125. package/dist/tools/recommendAgentForTask.d.ts +19 -0
  126. package/dist/tools/recommendAgentForTask.js +56 -0
  127. package/dist/tools/reconcileTasks.d.ts +41 -0
  128. package/dist/tools/reconcileTasks.js +352 -0
  129. package/dist/tools/registry.js +926 -2
  130. package/dist/tools/releaseMode.d.ts +50 -0
  131. package/dist/tools/releaseMode.js +370 -0
  132. package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
  133. package/dist/tools/runDirectVerificationBundle.js +64 -0
  134. package/dist/tools/runTaskLoop.d.ts +57 -0
  135. package/dist/tools/runTaskLoop.js +417 -0
  136. package/dist/tools/runVerification.d.ts +4 -0
  137. package/dist/tools/runVerification.js +4 -0
  138. package/dist/tools/safeStatus.d.ts +31 -1
  139. package/dist/tools/safeStatus.js +43 -1
  140. package/dist/tools/safeViews.d.ts +262 -0
  141. package/dist/tools/safeViews.js +252 -0
  142. package/dist/tools/schemaDriftCheck.d.ts +46 -0
  143. package/dist/tools/schemaDriftCheck.js +80 -0
  144. package/dist/tools/taskLineage.d.ts +91 -0
  145. package/dist/tools/taskLineage.js +175 -0
  146. package/dist/tools/toolCatalog.d.ts +4 -3
  147. package/dist/tools/toolCatalog.js +39 -11
  148. package/dist/tools/toolRegistry.d.ts +61 -0
  149. package/dist/tools/toolRegistry.js +834 -0
  150. package/dist/tools/toolSearch.d.ts +110 -0
  151. package/dist/tools/toolSearch.js +331 -0
  152. package/dist/tools/toolUsageStats.d.ts +41 -0
  153. package/dist/tools/toolUsageStats.js +116 -0
  154. package/dist/tools/waitForTask.js +1 -0
  155. package/dist/version.d.ts +2 -2
  156. package/dist/version.js +2 -2
  157. package/dist/watcherStatus.d.ts +29 -0
  158. package/dist/watcherStatus.js +92 -1
  159. package/docs/chatgpt-usage.md +31 -0
  160. package/docs/control-center/README.md +9 -0
  161. package/package.json +3 -2
  162. package/scripts/checks/control-center-smoke.js +87 -0
  163. package/scripts/checks/control-smoke.js +2 -2
  164. package/scripts/checks/doctor-smoke.js +1 -0
  165. package/scripts/checks/lifecycle-smoke.js +13 -9
  166. package/scripts/checks/mcp-manifest-check.js +65 -6
  167. package/scripts/checks/mcp-smoke.js +60 -9
  168. package/scripts/checks/watcher-supervisor-smoke.js +2 -2
  169. package/src/agents/agentRouter.ts +149 -0
  170. package/src/config.ts +9 -3
  171. package/src/controlCenter.ts +215 -4
  172. package/src/direct/directSessionStore.ts +2 -0
  173. package/src/direct/directVerification.ts +7 -0
  174. package/src/doctor.ts +36 -1
  175. package/src/goal/acceptanceEngine.ts +160 -0
  176. package/src/goal/acceptanceTemplate.ts +121 -0
  177. package/src/goal/goalGraph.ts +225 -0
  178. package/src/goal/goalProgress.ts +301 -0
  179. package/src/goal/goalStatus.ts +234 -0
  180. package/src/goal/goalStore.ts +306 -0
  181. package/src/goal/handoffExport.ts +211 -0
  182. package/src/goal/subgoalSync.ts +82 -0
  183. package/src/goal/worktreeManager.ts +404 -0
  184. package/src/logging.ts +91 -0
  185. package/src/policy/projectPolicy.ts +344 -0
  186. package/src/release/releaseGate.ts +567 -0
  187. package/src/runner/postTaskCleanup.ts +154 -0
  188. package/src/runner/runTask.ts +49 -10
  189. package/src/security/discoveryTokenStore.ts +157 -0
  190. package/src/security/toolInvocationGuard.ts +251 -0
  191. package/src/smoke-test.ts +15 -7
  192. package/src/taskRuntime.ts +17 -0
  193. package/src/test/unit/acceptance-engine.test.ts +261 -0
  194. package/src/test/unit/agent-router.test.ts +342 -0
  195. package/src/test/unit/audit-checks.test.ts +567 -0
  196. package/src/test/unit/diagnose-task.test.ts +544 -0
  197. package/src/test/unit/discovery-token-store.test.ts +181 -0
  198. package/src/test/unit/evidence-pack.test.ts +142 -0
  199. package/src/test/unit/goal-graph.test.ts +347 -0
  200. package/src/test/unit/goal-progress.test.ts +538 -0
  201. package/src/test/unit/goal-status.test.ts +270 -0
  202. package/src/test/unit/goal-store.test.ts +318 -0
  203. package/src/test/unit/goal-subgoal-task.test.ts +72 -0
  204. package/src/test/unit/goal-tools-registry.test.ts +243 -0
  205. package/src/test/unit/handoff-export.test.ts +295 -0
  206. package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
  207. package/src/test/unit/logging.test.ts +177 -5
  208. package/src/test/unit/post-task-cleanup.test.ts +53 -0
  209. package/src/test/unit/project-policy-release-mode.test.ts +156 -0
  210. package/src/test/unit/reconcile-tasks.test.ts +551 -0
  211. package/src/test/unit/release-gate.test.ts +314 -0
  212. package/src/test/unit/run-task-loop.test.ts +425 -0
  213. package/src/test/unit/safe-views.test.ts +184 -0
  214. package/src/test/unit/schema-drift-check.test.ts +260 -0
  215. package/src/test/unit/subgoal-sync.test.ts +236 -0
  216. package/src/test/unit/tool-invocation-guard.test.ts +542 -0
  217. package/src/test/unit/tool-usage-stats.test.ts +384 -0
  218. package/src/test/unit/toolSearch.test.ts +756 -0
  219. package/src/test/unit/worktree-manager.test.ts +247 -0
  220. package/src/tools/auditTask.ts +831 -402
  221. package/src/tools/cancelTask.ts +1 -1
  222. package/src/tools/checkReleaseGate.ts +45 -0
  223. package/src/tools/createTask.ts +64 -6
  224. package/src/tools/diagnoseTask.ts +460 -0
  225. package/src/tools/discardWorktree.ts +42 -0
  226. package/src/tools/discoverTools.ts +51 -0
  227. package/src/tools/evidencePack.ts +205 -0
  228. package/src/tools/explainTool.ts +26 -0
  229. package/src/tools/getTaskSummary.ts +2 -1
  230. package/src/tools/goalSubgoalTask.ts +170 -0
  231. package/src/tools/healthCheck.ts +4 -1
  232. package/src/tools/invokeDiscoveredTool.ts +163 -0
  233. package/src/tools/listTasks.ts +16 -2
  234. package/src/tools/listWorkspace.ts +71 -71
  235. package/src/tools/mergeWorktree.ts +42 -0
  236. package/src/tools/recommendAgentForTask.ts +79 -0
  237. package/src/tools/reconcileTasks.ts +464 -0
  238. package/src/tools/registry.ts +1093 -73
  239. package/src/tools/releaseMode.ts +450 -0
  240. package/src/tools/runDirectVerificationBundle.ts +98 -0
  241. package/src/tools/runTaskLoop.ts +526 -0
  242. package/src/tools/runVerification.ts +8 -0
  243. package/src/tools/safeStatus.ts +73 -2
  244. package/src/tools/safeViews.ts +273 -0
  245. package/src/tools/schemaDriftCheck.ts +120 -0
  246. package/src/tools/taskLineage.ts +300 -0
  247. package/src/tools/toolCatalog.ts +40 -11
  248. package/src/tools/toolRegistry.ts +896 -0
  249. package/src/tools/toolSearch.ts +464 -0
  250. package/src/tools/toolUsageStats.ts +151 -0
  251. package/src/tools/waitForTask.ts +1 -0
  252. package/src/version.ts +2 -2
  253. package/src/watcherStatus.ts +97 -1
  254. package/ui/pages/dashboard.html +143 -2
@@ -0,0 +1,551 @@
1
+ import { describe, it, beforeEach, afterEach } from "node:test";
2
+ import { strict as assert } from "node:assert";
3
+ import {
4
+ mkdtempSync,
5
+ rmSync,
6
+ mkdirSync,
7
+ writeFileSync,
8
+ utimesSync,
9
+ readFileSync,
10
+ existsSync,
11
+ } from "node:fs";
12
+ import { join, dirname } from "node:path";
13
+ import { tmpdir } from "node:os";
14
+ import { reconcileTasks } from "../../tools/reconcileTasks.js";
15
+ import type { PatchWardenConfig } from "../../config.js";
16
+
17
+ // ── Test fixtures ──────────────────────────────────────────────────
18
+
19
+ function makeConfig(workspaceRoot: string): PatchWardenConfig {
20
+ return {
21
+ workspaceRoot,
22
+ plansDir: ".patchwarden/plans",
23
+ tasksDir: ".patchwarden/tasks",
24
+ assessmentsDir: ".patchwarden/assessments",
25
+ assessmentTtlSeconds: 3600,
26
+ agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
27
+ allowedTestCommands: ["npm test"],
28
+ repoAllowedTestCommands: {},
29
+ maxReadFileBytes: 200_000,
30
+ defaultTaskTimeoutSeconds: 900,
31
+ maxTaskTimeoutSeconds: 3600,
32
+ watcherStaleSeconds: 30,
33
+ directSessionsDir: ".patchwarden/direct-sessions",
34
+ directSessionTtlSeconds: 3600,
35
+ directMaxPatchBytes: 200_000,
36
+ directMaxFileBytes: 500_000,
37
+ };
38
+ }
39
+
40
+ const ALIVE_PID = process.pid;
41
+ const DEAD_PID = 999999;
42
+
43
+ function isoSecondsAgo(seconds: number): string {
44
+ return new Date(Date.now() - seconds * 1000).toISOString();
45
+ }
46
+
47
+ function ageFile(filePath: string, secondsAgo: number): void {
48
+ if (!existsSync(filePath)) return;
49
+ const targetSec = (Date.now() - secondsAgo * 1000) / 1000;
50
+ utimesSync(filePath, targetSec, targetSec);
51
+ }
52
+
53
+ interface TaskFixtureOptions {
54
+ taskId: string;
55
+ status?: string;
56
+ phase?: string;
57
+ heartbeatSecondsAgo?: number;
58
+ childPid?: number;
59
+ watcherInstanceId?: string | null;
60
+ taskStartedSecondsAgo?: number;
61
+ createdSecondsAgo?: number;
62
+ stdoutSecondsAgo?: number;
63
+ stdoutContent?: string;
64
+ stderrSecondsAgo?: number;
65
+ stderrContent?: string;
66
+ resultMd?: boolean;
67
+ testLog?: boolean;
68
+ gitDiff?: boolean;
69
+ }
70
+
71
+ describe("reconcileTasks", () => {
72
+ let tempDir: string;
73
+ let config: PatchWardenConfig;
74
+ let tasksDir: string;
75
+ let watcherHeartbeatPath: string;
76
+ let reconcileLogPath: string;
77
+
78
+ beforeEach(() => {
79
+ tempDir = mkdtempSync(join(tmpdir(), "pw-reconcile-"));
80
+ config = makeConfig(tempDir);
81
+ tasksDir = join(tempDir, ".patchwarden", "tasks");
82
+ mkdirSync(tasksDir, { recursive: true });
83
+ watcherHeartbeatPath = join(dirname(tasksDir), "watcher-heartbeat.json");
84
+ reconcileLogPath = join(dirname(tasksDir), "reconcile.log");
85
+ });
86
+
87
+ afterEach(() => {
88
+ rmSync(tempDir, { recursive: true, force: true });
89
+ });
90
+
91
+ function buildTask(opts: TaskFixtureOptions): string {
92
+ const taskId = opts.taskId;
93
+ const taskDir = join(tasksDir, taskId);
94
+ mkdirSync(taskDir, { recursive: true });
95
+
96
+ const statusSecondsAgo = opts.createdSecondsAgo ?? 600;
97
+ const status: Record<string, unknown> = {
98
+ task_id: taskId,
99
+ status: opts.status ?? "running",
100
+ phase: opts.phase ?? "executing_agent",
101
+ created_at: isoSecondsAgo(statusSecondsAgo),
102
+ updated_at: isoSecondsAgo(statusSecondsAgo),
103
+ };
104
+ if (opts.heartbeatSecondsAgo !== undefined) {
105
+ status.last_heartbeat_at = isoSecondsAgo(opts.heartbeatSecondsAgo);
106
+ }
107
+ writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
108
+
109
+ const runtime: Record<string, unknown> = {
110
+ phase: opts.phase ?? "executing_agent",
111
+ };
112
+ if (opts.heartbeatSecondsAgo !== undefined) {
113
+ runtime.last_heartbeat_at = isoSecondsAgo(opts.heartbeatSecondsAgo);
114
+ }
115
+ if (opts.childPid !== undefined) {
116
+ runtime.child_pid = opts.childPid;
117
+ }
118
+ if (opts.watcherInstanceId !== undefined) {
119
+ runtime.watcher_instance_id = opts.watcherInstanceId;
120
+ }
121
+ if (opts.taskStartedSecondsAgo !== undefined) {
122
+ runtime.task_started_at = isoSecondsAgo(opts.taskStartedSecondsAgo);
123
+ }
124
+ runtime.current_command = null;
125
+ writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(runtime, null, 2), "utf-8");
126
+
127
+ if (opts.stdoutContent !== undefined) {
128
+ const stdoutPath = join(taskDir, "stdout.log");
129
+ writeFileSync(stdoutPath, opts.stdoutContent, "utf-8");
130
+ if (opts.stdoutSecondsAgo !== undefined) ageFile(stdoutPath, opts.stdoutSecondsAgo);
131
+ }
132
+ if (opts.stderrContent !== undefined) {
133
+ const stderrPath = join(taskDir, "stderr.log");
134
+ writeFileSync(stderrPath, opts.stderrContent, "utf-8");
135
+ if (opts.stderrSecondsAgo !== undefined) ageFile(stderrPath, opts.stderrSecondsAgo);
136
+ }
137
+ if (opts.resultMd) {
138
+ writeFileSync(join(taskDir, "result.md"), "# Result\nTask completed.", "utf-8");
139
+ }
140
+ if (opts.testLog) {
141
+ writeFileSync(join(taskDir, "test.log"), "TAP version 13\n1..1\nok 1 test", "utf-8");
142
+ }
143
+ if (opts.gitDiff) {
144
+ writeFileSync(join(taskDir, "git.diff"), "diff --git a/foo b/foo\n", "utf-8");
145
+ }
146
+ return taskDir;
147
+ }
148
+
149
+ function writeWatcherHeartbeat(instanceId: string, secondsAgo = 5): void {
150
+ const heartbeat = {
151
+ last_heartbeat_at: isoSecondsAgo(secondsAgo),
152
+ pid: process.pid,
153
+ instance_id: instanceId,
154
+ launcher_pid: process.ppid ?? null,
155
+ };
156
+ writeFileSync(watcherHeartbeatPath, JSON.stringify(heartbeat, null, 2), "utf-8");
157
+ }
158
+
159
+ function readTaskStatus(taskId: string): Record<string, unknown> {
160
+ const statusFile = join(tasksDir, taskId, "status.json");
161
+ return JSON.parse(readFileSync(statusFile, "utf-8"));
162
+ }
163
+
164
+ // ── 1. report_only: never modifies task state ──
165
+ it("report_only mode never modifies status.json", () => {
166
+ buildTask({
167
+ taskId: "task-rpo-001",
168
+ status: "running",
169
+ phase: "executing_agent",
170
+ heartbeatSecondsAgo: 600, // stale
171
+ childPid: DEAD_PID, // dead
172
+ createdSecondsAgo: 2000,
173
+ stdoutContent: "old output\n",
174
+ stdoutSecondsAgo: 600,
175
+ stderrContent: "stderr\n",
176
+ stderrSecondsAgo: 600,
177
+ });
178
+
179
+ const result = reconcileTasks({ mode: "report_only" }, config);
180
+
181
+ assert.equal(result.mode, "report_only");
182
+ assert.equal(result.scanned, 1);
183
+ assert.equal(result.candidates, 1);
184
+ assert.equal(result.reconciled, 0); // no changes
185
+ assert.equal(result.reports.length, 1);
186
+
187
+ const report = result.reports[0];
188
+ assert.equal(report.task_id, "task-rpo-001");
189
+ assert.equal(report.action_taken, "left_unchanged");
190
+ assert.equal(report.previous_status, null);
191
+ assert.equal(report.new_status, null);
192
+
193
+ // status.json must be unchanged
194
+ const status = readTaskStatus("task-rpo-001");
195
+ assert.equal(status.status, "running");
196
+
197
+ // No backup file should exist
198
+ assert.ok(!existsSync(join(tasksDir, "task-rpo-001", "status.json.bak")));
199
+ // No reconcile.log should be written
200
+ assert.ok(!existsSync(reconcileLogPath));
201
+ assert.equal(result.reconcile_log_path, null);
202
+ });
203
+
204
+ // ── 2. safe_fix: only high-confidence diagnoses get applied ──
205
+ it("safe_fix applies mark_failed_stale for high-confidence stale_running", () => {
206
+ buildTask({
207
+ taskId: "task-sf-001",
208
+ status: "running",
209
+ phase: "executing_agent",
210
+ heartbeatSecondsAgo: 600,
211
+ childPid: DEAD_PID,
212
+ createdSecondsAgo: 2000,
213
+ stdoutContent: "old output\n",
214
+ stdoutSecondsAgo: 600,
215
+ stderrContent: "stderr\n",
216
+ stderrSecondsAgo: 600,
217
+ });
218
+ // No watcher heartbeat → task has no watcher_instance_id → no_runtime_record
219
+ // → stale_running with high confidence
220
+
221
+ const result = reconcileTasks({ mode: "safe_fix" }, config);
222
+
223
+ assert.equal(result.mode, "safe_fix");
224
+ assert.equal(result.reconciled, 1);
225
+ assert.equal(result.skipped_low_confidence, 0);
226
+ assert.equal(result.skipped_active_watcher, 0);
227
+
228
+ const report = result.reports[0];
229
+ assert.equal(report.action_taken, "marked_failed_stale");
230
+ assert.equal(report.previous_status, "running");
231
+ assert.equal(report.new_status, "failed_stale");
232
+ assert.equal(report.applied_by, "reconcile_tasks");
233
+ assert.ok(report.applied_at !== null);
234
+
235
+ // status.json should reflect the new status
236
+ const status = readTaskStatus("task-sf-001");
237
+ assert.equal(status.status, "failed_stale");
238
+ assert.equal(status.previous_status, "running");
239
+ assert.equal(status.legacy_status, undefined); // only set for done_by_agent
240
+
241
+ // Audit fields should be present
242
+ const diagnosis = status.diagnosis as Record<string, unknown>;
243
+ assert.equal(diagnosis.type, "stale_running");
244
+ assert.equal(diagnosis.confidence, "high");
245
+ assert.equal(diagnosis.applied_by, "reconcile_tasks");
246
+ assert.ok(diagnosis.applied_at !== null);
247
+ assert.ok(Array.isArray(diagnosis.reasons));
248
+ });
249
+
250
+ // ── 3. safe_fix: skips medium-confidence diagnoses ──
251
+ it("safe_fix skips medium-confidence possibly_stale_running tasks", () => {
252
+ buildTask({
253
+ taskId: "task-sf-skip-001",
254
+ status: "running",
255
+ phase: "executing_agent",
256
+ heartbeatSecondsAgo: 200, // possibly stale
257
+ childPid: ALIVE_PID, // alive
258
+ watcherInstanceId: "watcher-A",
259
+ createdSecondsAgo: 2000,
260
+ stdoutContent: "output\n",
261
+ stdoutSecondsAgo: 400, // stale
262
+ stderrContent: "stderr\n",
263
+ stderrSecondsAgo: 400,
264
+ });
265
+ writeWatcherHeartbeat("watcher-A", 5);
266
+
267
+ const result = reconcileTasks({ mode: "safe_fix" }, config);
268
+
269
+ assert.equal(result.reconciled, 0);
270
+ assert.equal(result.skipped_low_confidence, 1);
271
+ assert.equal(result.skipped_active_watcher, 0);
272
+
273
+ const report = result.reports[0];
274
+ assert.equal(report.action_taken, "left_unchanged");
275
+ // status.json unchanged
276
+ const status = readTaskStatus("task-sf-skip-001");
277
+ assert.equal(status.status, "running");
278
+ assert.ok(!existsSync(join(tasksDir, "task-sf-skip-001", "status.json.bak")));
279
+ });
280
+
281
+ // ── 4. safe_fix: skips tasks still owned by an active watcher ──
282
+ it("safe_fix skips tasks owned by an active watcher", () => {
283
+ buildTask({
284
+ taskId: "task-sf-owned-001",
285
+ status: "running",
286
+ phase: "executing_agent",
287
+ heartbeatSecondsAgo: 600, // stale
288
+ childPid: DEAD_PID, // dead
289
+ watcherInstanceId: "watcher-A", // matches current watcher
290
+ createdSecondsAgo: 2000,
291
+ stdoutContent: "old output\n",
292
+ stdoutSecondsAgo: 600,
293
+ stderrContent: "stderr\n",
294
+ stderrSecondsAgo: 600,
295
+ });
296
+ writeWatcherHeartbeat("watcher-A", 5);
297
+
298
+ const result = reconcileTasks({ mode: "safe_fix" }, config);
299
+
300
+ assert.equal(result.reconciled, 0);
301
+ assert.equal(result.skipped_active_watcher, 1);
302
+
303
+ const report = result.reports[0];
304
+ assert.equal(report.action_taken, "left_unchanged");
305
+ assert.ok(report.reasons.some((r) => r.includes("active watcher")));
306
+
307
+ // status.json unchanged
308
+ const status = readTaskStatus("task-sf-owned-001");
309
+ assert.equal(status.status, "running");
310
+ assert.ok(!existsSync(join(tasksDir, "task-sf-owned-001", "status.json.bak")));
311
+ });
312
+
313
+ // ── 5. safe_fix: writes status.json.bak backup ──
314
+ it("safe_fix writes status.json.bak with original content before changing", () => {
315
+ buildTask({
316
+ taskId: "task-sf-bak-001",
317
+ status: "running",
318
+ phase: "executing_agent",
319
+ heartbeatSecondsAgo: 600,
320
+ childPid: DEAD_PID,
321
+ createdSecondsAgo: 2000,
322
+ stdoutContent: "old output\n",
323
+ stdoutSecondsAgo: 600,
324
+ stderrContent: "stderr\n",
325
+ stderrSecondsAgo: 600,
326
+ });
327
+
328
+ const originalStatusRaw = readFileSync(join(tasksDir, "task-sf-bak-001", "status.json"), "utf-8");
329
+
330
+ reconcileTasks({ mode: "safe_fix" }, config);
331
+
332
+ const backupPath = join(tasksDir, "task-sf-bak-001", "status.json.bak");
333
+ assert.ok(existsSync(backupPath), "status.json.bak should exist after safe_fix");
334
+
335
+ const backupContent = readFileSync(backupPath, "utf-8");
336
+ assert.equal(backupContent, originalStatusRaw, "backup must match original status.json content");
337
+ });
338
+
339
+ // ── 6. safe_fix: writes reconcile.log with append semantics ──
340
+ it("safe_fix writes reconcile.log at .patchwarden root", () => {
341
+ buildTask({
342
+ taskId: "task-sf-log-001",
343
+ status: "running",
344
+ phase: "executing_agent",
345
+ heartbeatSecondsAgo: 600,
346
+ childPid: DEAD_PID,
347
+ createdSecondsAgo: 2000,
348
+ stdoutContent: "old output\n",
349
+ stdoutSecondsAgo: 600,
350
+ stderrContent: "stderr\n",
351
+ stderrSecondsAgo: 600,
352
+ });
353
+
354
+ const result = reconcileTasks({ mode: "safe_fix" }, config);
355
+
356
+ assert.ok(result.reconcile_log_path !== null);
357
+ assert.ok(existsSync(reconcileLogPath), "reconcile.log should exist");
358
+
359
+ const logContent = readFileSync(reconcileLogPath, "utf-8");
360
+ const logLines = logContent.trim().split("\n");
361
+ assert.ok(logLines.length >= 1);
362
+
363
+ const logEntry = JSON.parse(logLines[0]);
364
+ assert.equal(logEntry.task_id, "task-sf-log-001");
365
+ assert.equal(logEntry.previous_status, "running");
366
+ assert.equal(logEntry.new_status, "failed_stale");
367
+ assert.equal(logEntry.diagnosis, "stale_running");
368
+ assert.equal(logEntry.confidence, "high");
369
+ assert.equal(logEntry.applied_by, "reconcile_tasks");
370
+ });
371
+
372
+ // ── 7. safe_fix: appends to existing reconcile.log (does not overwrite) ──
373
+ it("safe_fix appends to existing reconcile.log without overwriting", () => {
374
+ // Pre-existing log content
375
+ mkdirSync(dirname(reconcileLogPath), { recursive: true });
376
+ writeFileSync(reconcileLogPath, JSON.stringify({
377
+ timestamp: isoSecondsAgo(3600),
378
+ task_id: "prior-task",
379
+ previous_status: "running",
380
+ new_status: "failed_stale",
381
+ }) + "\n", "utf-8");
382
+
383
+ buildTask({
384
+ taskId: "task-sf-append-001",
385
+ status: "running",
386
+ phase: "executing_agent",
387
+ heartbeatSecondsAgo: 600,
388
+ childPid: DEAD_PID,
389
+ createdSecondsAgo: 2000,
390
+ stdoutContent: "old output\n",
391
+ stdoutSecondsAgo: 600,
392
+ stderrContent: "stderr\n",
393
+ stderrSecondsAgo: 600,
394
+ });
395
+
396
+ reconcileTasks({ mode: "safe_fix" }, config);
397
+
398
+ const logContent = readFileSync(reconcileLogPath, "utf-8");
399
+ const logLines = logContent.trim().split("\n");
400
+ assert.ok(logLines.length >= 2, "log should contain prior + new entries");
401
+
402
+ const firstEntry = JSON.parse(logLines[0]);
403
+ assert.equal(firstEntry.task_id, "prior-task");
404
+
405
+ const newEntry = logLines
406
+ .map((l) => JSON.parse(l))
407
+ .find((e: Record<string, unknown>) => e.task_id === "task-sf-append-001");
408
+ assert.ok(newEntry, "new task entry should be appended");
409
+ assert.equal(newEntry.new_status, "failed_stale");
410
+ });
411
+
412
+ // ── 8. safe_fix: done_candidate → done_by_agent with acceptance_status=pending ──
413
+ it("safe_fix marks done_by_agent and sets acceptance_status=pending + legacy_status=done", () => {
414
+ buildTask({
415
+ taskId: "task-sf-donecand-001",
416
+ status: "running",
417
+ phase: "executing_agent",
418
+ heartbeatSecondsAgo: 600,
419
+ childPid: DEAD_PID,
420
+ createdSecondsAgo: 2000,
421
+ stdoutContent: "done\n",
422
+ stdoutSecondsAgo: 600,
423
+ stderrContent: "stderr\n",
424
+ stderrSecondsAgo: 600,
425
+ resultMd: true,
426
+ testLog: true,
427
+ gitDiff: true,
428
+ });
429
+ // No watcher heartbeat → orphaned_running, but done_candidate takes priority
430
+
431
+ const result = reconcileTasks({ mode: "safe_fix" }, config);
432
+
433
+ assert.equal(result.reconciled, 1);
434
+
435
+ const report = result.reports[0];
436
+ assert.equal(report.action_taken, "marked_done_by_agent");
437
+ assert.equal(report.new_status, "done_by_agent");
438
+
439
+ const status = readTaskStatus("task-sf-donecand-001");
440
+ assert.equal(status.status, "done_by_agent");
441
+ assert.equal(status.acceptance_status, "pending");
442
+ assert.equal(status.legacy_status, "done");
443
+ assert.equal(status.previous_status, "running");
444
+ });
445
+
446
+ // ── 9. safe_fix: orphaned_running → orphaned ──
447
+ it("safe_fix marks orphaned when watcher instance mismatches", () => {
448
+ buildTask({
449
+ taskId: "task-sf-orphan-001",
450
+ status: "running",
451
+ phase: "executing_agent",
452
+ heartbeatSecondsAgo: 30, // fresh
453
+ childPid: ALIVE_PID,
454
+ watcherInstanceId: "watcher-old", // mismatch
455
+ createdSecondsAgo: 2000,
456
+ stdoutContent: "output\n",
457
+ stdoutSecondsAgo: 10,
458
+ stderrContent: "stderr\n",
459
+ stderrSecondsAgo: 10,
460
+ });
461
+ writeWatcherHeartbeat("watcher-new", 5);
462
+
463
+ const result = reconcileTasks({ mode: "safe_fix" }, config);
464
+
465
+ // High confidence orphaned_running, not owned by current watcher
466
+ assert.equal(result.reconciled, 1);
467
+
468
+ const report = result.reports[0];
469
+ assert.equal(report.action_taken, "marked_orphaned");
470
+ assert.equal(report.new_status, "orphaned");
471
+
472
+ const status = readTaskStatus("task-sf-orphan-001");
473
+ assert.equal(status.status, "orphaned");
474
+ assert.equal(status.previous_status, "running");
475
+ });
476
+
477
+ // ── 10. Age filter: tasks younger than max_age_minutes are skipped ──
478
+ it("skips tasks younger than max_age_minutes", () => {
479
+ buildTask({
480
+ taskId: "task-young-001",
481
+ status: "running",
482
+ phase: "executing_agent",
483
+ heartbeatSecondsAgo: 10, // fresh — active
484
+ childPid: ALIVE_PID,
485
+ watcherInstanceId: "watcher-A",
486
+ createdSecondsAgo: 60, // only 1 minute old
487
+ stdoutContent: "running\n",
488
+ stdoutSecondsAgo: 5,
489
+ stderrContent: "stderr\n",
490
+ stderrSecondsAgo: 5,
491
+ });
492
+ writeWatcherHeartbeat("watcher-A", 3);
493
+
494
+ const result = reconcileTasks({ mode: "report_only", max_age_minutes: 30 }, config);
495
+
496
+ assert.equal(result.scanned, 1);
497
+ assert.equal(result.candidates, 0); // too young
498
+ assert.equal(result.reports.length, 0);
499
+ });
500
+
501
+ // ── 11. Non-running tasks are not candidates ──
502
+ it("does not consider terminal tasks as candidates", () => {
503
+ buildTask({
504
+ taskId: "task-done-001",
505
+ status: "done",
506
+ phase: "completed",
507
+ createdSecondsAgo: 1000,
508
+ });
509
+ buildTask({
510
+ taskId: "task-failed-001",
511
+ status: "failed",
512
+ phase: "failed",
513
+ createdSecondsAgo: 1000,
514
+ });
515
+
516
+ const result = reconcileTasks({ mode: "report_only" }, config);
517
+
518
+ assert.equal(result.scanned, 2);
519
+ assert.equal(result.candidates, 0);
520
+ });
521
+
522
+ // ── 12. Empty tasks dir returns zero-scanned result ──
523
+ it("returns empty result when tasks dir is empty", () => {
524
+ const result = reconcileTasks({ mode: "report_only" }, config);
525
+ assert.equal(result.scanned, 0);
526
+ assert.equal(result.candidates, 0);
527
+ assert.equal(result.reports.length, 0);
528
+ assert.equal(result.reconcile_log_path, null);
529
+ });
530
+
531
+ // ── 13. No temp file left behind after successful atomic write ──
532
+ it("does not leave status.json.tmp behind after safe_fix", () => {
533
+ buildTask({
534
+ taskId: "task-sf-notmp-001",
535
+ status: "running",
536
+ phase: "executing_agent",
537
+ heartbeatSecondsAgo: 600,
538
+ childPid: DEAD_PID,
539
+ createdSecondsAgo: 2000,
540
+ stdoutContent: "old output\n",
541
+ stdoutSecondsAgo: 600,
542
+ stderrContent: "stderr\n",
543
+ stderrSecondsAgo: 600,
544
+ });
545
+
546
+ reconcileTasks({ mode: "safe_fix" }, config);
547
+
548
+ const tmpPath = join(tasksDir, "task-sf-notmp-001", "status.json.tmp");
549
+ assert.ok(!existsSync(tmpPath), "status.json.tmp should not exist after rename");
550
+ });
551
+ });