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,544 @@
|
|
|
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
|
+
existsSync,
|
|
10
|
+
} from "node:fs";
|
|
11
|
+
import { join, dirname } from "node:path";
|
|
12
|
+
import { tmpdir } from "node:os";
|
|
13
|
+
import { diagnoseTask } from "../../tools/diagnoseTask.js";
|
|
14
|
+
import type { PatchWardenConfig } from "../../config.js";
|
|
15
|
+
|
|
16
|
+
// ── Test fixtures ──────────────────────────────────────────────────
|
|
17
|
+
|
|
18
|
+
function makeConfig(workspaceRoot: string): PatchWardenConfig {
|
|
19
|
+
return {
|
|
20
|
+
workspaceRoot,
|
|
21
|
+
plansDir: ".patchwarden/plans",
|
|
22
|
+
tasksDir: ".patchwarden/tasks",
|
|
23
|
+
assessmentsDir: ".patchwarden/assessments",
|
|
24
|
+
assessmentTtlSeconds: 3600,
|
|
25
|
+
agents: { codex: { command: "codex", args: ["exec", "{prompt}"] } },
|
|
26
|
+
allowedTestCommands: ["npm test"],
|
|
27
|
+
repoAllowedTestCommands: {},
|
|
28
|
+
maxReadFileBytes: 200_000,
|
|
29
|
+
defaultTaskTimeoutSeconds: 900,
|
|
30
|
+
maxTaskTimeoutSeconds: 3600,
|
|
31
|
+
watcherStaleSeconds: 30,
|
|
32
|
+
directSessionsDir: ".patchwarden/direct-sessions",
|
|
33
|
+
directSessionTtlSeconds: 3600,
|
|
34
|
+
directMaxPatchBytes: 200_000,
|
|
35
|
+
directMaxFileBytes: 500_000,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** A PID that is guaranteed to be alive (the test process itself). */
|
|
40
|
+
const ALIVE_PID = process.pid;
|
|
41
|
+
/** A PID that is guaranteed to not exist (well above typical PID max). */
|
|
42
|
+
const DEAD_PID = 999999;
|
|
43
|
+
|
|
44
|
+
function isoSecondsAgo(seconds: number): string {
|
|
45
|
+
return new Date(Date.now() - seconds * 1000).toISOString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Set a file's mtime to N seconds ago so diagnose_task sees it as stale or fresh. */
|
|
49
|
+
function ageFile(filePath: string, secondsAgo: number): void {
|
|
50
|
+
if (!existsSync(filePath)) return;
|
|
51
|
+
const targetMs = Date.now() - secondsAgo * 1000;
|
|
52
|
+
const targetSec = targetMs / 1000;
|
|
53
|
+
utimesSync(filePath, targetSec, targetSec);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
interface TaskFixtureOptions {
|
|
57
|
+
taskId: string;
|
|
58
|
+
status?: string;
|
|
59
|
+
phase?: string;
|
|
60
|
+
heartbeatSecondsAgo?: number; // runtime.last_heartbeat_at
|
|
61
|
+
childPid?: number; // runtime.child_pid
|
|
62
|
+
childStartedSecondsAgo?: number; // runtime.child_started_at
|
|
63
|
+
watcherInstanceId?: string | null; // runtime.watcher_instance_id
|
|
64
|
+
taskStartedSecondsAgo?: number; // runtime.task_started_at
|
|
65
|
+
stdoutSecondsAgo?: number; // mtime of stdout.log
|
|
66
|
+
stdoutContent?: string;
|
|
67
|
+
stderrSecondsAgo?: number; // mtime of stderr.log
|
|
68
|
+
stderrContent?: string;
|
|
69
|
+
resultMd?: boolean;
|
|
70
|
+
testLog?: boolean;
|
|
71
|
+
gitDiff?: boolean;
|
|
72
|
+
artifactManifest?: boolean;
|
|
73
|
+
createdSecondsAgo?: number;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
describe("diagnoseTask", () => {
|
|
77
|
+
let tempDir: string;
|
|
78
|
+
let config: PatchWardenConfig;
|
|
79
|
+
let tasksDir: string;
|
|
80
|
+
let watcherHeartbeatPath: string;
|
|
81
|
+
|
|
82
|
+
beforeEach(() => {
|
|
83
|
+
tempDir = mkdtempSync(join(tmpdir(), "pw-diagnose-"));
|
|
84
|
+
config = makeConfig(tempDir);
|
|
85
|
+
tasksDir = join(tempDir, ".patchwarden", "tasks");
|
|
86
|
+
mkdirSync(tasksDir, { recursive: true });
|
|
87
|
+
watcherHeartbeatPath = join(dirname(tasksDir), "watcher-heartbeat.json");
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
afterEach(() => {
|
|
91
|
+
rmSync(tempDir, { recursive: true, force: true });
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* Build a task fixture with status.json, runtime.json, and optional log
|
|
96
|
+
* files with controlled mtimes.
|
|
97
|
+
*/
|
|
98
|
+
function buildTask(opts: TaskFixtureOptions): string {
|
|
99
|
+
const taskId = opts.taskId;
|
|
100
|
+
const taskDir = join(tasksDir, taskId);
|
|
101
|
+
mkdirSync(taskDir, { recursive: true });
|
|
102
|
+
|
|
103
|
+
const statusSecondsAgo = opts.createdSecondsAgo ?? 600;
|
|
104
|
+
const status: Record<string, unknown> = {
|
|
105
|
+
task_id: taskId,
|
|
106
|
+
status: opts.status ?? "running",
|
|
107
|
+
phase: opts.phase ?? "executing_agent",
|
|
108
|
+
created_at: isoSecondsAgo(statusSecondsAgo),
|
|
109
|
+
updated_at: isoSecondsAgo(statusSecondsAgo),
|
|
110
|
+
};
|
|
111
|
+
if (opts.heartbeatSecondsAgo !== undefined) {
|
|
112
|
+
status.last_heartbeat_at = isoSecondsAgo(opts.heartbeatSecondsAgo);
|
|
113
|
+
}
|
|
114
|
+
writeFileSync(join(taskDir, "status.json"), JSON.stringify(status, null, 2), "utf-8");
|
|
115
|
+
|
|
116
|
+
const runtime: Record<string, unknown> = {
|
|
117
|
+
phase: opts.phase ?? "executing_agent",
|
|
118
|
+
};
|
|
119
|
+
if (opts.heartbeatSecondsAgo !== undefined) {
|
|
120
|
+
runtime.last_heartbeat_at = isoSecondsAgo(opts.heartbeatSecondsAgo);
|
|
121
|
+
}
|
|
122
|
+
if (opts.childPid !== undefined) {
|
|
123
|
+
runtime.child_pid = opts.childPid;
|
|
124
|
+
}
|
|
125
|
+
if (opts.childStartedSecondsAgo !== undefined) {
|
|
126
|
+
runtime.child_started_at = isoSecondsAgo(opts.childStartedSecondsAgo);
|
|
127
|
+
}
|
|
128
|
+
if (opts.watcherInstanceId !== undefined) {
|
|
129
|
+
runtime.watcher_instance_id = opts.watcherInstanceId;
|
|
130
|
+
}
|
|
131
|
+
if (opts.taskStartedSecondsAgo !== undefined) {
|
|
132
|
+
runtime.task_started_at = isoSecondsAgo(opts.taskStartedSecondsAgo);
|
|
133
|
+
}
|
|
134
|
+
runtime.current_command = null;
|
|
135
|
+
writeFileSync(join(taskDir, "runtime.json"), JSON.stringify(runtime, null, 2), "utf-8");
|
|
136
|
+
|
|
137
|
+
if (opts.stdoutContent !== undefined) {
|
|
138
|
+
const stdoutPath = join(taskDir, "stdout.log");
|
|
139
|
+
writeFileSync(stdoutPath, opts.stdoutContent, "utf-8");
|
|
140
|
+
if (opts.stdoutSecondsAgo !== undefined) ageFile(stdoutPath, opts.stdoutSecondsAgo);
|
|
141
|
+
}
|
|
142
|
+
if (opts.stderrContent !== undefined) {
|
|
143
|
+
const stderrPath = join(taskDir, "stderr.log");
|
|
144
|
+
writeFileSync(stderrPath, opts.stderrContent, "utf-8");
|
|
145
|
+
if (opts.stderrSecondsAgo !== undefined) ageFile(stderrPath, opts.stderrSecondsAgo);
|
|
146
|
+
}
|
|
147
|
+
if (opts.resultMd) {
|
|
148
|
+
writeFileSync(join(taskDir, "result.md"), "# Result\nTask completed.", "utf-8");
|
|
149
|
+
}
|
|
150
|
+
if (opts.testLog) {
|
|
151
|
+
writeFileSync(join(taskDir, "test.log"), "TAP version 13\n1..1\nok 1 test", "utf-8");
|
|
152
|
+
}
|
|
153
|
+
if (opts.gitDiff) {
|
|
154
|
+
writeFileSync(join(taskDir, "git.diff"), "diff --git a/foo b/foo\n", "utf-8");
|
|
155
|
+
}
|
|
156
|
+
if (opts.artifactManifest) {
|
|
157
|
+
writeFileSync(join(taskDir, "artifact_manifest.json"), "{}", "utf-8");
|
|
158
|
+
}
|
|
159
|
+
return taskDir;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Write a watcher heartbeat with the given instance_id and freshness. */
|
|
163
|
+
function writeWatcherHeartbeat(instanceId: string, secondsAgo = 5): void {
|
|
164
|
+
const heartbeat = {
|
|
165
|
+
last_heartbeat_at: isoSecondsAgo(secondsAgo),
|
|
166
|
+
pid: process.pid,
|
|
167
|
+
instance_id: instanceId,
|
|
168
|
+
launcher_pid: process.ppid ?? null,
|
|
169
|
+
};
|
|
170
|
+
writeFileSync(watcherHeartbeatPath, JSON.stringify(heartbeat, null, 2), "utf-8");
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// ── 1. active_running: fresh heartbeat, watcher owns, logs growing ──
|
|
174
|
+
it("diagnoses active_running when heartbeat fresh, watcher owns, logs growing", () => {
|
|
175
|
+
buildTask({
|
|
176
|
+
taskId: "task-active-001",
|
|
177
|
+
status: "running",
|
|
178
|
+
phase: "executing_agent",
|
|
179
|
+
heartbeatSecondsAgo: 10, // fresh (< 120s)
|
|
180
|
+
childPid: ALIVE_PID, // alive
|
|
181
|
+
watcherInstanceId: "watcher-A", // matches watcher
|
|
182
|
+
taskStartedSecondsAgo: 60,
|
|
183
|
+
stdoutContent: "Running...\n",
|
|
184
|
+
stdoutSecondsAgo: 5, // logs growing (< 120s)
|
|
185
|
+
stderrContent: "stderr\n",
|
|
186
|
+
stderrSecondsAgo: 5,
|
|
187
|
+
});
|
|
188
|
+
writeWatcherHeartbeat("watcher-A", 3);
|
|
189
|
+
|
|
190
|
+
const result = diagnoseTask({ task_id: "task-active-001" }, config);
|
|
191
|
+
|
|
192
|
+
assert.equal(result.diagnosis, "active_running");
|
|
193
|
+
assert.equal(result.confidence, "high");
|
|
194
|
+
assert.ok(result.safe_actions.includes("leave_unchanged"));
|
|
195
|
+
assert.equal(result.evidence.watcher_owns_task, true);
|
|
196
|
+
assert.equal(result.evidence.child_pid_alive, true);
|
|
197
|
+
assert.equal(result.evidence.pid_reuse_suspected, false);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// ── 2. stale_running: heartbeat stale, PID dead, no watcher ownership, logs stale ──
|
|
201
|
+
//
|
|
202
|
+
// This task was started directly (e.g., via run_task MCP tool) and has no
|
|
203
|
+
// watcher_instance_id in runtime, so isWatcherOwningTask returns
|
|
204
|
+
// "no_runtime_record" — which does NOT trigger orphaned_running. With
|
|
205
|
+
// heartbeat/PID/logs all stale, it falls through to stale_running.
|
|
206
|
+
it("diagnoses stale_running when heartbeat stale, PID dead, no watcher record, logs stale", () => {
|
|
207
|
+
buildTask({
|
|
208
|
+
taskId: "task-stale-001",
|
|
209
|
+
status: "running",
|
|
210
|
+
phase: "executing_agent",
|
|
211
|
+
heartbeatSecondsAgo: 600, // stale (> 300s)
|
|
212
|
+
childPid: DEAD_PID, // dead
|
|
213
|
+
// No watcherInstanceId — simulates task started directly via run_task
|
|
214
|
+
taskStartedSecondsAgo: 700,
|
|
215
|
+
stdoutContent: "old output\n",
|
|
216
|
+
stdoutSecondsAgo: 600, // stale (> 300s)
|
|
217
|
+
stderrContent: "old stderr\n",
|
|
218
|
+
stderrSecondsAgo: 600,
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
const result = diagnoseTask({ task_id: "task-stale-001" }, config);
|
|
222
|
+
|
|
223
|
+
assert.equal(result.diagnosis, "stale_running");
|
|
224
|
+
assert.equal(result.confidence, "high");
|
|
225
|
+
assert.ok(result.safe_actions.includes("mark_failed_stale"));
|
|
226
|
+
assert.equal(result.evidence.child_pid_alive, false);
|
|
227
|
+
assert.equal(result.evidence.watcher_owns_task, false);
|
|
228
|
+
assert.equal(result.evidence.watcher_ownership_reason, "no_runtime_record");
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
// ── 3. possibly_stale_running: heartbeat possibly stale + logs stale, PID alive ──
|
|
232
|
+
//
|
|
233
|
+
// heartbeat is between 120-300s (possibly stale), logs are > 300s (stale),
|
|
234
|
+
// and PID is alive but pidReuseSuspected is false because heartbeat is not
|
|
235
|
+
// stale enough (> 300s required). This falls into possibly_stale_running.
|
|
236
|
+
it("diagnoses possibly_stale_running when heartbeat possibly stale and logs stale", () => {
|
|
237
|
+
buildTask({
|
|
238
|
+
taskId: "task-posstale-001",
|
|
239
|
+
status: "running",
|
|
240
|
+
phase: "executing_agent",
|
|
241
|
+
heartbeatSecondsAgo: 200, // possibly stale (> 120, <= 300)
|
|
242
|
+
childPid: ALIVE_PID, // alive
|
|
243
|
+
watcherInstanceId: "watcher-A",
|
|
244
|
+
taskStartedSecondsAgo: 300,
|
|
245
|
+
stdoutContent: "output\n",
|
|
246
|
+
stdoutSecondsAgo: 400, // stale (> 300s)
|
|
247
|
+
stderrContent: "stderr\n",
|
|
248
|
+
stderrSecondsAgo: 400,
|
|
249
|
+
});
|
|
250
|
+
writeWatcherHeartbeat("watcher-A", 5);
|
|
251
|
+
|
|
252
|
+
const result = diagnoseTask({ task_id: "task-posstale-001" }, config);
|
|
253
|
+
|
|
254
|
+
// heartbeatPossiblyStale=true && logsStale=true → possibly_stale_running
|
|
255
|
+
assert.equal(result.diagnosis, "possibly_stale_running");
|
|
256
|
+
assert.equal(result.confidence, "medium");
|
|
257
|
+
assert.ok(result.safe_actions.includes("leave_unchanged"));
|
|
258
|
+
assert.equal(result.evidence.pid_reuse_suspected, false);
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// ── 4. orphaned_running: watcher does not own task (instance mismatch) ──
|
|
262
|
+
it("diagnoses orphaned_running when watcher instance_id mismatches", () => {
|
|
263
|
+
buildTask({
|
|
264
|
+
taskId: "task-orphan-001",
|
|
265
|
+
status: "running",
|
|
266
|
+
phase: "executing_agent",
|
|
267
|
+
heartbeatSecondsAgo: 30, // fresh
|
|
268
|
+
childPid: ALIVE_PID,
|
|
269
|
+
watcherInstanceId: "watcher-old", // does NOT match current watcher
|
|
270
|
+
taskStartedSecondsAgo: 60,
|
|
271
|
+
stdoutContent: "output\n",
|
|
272
|
+
stdoutSecondsAgo: 10,
|
|
273
|
+
stderrContent: "stderr\n",
|
|
274
|
+
stderrSecondsAgo: 10,
|
|
275
|
+
});
|
|
276
|
+
writeWatcherHeartbeat("watcher-new", 5);
|
|
277
|
+
|
|
278
|
+
const result = diagnoseTask({ task_id: "task-orphan-001" }, config);
|
|
279
|
+
|
|
280
|
+
assert.equal(result.diagnosis, "orphaned_running");
|
|
281
|
+
assert.equal(result.confidence, "high");
|
|
282
|
+
assert.ok(result.safe_actions.includes("mark_orphaned"));
|
|
283
|
+
assert.equal(result.evidence.watcher_owns_task, false);
|
|
284
|
+
assert.equal(result.evidence.watcher_ownership_reason, "instance_mismatch");
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// ── 5. artifact_collection_stuck: in collecting_artifacts and stuck ──
|
|
288
|
+
it("diagnoses artifact_collection_stuck when in collecting phase and signals stale", () => {
|
|
289
|
+
buildTask({
|
|
290
|
+
taskId: "task-stuckart-001",
|
|
291
|
+
status: "collecting_artifacts",
|
|
292
|
+
phase: "collecting_artifacts",
|
|
293
|
+
heartbeatSecondsAgo: 400, // stale
|
|
294
|
+
childPid: DEAD_PID, // dead
|
|
295
|
+
watcherInstanceId: "watcher-A",
|
|
296
|
+
taskStartedSecondsAgo: 500,
|
|
297
|
+
stdoutContent: "agent done\n",
|
|
298
|
+
stdoutSecondsAgo: 400,
|
|
299
|
+
stderrContent: "stderr\n",
|
|
300
|
+
stderrSecondsAgo: 400,
|
|
301
|
+
});
|
|
302
|
+
writeWatcherHeartbeat("watcher-A", 5);
|
|
303
|
+
|
|
304
|
+
const result = diagnoseTask({ task_id: "task-stuckart-001" }, config);
|
|
305
|
+
|
|
306
|
+
assert.equal(result.diagnosis, "artifact_collection_stuck");
|
|
307
|
+
assert.equal(result.confidence, "high");
|
|
308
|
+
assert.ok(result.safe_actions.includes("recollect_artifacts"));
|
|
309
|
+
assert.ok(result.safe_actions.includes("mark_failed_stale"));
|
|
310
|
+
assert.equal(result.evidence.collecting_artifacts_phase, true);
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
// ── 6. done_candidate: artifacts exist while status still running ──
|
|
314
|
+
it("diagnoses done_candidate with high confidence when result.md + test.log exist", () => {
|
|
315
|
+
buildTask({
|
|
316
|
+
taskId: "task-donecand-001",
|
|
317
|
+
status: "running",
|
|
318
|
+
phase: "executing_agent",
|
|
319
|
+
heartbeatSecondsAgo: 400,
|
|
320
|
+
childPid: DEAD_PID,
|
|
321
|
+
watcherInstanceId: "watcher-old",
|
|
322
|
+
taskStartedSecondsAgo: 500,
|
|
323
|
+
stdoutContent: "done\n",
|
|
324
|
+
stdoutSecondsAgo: 400,
|
|
325
|
+
stderrContent: "stderr\n",
|
|
326
|
+
stderrSecondsAgo: 400,
|
|
327
|
+
resultMd: true,
|
|
328
|
+
testLog: true,
|
|
329
|
+
gitDiff: true,
|
|
330
|
+
});
|
|
331
|
+
// No watcher heartbeat — orphaned
|
|
332
|
+
|
|
333
|
+
const result = diagnoseTask({ task_id: "task-donecand-001" }, config);
|
|
334
|
+
|
|
335
|
+
// done_candidate takes priority over orphaned_running because artifacts exist
|
|
336
|
+
assert.equal(result.diagnosis, "done_candidate");
|
|
337
|
+
assert.equal(result.confidence, "high");
|
|
338
|
+
assert.ok(result.safe_actions.includes("mark_done_by_agent"));
|
|
339
|
+
assert.equal(result.evidence.has_result_md, true);
|
|
340
|
+
assert.equal(result.evidence.has_test_log, true);
|
|
341
|
+
});
|
|
342
|
+
|
|
343
|
+
it("diagnoses done_candidate with medium confidence when only result.md + collecting phase", () => {
|
|
344
|
+
buildTask({
|
|
345
|
+
taskId: "task-donecand-002",
|
|
346
|
+
status: "collecting_artifacts",
|
|
347
|
+
phase: "collecting_artifacts",
|
|
348
|
+
heartbeatSecondsAgo: 30, // fresh — not stuck
|
|
349
|
+
childPid: ALIVE_PID,
|
|
350
|
+
watcherInstanceId: "watcher-A",
|
|
351
|
+
taskStartedSecondsAgo: 100,
|
|
352
|
+
stdoutContent: "done\n",
|
|
353
|
+
stdoutSecondsAgo: 30,
|
|
354
|
+
stderrContent: "stderr\n",
|
|
355
|
+
stderrSecondsAgo: 30,
|
|
356
|
+
resultMd: true,
|
|
357
|
+
// No test.log/git.diff → medium signal
|
|
358
|
+
});
|
|
359
|
+
writeWatcherHeartbeat("watcher-A", 5);
|
|
360
|
+
|
|
361
|
+
const result = diagnoseTask({ task_id: "task-donecand-002" }, config);
|
|
362
|
+
|
|
363
|
+
assert.equal(result.diagnosis, "done_candidate");
|
|
364
|
+
assert.equal(result.confidence, "medium");
|
|
365
|
+
assert.ok(result.safe_actions.includes("mark_done_by_agent"));
|
|
366
|
+
assert.ok(result.safe_actions.includes("collect_artifacts"));
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
// ── 7. unknown: insufficient evidence ──
|
|
370
|
+
//
|
|
371
|
+
// heartbeat is fresh but PID is dead and logs are stale — no single
|
|
372
|
+
// branch matches with enough confidence, so it falls through to unknown.
|
|
373
|
+
it("diagnoses unknown when signals conflict (fresh heartbeat but dead PID + stale logs)", () => {
|
|
374
|
+
buildTask({
|
|
375
|
+
taskId: "task-unknown-001",
|
|
376
|
+
status: "running",
|
|
377
|
+
phase: "executing_agent",
|
|
378
|
+
heartbeatSecondsAgo: 10, // fresh — doesn't hit stale paths
|
|
379
|
+
childPid: DEAD_PID, // dead — but heartbeat says fresh
|
|
380
|
+
watcherInstanceId: "watcher-A",
|
|
381
|
+
taskStartedSecondsAgo: 60,
|
|
382
|
+
stdoutContent: "running\n",
|
|
383
|
+
stdoutSecondsAgo: 400, // stale — but heartbeat says fresh
|
|
384
|
+
stderrContent: "stderr\n",
|
|
385
|
+
stderrSecondsAgo: 400,
|
|
386
|
+
});
|
|
387
|
+
writeWatcherHeartbeat("watcher-A", 5);
|
|
388
|
+
|
|
389
|
+
const result = diagnoseTask({ task_id: "task-unknown-001" }, config);
|
|
390
|
+
|
|
391
|
+
// Fresh heartbeat means not stale_running / possibly_stale_running.
|
|
392
|
+
// Dead PID + stale logs means not active_running.
|
|
393
|
+
// Conflicting signals → unknown.
|
|
394
|
+
assert.equal(result.diagnosis, "unknown");
|
|
395
|
+
assert.equal(result.confidence, "low");
|
|
396
|
+
assert.ok(result.safe_actions.includes("leave_unchanged"));
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
// ── 8. PID reuse case: PID alive but everything else stale ──
|
|
400
|
+
//
|
|
401
|
+
// This is the conservative safety check: even though process.kill(pid, 0)
|
|
402
|
+
// returns true, we refuse to call the task "active" because heartbeat and
|
|
403
|
+
// logs are all stale. The PID may have been reused by the OS for an
|
|
404
|
+
// unrelated process.
|
|
405
|
+
it("refuses active_running on PID reuse: PID alive but heartbeat + logs stale", () => {
|
|
406
|
+
buildTask({
|
|
407
|
+
taskId: "task-pidreuse-001",
|
|
408
|
+
status: "running",
|
|
409
|
+
phase: "executing_agent",
|
|
410
|
+
heartbeatSecondsAgo: 600, // very stale
|
|
411
|
+
childPid: ALIVE_PID, // alive — but probably reused!
|
|
412
|
+
watcherInstanceId: "watcher-A",
|
|
413
|
+
taskStartedSecondsAgo: 700,
|
|
414
|
+
stdoutContent: "old output\n",
|
|
415
|
+
stdoutSecondsAgo: 600, // very stale
|
|
416
|
+
stderrContent: "old stderr\n",
|
|
417
|
+
stderrSecondsAgo: 600,
|
|
418
|
+
});
|
|
419
|
+
writeWatcherHeartbeat("watcher-A", 5);
|
|
420
|
+
|
|
421
|
+
const result = diagnoseTask({ task_id: "task-pidreuse-001" }, config);
|
|
422
|
+
|
|
423
|
+
// Must NOT be active_running — that would be the unsafe conclusion.
|
|
424
|
+
assert.notEqual(result.diagnosis, "active_running");
|
|
425
|
+
assert.equal(result.diagnosis, "possibly_stale_running");
|
|
426
|
+
assert.equal(result.confidence, "medium");
|
|
427
|
+
assert.equal(result.evidence.pid_reuse_suspected, true);
|
|
428
|
+
assert.equal(result.evidence.child_pid_alive, true);
|
|
429
|
+
assert.ok(result.reasons.some((r) => r.includes("PID reuse")));
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
// ── 9. terminal: already-terminal statuses return terminal diagnosis ──
|
|
433
|
+
it("returns terminal diagnosis for done status", () => {
|
|
434
|
+
buildTask({
|
|
435
|
+
taskId: "pw-terminal-done",
|
|
436
|
+
status: "done",
|
|
437
|
+
phase: "completed",
|
|
438
|
+
createdSecondsAgo: 1000,
|
|
439
|
+
});
|
|
440
|
+
|
|
441
|
+
const result = diagnoseTask({ task_id: "pw-terminal-done" }, config);
|
|
442
|
+
|
|
443
|
+
assert.equal(result.diagnosis, "terminal");
|
|
444
|
+
assert.equal(result.confidence, "high");
|
|
445
|
+
assert.ok(result.safe_actions.includes("leave_unchanged"));
|
|
446
|
+
});
|
|
447
|
+
|
|
448
|
+
it("returns terminal diagnosis for failed_stale status (v0.7.0 status)", () => {
|
|
449
|
+
buildTask({
|
|
450
|
+
taskId: "pw-terminal-failed-stale",
|
|
451
|
+
status: "failed_stale",
|
|
452
|
+
phase: "failed_stale",
|
|
453
|
+
createdSecondsAgo: 1000,
|
|
454
|
+
});
|
|
455
|
+
|
|
456
|
+
const result = diagnoseTask({ task_id: "pw-terminal-failed-stale" }, config);
|
|
457
|
+
|
|
458
|
+
assert.equal(result.diagnosis, "terminal");
|
|
459
|
+
});
|
|
460
|
+
|
|
461
|
+
it("returns terminal diagnosis for orphaned status (v0.7.0 status)", () => {
|
|
462
|
+
buildTask({
|
|
463
|
+
taskId: "pw-terminal-orphaned",
|
|
464
|
+
status: "orphaned",
|
|
465
|
+
phase: "orphaned",
|
|
466
|
+
createdSecondsAgo: 1000,
|
|
467
|
+
});
|
|
468
|
+
|
|
469
|
+
const result = diagnoseTask({ task_id: "pw-terminal-orphaned" }, config);
|
|
470
|
+
|
|
471
|
+
assert.equal(result.diagnosis, "terminal");
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
it("returns terminal diagnosis for done_by_agent status (v0.7.0 status)", () => {
|
|
475
|
+
buildTask({
|
|
476
|
+
taskId: "pw-terminal-done-by-agent",
|
|
477
|
+
status: "done_by_agent",
|
|
478
|
+
phase: "done_by_agent",
|
|
479
|
+
createdSecondsAgo: 1000,
|
|
480
|
+
});
|
|
481
|
+
|
|
482
|
+
const result = diagnoseTask({ task_id: "pw-terminal-done-by-agent" }, config);
|
|
483
|
+
|
|
484
|
+
assert.equal(result.diagnosis, "terminal");
|
|
485
|
+
});
|
|
486
|
+
|
|
487
|
+
// ── include_logs flag ──
|
|
488
|
+
it("includes redacted log tails when include_logs=true", () => {
|
|
489
|
+
buildTask({
|
|
490
|
+
taskId: "task-logs-001",
|
|
491
|
+
status: "running",
|
|
492
|
+
phase: "executing_agent",
|
|
493
|
+
heartbeatSecondsAgo: 10,
|
|
494
|
+
childPid: ALIVE_PID,
|
|
495
|
+
watcherInstanceId: "watcher-A",
|
|
496
|
+
taskStartedSecondsAgo: 60,
|
|
497
|
+
stdoutContent: "line1\nline2 with Bearer abcdefghijklmnop123456\n",
|
|
498
|
+
stdoutSecondsAgo: 5,
|
|
499
|
+
stderrContent: "stderr line\n",
|
|
500
|
+
stderrSecondsAgo: 5,
|
|
501
|
+
});
|
|
502
|
+
writeWatcherHeartbeat("watcher-A", 3);
|
|
503
|
+
|
|
504
|
+
const result = diagnoseTask({ task_id: "task-logs-001", include_logs: true }, config);
|
|
505
|
+
|
|
506
|
+
assert.ok(result.logs !== null);
|
|
507
|
+
assert.ok(result.logs!.stdout_tail !== null);
|
|
508
|
+
assert.ok(result.logs!.stderr_tail !== null);
|
|
509
|
+
// The bearer token must be redacted
|
|
510
|
+
assert.ok(!result.logs!.stdout_tail!.includes("abcdefghijklmnop123456"));
|
|
511
|
+
assert.ok(result.logs!.stdout_tail!.includes("[REDACTED]"));
|
|
512
|
+
});
|
|
513
|
+
|
|
514
|
+
it("omits log tails when include_logs is false (default)", () => {
|
|
515
|
+
buildTask({
|
|
516
|
+
taskId: "task-nologs-001",
|
|
517
|
+
status: "running",
|
|
518
|
+
phase: "executing_agent",
|
|
519
|
+
heartbeatSecondsAgo: 10,
|
|
520
|
+
childPid: ALIVE_PID,
|
|
521
|
+
watcherInstanceId: "watcher-A",
|
|
522
|
+
taskStartedSecondsAgo: 60,
|
|
523
|
+
stdoutContent: "line1\n",
|
|
524
|
+
stdoutSecondsAgo: 5,
|
|
525
|
+
stderrContent: "stderr\n",
|
|
526
|
+
stderrSecondsAgo: 5,
|
|
527
|
+
});
|
|
528
|
+
writeWatcherHeartbeat("watcher-A", 3);
|
|
529
|
+
|
|
530
|
+
const result = diagnoseTask({ task_id: "task-nologs-001" }, config);
|
|
531
|
+
|
|
532
|
+
assert.equal(result.logs, null);
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
// ── Error case: task not found ──
|
|
536
|
+
it("throws when task does not exist", () => {
|
|
537
|
+
// guardReadPath throws "File not found" before the existsSync check
|
|
538
|
+
// produces "Task not found" — both indicate the task doesn't exist.
|
|
539
|
+
assert.throws(
|
|
540
|
+
() => diagnoseTask({ task_id: "non-existent-task" }, config),
|
|
541
|
+
/(Task not found|File not found)/
|
|
542
|
+
);
|
|
543
|
+
});
|
|
544
|
+
});
|