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