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.
- package/README.en.md +54 -20
- package/README.md +48 -21
- package/dist/agents/agentRouter.d.ts +40 -0
- package/dist/agents/agentRouter.js +95 -0
- package/dist/config.d.ts +1 -1
- package/dist/config.js +6 -2
- package/dist/controlCenter.js +212 -3
- package/dist/direct/directSessionStore.d.ts +2 -0
- package/dist/direct/directVerification.js +7 -0
- package/dist/doctor.js +32 -1
- package/dist/goal/acceptanceEngine.d.ts +62 -0
- package/dist/goal/acceptanceEngine.js +103 -0
- package/dist/goal/acceptanceTemplate.d.ts +10 -0
- package/dist/goal/acceptanceTemplate.js +104 -0
- package/dist/goal/goalGraph.d.ts +58 -0
- package/dist/goal/goalGraph.js +189 -0
- package/dist/goal/goalProgress.d.ts +81 -0
- package/dist/goal/goalProgress.js +216 -0
- package/dist/goal/goalStatus.d.ts +60 -0
- package/dist/goal/goalStatus.js +137 -0
- package/dist/goal/goalStore.d.ts +79 -0
- package/dist/goal/goalStore.js +211 -0
- package/dist/goal/handoffExport.d.ts +34 -0
- package/dist/goal/handoffExport.js +183 -0
- package/dist/goal/subgoalSync.d.ts +40 -0
- package/dist/goal/subgoalSync.js +72 -0
- package/dist/goal/worktreeManager.d.ts +96 -0
- package/dist/goal/worktreeManager.js +292 -0
- package/dist/logging.d.ts +44 -0
- package/dist/logging.js +68 -0
- package/dist/policy/projectPolicy.d.ts +55 -0
- package/dist/policy/projectPolicy.js +286 -0
- package/dist/release/releaseGate.d.ts +99 -0
- package/dist/release/releaseGate.js +475 -0
- package/dist/runner/postTaskCleanup.d.ts +13 -0
- package/dist/runner/postTaskCleanup.js +147 -0
- package/dist/runner/runTask.js +50 -10
- package/dist/security/discoveryTokenStore.d.ts +62 -0
- package/dist/security/discoveryTokenStore.js +100 -0
- package/dist/security/toolInvocationGuard.d.ts +35 -0
- package/dist/security/toolInvocationGuard.js +153 -0
- package/dist/smoke-test.js +18 -10
- package/dist/taskRuntime.d.ts +17 -0
- package/dist/test/unit/acceptance-engine.test.d.ts +1 -0
- package/dist/test/unit/acceptance-engine.test.js +228 -0
- package/dist/test/unit/agent-router.test.d.ts +1 -0
- package/dist/test/unit/agent-router.test.js +287 -0
- package/dist/test/unit/audit-checks.test.d.ts +1 -0
- package/dist/test/unit/audit-checks.test.js +350 -0
- package/dist/test/unit/diagnose-task.test.d.ts +1 -0
- package/dist/test/unit/diagnose-task.test.js +457 -0
- package/dist/test/unit/discovery-token-store.test.d.ts +1 -0
- package/dist/test/unit/discovery-token-store.test.js +139 -0
- package/dist/test/unit/evidence-pack.test.d.ts +1 -0
- package/dist/test/unit/evidence-pack.test.js +130 -0
- package/dist/test/unit/goal-graph.test.d.ts +1 -0
- package/dist/test/unit/goal-graph.test.js +298 -0
- package/dist/test/unit/goal-progress.test.d.ts +1 -0
- package/dist/test/unit/goal-progress.test.js +381 -0
- package/dist/test/unit/goal-status.test.d.ts +1 -0
- package/dist/test/unit/goal-status.test.js +215 -0
- package/dist/test/unit/goal-store.test.d.ts +1 -0
- package/dist/test/unit/goal-store.test.js +253 -0
- package/dist/test/unit/goal-subgoal-task.test.d.ts +1 -0
- package/dist/test/unit/goal-subgoal-task.test.js +55 -0
- package/dist/test/unit/goal-tools-registry.test.d.ts +1 -0
- package/dist/test/unit/goal-tools-registry.test.js +190 -0
- package/dist/test/unit/handoff-export.test.d.ts +1 -0
- package/dist/test/unit/handoff-export.test.js +263 -0
- package/dist/test/unit/invoke-discovered-tool.test.d.ts +1 -0
- package/dist/test/unit/invoke-discovered-tool.test.js +167 -0
- package/dist/test/unit/logging.test.js +127 -5
- package/dist/test/unit/post-task-cleanup.test.d.ts +1 -0
- package/dist/test/unit/post-task-cleanup.test.js +48 -0
- package/dist/test/unit/project-policy-release-mode.test.d.ts +1 -0
- package/dist/test/unit/project-policy-release-mode.test.js +125 -0
- package/dist/test/unit/reconcile-tasks.test.d.ts +1 -0
- package/dist/test/unit/reconcile-tasks.test.js +456 -0
- package/dist/test/unit/release-gate.test.d.ts +1 -0
- package/dist/test/unit/release-gate.test.js +242 -0
- package/dist/test/unit/run-task-loop.test.d.ts +1 -0
- package/dist/test/unit/run-task-loop.test.js +380 -0
- package/dist/test/unit/safe-views.test.d.ts +1 -0
- package/dist/test/unit/safe-views.test.js +171 -0
- package/dist/test/unit/schema-drift-check.test.d.ts +1 -0
- package/dist/test/unit/schema-drift-check.test.js +176 -0
- package/dist/test/unit/subgoal-sync.test.d.ts +1 -0
- package/dist/test/unit/subgoal-sync.test.js +183 -0
- package/dist/test/unit/tool-invocation-guard.test.d.ts +1 -0
- package/dist/test/unit/tool-invocation-guard.test.js +432 -0
- package/dist/test/unit/tool-usage-stats.test.d.ts +1 -0
- package/dist/test/unit/tool-usage-stats.test.js +300 -0
- package/dist/test/unit/toolSearch.test.d.ts +1 -0
- package/dist/test/unit/toolSearch.test.js +571 -0
- package/dist/test/unit/worktree-manager.test.d.ts +1 -0
- package/dist/test/unit/worktree-manager.test.js +176 -0
- package/dist/tools/auditTask.d.ts +103 -1
- package/dist/tools/auditTask.js +461 -8
- package/dist/tools/cancelTask.js +1 -1
- package/dist/tools/checkReleaseGate.d.ts +21 -0
- package/dist/tools/checkReleaseGate.js +22 -0
- package/dist/tools/createTask.d.ts +18 -2
- package/dist/tools/createTask.js +29 -1
- package/dist/tools/diagnoseTask.d.ts +45 -0
- package/dist/tools/diagnoseTask.js +347 -0
- package/dist/tools/discardWorktree.d.ts +24 -0
- package/dist/tools/discardWorktree.js +27 -0
- package/dist/tools/discoverTools.d.ts +11 -0
- package/dist/tools/discoverTools.js +39 -0
- package/dist/tools/evidencePack.d.ts +39 -0
- package/dist/tools/evidencePack.js +168 -0
- package/dist/tools/explainTool.d.ts +11 -0
- package/dist/tools/explainTool.js +21 -0
- package/dist/tools/getTaskSummary.js +2 -1
- package/dist/tools/goalSubgoalTask.d.ts +51 -0
- package/dist/tools/goalSubgoalTask.js +110 -0
- package/dist/tools/healthCheck.d.ts +1 -0
- package/dist/tools/healthCheck.js +5 -1
- package/dist/tools/invokeDiscoveredTool.d.ts +37 -0
- package/dist/tools/invokeDiscoveredTool.js +112 -0
- package/dist/tools/listTasks.d.ts +3 -1
- package/dist/tools/listTasks.js +14 -1
- package/dist/tools/mergeWorktree.d.ts +24 -0
- package/dist/tools/mergeWorktree.js +27 -0
- package/dist/tools/recommendAgentForTask.d.ts +19 -0
- package/dist/tools/recommendAgentForTask.js +56 -0
- package/dist/tools/reconcileTasks.d.ts +41 -0
- package/dist/tools/reconcileTasks.js +352 -0
- package/dist/tools/registry.js +926 -2
- package/dist/tools/releaseMode.d.ts +50 -0
- package/dist/tools/releaseMode.js +370 -0
- package/dist/tools/runDirectVerificationBundle.d.ts +26 -0
- package/dist/tools/runDirectVerificationBundle.js +64 -0
- package/dist/tools/runTaskLoop.d.ts +57 -0
- package/dist/tools/runTaskLoop.js +417 -0
- package/dist/tools/runVerification.d.ts +4 -0
- package/dist/tools/runVerification.js +4 -0
- package/dist/tools/safeStatus.d.ts +31 -1
- package/dist/tools/safeStatus.js +43 -1
- package/dist/tools/safeViews.d.ts +262 -0
- package/dist/tools/safeViews.js +252 -0
- package/dist/tools/schemaDriftCheck.d.ts +46 -0
- package/dist/tools/schemaDriftCheck.js +80 -0
- package/dist/tools/taskLineage.d.ts +91 -0
- package/dist/tools/taskLineage.js +175 -0
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +39 -11
- package/dist/tools/toolRegistry.d.ts +61 -0
- package/dist/tools/toolRegistry.js +834 -0
- package/dist/tools/toolSearch.d.ts +110 -0
- package/dist/tools/toolSearch.js +331 -0
- package/dist/tools/toolUsageStats.d.ts +41 -0
- package/dist/tools/toolUsageStats.js +116 -0
- package/dist/tools/waitForTask.js +1 -0
- package/dist/version.d.ts +2 -2
- package/dist/version.js +2 -2
- package/dist/watcherStatus.d.ts +29 -0
- package/dist/watcherStatus.js +92 -1
- package/docs/chatgpt-usage.md +31 -0
- package/docs/control-center/README.md +9 -0
- package/package.json +3 -2
- package/scripts/checks/control-center-smoke.js +87 -0
- package/scripts/checks/control-smoke.js +2 -2
- package/scripts/checks/doctor-smoke.js +1 -0
- package/scripts/checks/lifecycle-smoke.js +13 -9
- package/scripts/checks/mcp-manifest-check.js +65 -6
- package/scripts/checks/mcp-smoke.js +60 -9
- package/scripts/checks/watcher-supervisor-smoke.js +2 -2
- package/src/agents/agentRouter.ts +149 -0
- package/src/config.ts +9 -3
- package/src/controlCenter.ts +215 -4
- package/src/direct/directSessionStore.ts +2 -0
- package/src/direct/directVerification.ts +7 -0
- package/src/doctor.ts +36 -1
- package/src/goal/acceptanceEngine.ts +160 -0
- package/src/goal/acceptanceTemplate.ts +121 -0
- package/src/goal/goalGraph.ts +225 -0
- package/src/goal/goalProgress.ts +301 -0
- package/src/goal/goalStatus.ts +234 -0
- package/src/goal/goalStore.ts +306 -0
- package/src/goal/handoffExport.ts +211 -0
- package/src/goal/subgoalSync.ts +82 -0
- package/src/goal/worktreeManager.ts +404 -0
- package/src/logging.ts +91 -0
- package/src/policy/projectPolicy.ts +344 -0
- package/src/release/releaseGate.ts +567 -0
- package/src/runner/postTaskCleanup.ts +154 -0
- package/src/runner/runTask.ts +49 -10
- package/src/security/discoveryTokenStore.ts +157 -0
- package/src/security/toolInvocationGuard.ts +251 -0
- package/src/smoke-test.ts +15 -7
- package/src/taskRuntime.ts +17 -0
- package/src/test/unit/acceptance-engine.test.ts +261 -0
- package/src/test/unit/agent-router.test.ts +342 -0
- package/src/test/unit/audit-checks.test.ts +567 -0
- package/src/test/unit/diagnose-task.test.ts +544 -0
- package/src/test/unit/discovery-token-store.test.ts +181 -0
- package/src/test/unit/evidence-pack.test.ts +142 -0
- package/src/test/unit/goal-graph.test.ts +347 -0
- package/src/test/unit/goal-progress.test.ts +538 -0
- package/src/test/unit/goal-status.test.ts +270 -0
- package/src/test/unit/goal-store.test.ts +318 -0
- package/src/test/unit/goal-subgoal-task.test.ts +72 -0
- package/src/test/unit/goal-tools-registry.test.ts +243 -0
- package/src/test/unit/handoff-export.test.ts +295 -0
- package/src/test/unit/invoke-discovered-tool.test.ts +216 -0
- package/src/test/unit/logging.test.ts +177 -5
- package/src/test/unit/post-task-cleanup.test.ts +53 -0
- package/src/test/unit/project-policy-release-mode.test.ts +156 -0
- package/src/test/unit/reconcile-tasks.test.ts +551 -0
- package/src/test/unit/release-gate.test.ts +314 -0
- package/src/test/unit/run-task-loop.test.ts +425 -0
- package/src/test/unit/safe-views.test.ts +184 -0
- package/src/test/unit/schema-drift-check.test.ts +260 -0
- package/src/test/unit/subgoal-sync.test.ts +236 -0
- package/src/test/unit/tool-invocation-guard.test.ts +542 -0
- package/src/test/unit/tool-usage-stats.test.ts +384 -0
- package/src/test/unit/toolSearch.test.ts +756 -0
- package/src/test/unit/worktree-manager.test.ts +247 -0
- package/src/tools/auditTask.ts +831 -402
- package/src/tools/cancelTask.ts +1 -1
- package/src/tools/checkReleaseGate.ts +45 -0
- package/src/tools/createTask.ts +64 -6
- package/src/tools/diagnoseTask.ts +460 -0
- package/src/tools/discardWorktree.ts +42 -0
- package/src/tools/discoverTools.ts +51 -0
- package/src/tools/evidencePack.ts +205 -0
- package/src/tools/explainTool.ts +26 -0
- package/src/tools/getTaskSummary.ts +2 -1
- package/src/tools/goalSubgoalTask.ts +170 -0
- package/src/tools/healthCheck.ts +4 -1
- package/src/tools/invokeDiscoveredTool.ts +163 -0
- package/src/tools/listTasks.ts +16 -2
- package/src/tools/listWorkspace.ts +71 -71
- package/src/tools/mergeWorktree.ts +42 -0
- package/src/tools/recommendAgentForTask.ts +79 -0
- package/src/tools/reconcileTasks.ts +464 -0
- package/src/tools/registry.ts +1093 -73
- package/src/tools/releaseMode.ts +450 -0
- package/src/tools/runDirectVerificationBundle.ts +98 -0
- package/src/tools/runTaskLoop.ts +526 -0
- package/src/tools/runVerification.ts +8 -0
- package/src/tools/safeStatus.ts +73 -2
- package/src/tools/safeViews.ts +273 -0
- package/src/tools/schemaDriftCheck.ts +120 -0
- package/src/tools/taskLineage.ts +300 -0
- package/src/tools/toolCatalog.ts +40 -11
- package/src/tools/toolRegistry.ts +896 -0
- package/src/tools/toolSearch.ts +464 -0
- package/src/tools/toolUsageStats.ts +151 -0
- package/src/tools/waitForTask.ts +1 -0
- package/src/version.ts +2 -2
- package/src/watcherStatus.ts +97 -1
- package/ui/pages/dashboard.html +143 -2
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, it, beforeEach, afterEach } from "node:test";
|
|
2
|
+
import { strict as assert } from "node:assert";
|
|
3
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { tmpdir } from "node:os";
|
|
6
|
+
import { reloadConfig } from "../../config.js";
|
|
7
|
+
import { getProjectPolicySummary } from "../../policy/projectPolicy.js";
|
|
8
|
+
import { releaseCleanup, releasePrepare, releaseVerify } from "../../tools/releaseMode.js";
|
|
9
|
+
let tempDir;
|
|
10
|
+
let repoPath;
|
|
11
|
+
let prevConfigEnv;
|
|
12
|
+
function writeConfig(allowedTestCommands = ["npm test", "npm run build"]) {
|
|
13
|
+
const configPath = join(tempDir, "patchwarden.config.json");
|
|
14
|
+
writeFileSync(configPath, JSON.stringify({
|
|
15
|
+
workspaceRoot: tempDir,
|
|
16
|
+
plansDir: ".patchwarden/plans",
|
|
17
|
+
tasksDir: ".patchwarden/tasks",
|
|
18
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
19
|
+
allowedTestCommands,
|
|
20
|
+
agents: {},
|
|
21
|
+
defaultTaskTimeoutSeconds: 30,
|
|
22
|
+
maxTaskTimeoutSeconds: 120,
|
|
23
|
+
}), "utf-8");
|
|
24
|
+
prevConfigEnv = process.env.PATCHWARDEN_CONFIG;
|
|
25
|
+
process.env.PATCHWARDEN_CONFIG = configPath;
|
|
26
|
+
reloadConfig(configPath);
|
|
27
|
+
}
|
|
28
|
+
function writePackage(version = "1.3.0") {
|
|
29
|
+
writeFileSync(join(repoPath, "package.json"), JSON.stringify({
|
|
30
|
+
name: "patchwarden-test",
|
|
31
|
+
version,
|
|
32
|
+
repository: { type: "git", url: "git+https://github.com/example/patchwarden-test.git" },
|
|
33
|
+
}), "utf-8");
|
|
34
|
+
}
|
|
35
|
+
describe("project policy and release mode", () => {
|
|
36
|
+
beforeEach(() => {
|
|
37
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-policy-"));
|
|
38
|
+
repoPath = join(tempDir, "repo");
|
|
39
|
+
mkdirSync(repoPath, { recursive: true });
|
|
40
|
+
writePackage();
|
|
41
|
+
writeConfig();
|
|
42
|
+
});
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
if (prevConfigEnv === undefined) {
|
|
45
|
+
delete process.env.PATCHWARDEN_CONFIG;
|
|
46
|
+
}
|
|
47
|
+
else {
|
|
48
|
+
process.env.PATCHWARDEN_CONFIG = prevConfigEnv;
|
|
49
|
+
}
|
|
50
|
+
reloadConfig();
|
|
51
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
it("returns safe defaults when project-policy.json is missing", () => {
|
|
54
|
+
const summary = getProjectPolicySummary(repoPath);
|
|
55
|
+
assert.equal(summary.exists, false);
|
|
56
|
+
assert.equal(summary.valid, true);
|
|
57
|
+
assert.equal(summary.effective_policy.release_mode.version_source, "package.json");
|
|
58
|
+
assert.equal(summary.release_readiness.version, "1.3.0");
|
|
59
|
+
});
|
|
60
|
+
it("parses BOM-prefixed policy JSON and reports allowlist issues without granting permission", () => {
|
|
61
|
+
mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
|
|
62
|
+
writeFileSync(join(repoPath, ".patchwarden", "project-policy.json"), "\uFEFF" + JSON.stringify({
|
|
63
|
+
allowed_commands: ["npm run build", "npm publish"],
|
|
64
|
+
release_mode: {
|
|
65
|
+
version_source: "package.json",
|
|
66
|
+
required_commands: ["npm run build", "not-allowed"],
|
|
67
|
+
},
|
|
68
|
+
}), "utf-8");
|
|
69
|
+
const summary = getProjectPolicySummary(repoPath);
|
|
70
|
+
assert.equal(summary.exists, true);
|
|
71
|
+
assert.equal(summary.valid, false);
|
|
72
|
+
assert.ok(summary.issues.some((issue) => issue.code === "high_risk_command"));
|
|
73
|
+
assert.ok(summary.issues.some((issue) => issue.code === "command_not_allowlisted"));
|
|
74
|
+
});
|
|
75
|
+
it("rejects unsafe path patterns and sensitive protected paths block cleanup", () => {
|
|
76
|
+
mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
|
|
77
|
+
writeFileSync(join(repoPath, ".patchwarden", "project-policy.json"), JSON.stringify({
|
|
78
|
+
auto_cleanup: { enabled: true, patterns: ["../escape", ".git", "release_packages"], exclude: [] },
|
|
79
|
+
protected_paths: [".env", "release_packages"],
|
|
80
|
+
}), "utf-8");
|
|
81
|
+
mkdirSync(join(repoPath, "release_packages"), { recursive: true });
|
|
82
|
+
const summary = getProjectPolicySummary(repoPath);
|
|
83
|
+
assert.equal(summary.valid, false);
|
|
84
|
+
assert.ok(summary.issues.some((issue) => issue.code === "unsafe_path_pattern"));
|
|
85
|
+
const cleanup = releaseCleanup({ repo_path: repoPath, dry_run: true });
|
|
86
|
+
const skipped = cleanup.summary.skipped;
|
|
87
|
+
assert.ok(skipped.some((entry) => entry.path === "release_packages" && entry.reason === "protected_or_sensitive"));
|
|
88
|
+
});
|
|
89
|
+
it("release_cleanup defaults to dry run and writes BOM-free JSON summary", () => {
|
|
90
|
+
mkdirSync(join(repoPath, "release_packages"), { recursive: true });
|
|
91
|
+
const result = releaseCleanup({ repo_path: repoPath });
|
|
92
|
+
assert.equal(result.mode, "release_cleanup");
|
|
93
|
+
assert.equal(result.summary.dry_run, true);
|
|
94
|
+
assert.equal(existsSync(join(repoPath, "release_packages")), true);
|
|
95
|
+
const reportPath = join(repoPath, String(result.summary.report_path));
|
|
96
|
+
const raw = readFileSync(reportPath, "utf-8");
|
|
97
|
+
assert.notEqual(raw.charCodeAt(0), 0xfeff);
|
|
98
|
+
assert.doesNotThrow(() => JSON.parse(raw));
|
|
99
|
+
});
|
|
100
|
+
it("release_cleanup honors auto_cleanup.enabled=false before deleting", () => {
|
|
101
|
+
mkdirSync(join(repoPath, ".patchwarden"), { recursive: true });
|
|
102
|
+
writeFileSync(join(repoPath, ".patchwarden", "project-policy.json"), JSON.stringify({
|
|
103
|
+
auto_cleanup: { enabled: false, patterns: ["release_packages"], exclude: [] },
|
|
104
|
+
}), "utf-8");
|
|
105
|
+
mkdirSync(join(repoPath, "release_packages"), { recursive: true });
|
|
106
|
+
const result = releaseCleanup({ repo_path: repoPath, dry_run: false });
|
|
107
|
+
assert.equal(result.summary.cleanup_disabled_by_policy, true);
|
|
108
|
+
assert.deepEqual(result.summary.removed, []);
|
|
109
|
+
assert.equal(existsSync(join(repoPath, "release_packages")), true);
|
|
110
|
+
});
|
|
111
|
+
it("release_verify fails when required metadata cannot be inferred", async () => {
|
|
112
|
+
writeFileSync(join(repoPath, "package.json"), JSON.stringify({ name: "", version: "" }), "utf-8");
|
|
113
|
+
const result = await releaseVerify({ repo_path: repoPath });
|
|
114
|
+
assert.equal(result.ok, false);
|
|
115
|
+
const stages = result.summary.stages;
|
|
116
|
+
assert.equal(stages.published_verified, "failed");
|
|
117
|
+
assert.equal(stages.github_release_verified, "failed");
|
|
118
|
+
assert.equal(stages.ci_verified, "failed");
|
|
119
|
+
});
|
|
120
|
+
it("release_prepare blocks commands not accepted by the existing command guard", () => {
|
|
121
|
+
const result = releasePrepare({ repo_path: repoPath, required_commands: ["npm run build", "npm run secret"] });
|
|
122
|
+
const commands = result.summary.commands;
|
|
123
|
+
assert.equal(commands.find((entry) => entry.command === "npm run secret")?.status, "blocked");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -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 {};
|