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.
- package/PatchWarden-Control-Tray.cmd +11 -0
- package/PatchWarden-Control.cmd +6 -0
- package/PatchWarden-Desktop.cmd +5 -0
- package/PatchWarden.cmd +1 -1
- package/README.en.md +112 -24
- package/README.md +36 -25
- package/Restart-PatchWarden-Control.cmd +6 -0
- package/Stop-PatchWarden.cmd +11 -0
- 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.d.ts +14 -0
- package/dist/controlCenter.js +2015 -0
- package/dist/doctor.js +35 -4
- 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/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/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/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/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 +175 -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/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/reconcileTasks.d.ts +41 -0
- package/dist/tools/reconcileTasks.js +352 -0
- package/dist/tools/registry.js +550 -0
- package/dist/tools/safeStatus.d.ts +31 -1
- package/dist/tools/safeStatus.js +43 -1
- package/dist/tools/safeViews.d.ts +256 -0
- package/dist/tools/safeViews.js +250 -0
- package/dist/tools/schemaDriftCheck.d.ts +46 -0
- package/dist/tools/schemaDriftCheck.js +80 -0
- package/dist/tools/toolCatalog.d.ts +4 -3
- package/dist/tools/toolCatalog.js +33 -11
- package/dist/tools/toolRegistry.d.ts +61 -0
- package/dist/tools/toolRegistry.js +724 -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/control-center/README.md +33 -0
- package/docs/control-center/control-center-daily-driver.md +211 -0
- package/docs/control-center/control-center-mvp.md +205 -0
- package/docs/control-center/control-center-phase2.md +159 -0
- package/docs/demo.md +3 -0
- package/docs/release-v0.6.4.md +45 -0
- package/examples/openai-tunnel/README.md +5 -5
- package/examples/openai-tunnel/tunnel-client.example.yaml +3 -3
- package/package.json +25 -16
- package/scripts/README.md +47 -0
- package/scripts/{brand-check.js → checks/brand-check.js} +2 -2
- package/scripts/checks/control-center-smoke.js +1098 -0
- package/scripts/{control-smoke.js → checks/control-smoke.js} +19 -5
- package/scripts/{doctor-smoke.js → checks/doctor-smoke.js} +2 -1
- package/scripts/{http-mcp-smoke.js → checks/http-mcp-smoke.js} +2 -2
- package/scripts/{lifecycle-smoke.js → checks/lifecycle-smoke.js} +25 -21
- package/scripts/{mcp-manifest-check.js → checks/mcp-manifest-check.js} +54 -7
- package/scripts/{mcp-smoke.js → checks/mcp-smoke.js} +40 -11
- package/scripts/{package-manifest-check.js → checks/package-manifest-check.js} +2 -1
- package/scripts/{tunnel-supervisor-smoke.js → checks/tunnel-supervisor-smoke.js} +2 -2
- package/scripts/{unit-tests.js → checks/unit-tests.js} +1 -1
- package/scripts/{watcher-supervisor-smoke.js → checks/watcher-supervisor-smoke.js} +13 -7
- package/scripts/control/control-center-tray.ps1 +281 -0
- package/scripts/{get-patchwarden-health.ps1 → control/get-patchwarden-health.ps1} +3 -3
- package/scripts/{manage-patchwarden.ps1 → control/manage-patchwarden.ps1} +30 -4
- package/scripts/control/restart-control-center.ps1 +173 -0
- package/scripts/{restart-patchwarden.ps1 → control/restart-patchwarden.ps1} +1 -1
- package/scripts/control/start-control-center.ps1 +263 -0
- package/scripts/{start-patchwarden-tunnel.ps1 → control/start-patchwarden-tunnel.ps1} +48 -6
- package/scripts/control/stop-patchwarden.ps1 +114 -0
- package/scripts/launchers/Check-PatchWarden-Health.cmd +1 -1
- package/scripts/launchers/Reset-PatchWarden-Tunnel-Key.cmd +1 -1
- package/scripts/launchers/Restart-PatchWarden.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Direct-Tunnel.cmd +1 -1
- package/scripts/launchers/Start-PatchWarden-Tunnel.cmd +1 -1
- package/scripts/{patchwarden-mcp-direct.cmd → mcp/patchwarden-mcp-direct.cmd} +1 -1
- package/scripts/{patchwarden-mcp-stdio.cmd → mcp/patchwarden-mcp-stdio.cmd} +1 -1
- package/scripts/{pack-clean.js → release/pack-clean.js} +9 -1
- package/src/agents/agentRouter.ts +149 -0
- package/src/config.ts +9 -3
- package/src/controlCenter.ts +2166 -0
- package/src/doctor.ts +40 -5
- 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/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/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/reconcile-tasks.test.ts +551 -0
- package/src/test/unit/release-gate.test.ts +314 -0
- package/src/test/unit/safe-views.test.ts +184 -0
- package/src/test/unit/schema-drift-check.test.ts +258 -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/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/mergeWorktree.ts +42 -0
- package/src/tools/reconcileTasks.ts +464 -0
- package/src/tools/registry.ts +618 -1
- package/src/tools/safeStatus.ts +73 -2
- package/src/tools/safeViews.ts +271 -0
- package/src/tools/schemaDriftCheck.ts +120 -0
- package/src/tools/toolCatalog.ts +34 -11
- package/src/tools/toolRegistry.ts +786 -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/colors_and_type.css +141 -0
- package/ui/pages/audit.html +743 -0
- package/ui/pages/dashboard.html +1154 -0
- package/ui/pages/direct-sessions.html +652 -0
- package/ui/pages/logs.html +502 -0
- package/ui/pages/task-detail.html +1229 -0
- package/ui/pages/tasks.html +702 -0
- package/ui/pages/workspace.html +947 -0
- package/ui/partials/project-shell.html +362 -0
- package/ui/vendor/lucide.js +12 -0
- package/ui/vendor/tailwindcss-browser.js +947 -0
package/src/tools/cancelTask.ts
CHANGED
|
@@ -19,7 +19,7 @@ export function requestTaskTermination(taskId: string, force: boolean) {
|
|
|
19
19
|
|
|
20
20
|
const data = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
21
21
|
const currentStatus: TaskStatus = data.status;
|
|
22
|
-
if (["done", "failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled"].includes(currentStatus)) {
|
|
22
|
+
if (["done", "done_by_agent", "failed", "failed_verification", "failed_scope_violation", "failed_policy_violation", "canceled"].includes(currentStatus)) {
|
|
23
23
|
return {
|
|
24
24
|
task_id: taskId,
|
|
25
25
|
previous_status: currentStatus,
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { getConfig } from "../config.js";
|
|
2
|
+
import { guardWorkspacePath } from "../security/pathGuard.js";
|
|
3
|
+
import {
|
|
4
|
+
runReleaseGateCheck,
|
|
5
|
+
type ReleaseStage,
|
|
6
|
+
} from "../release/releaseGate.js";
|
|
7
|
+
|
|
8
|
+
export interface CheckReleaseGateInput {
|
|
9
|
+
repo_path: string;
|
|
10
|
+
target_stage: ReleaseStage;
|
|
11
|
+
package_name?: string;
|
|
12
|
+
version?: string;
|
|
13
|
+
github_repo?: string;
|
|
14
|
+
branch?: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* MCP tool handler for the release gate.
|
|
19
|
+
*
|
|
20
|
+
* Validates repo_path against the workspace boundary, runs the release-gate
|
|
21
|
+
* stages up to target_stage, and returns the result as a JSON text blob.
|
|
22
|
+
*/
|
|
23
|
+
export async function checkReleaseGate(
|
|
24
|
+
input: CheckReleaseGateInput,
|
|
25
|
+
): Promise<{
|
|
26
|
+
content: Array<{ type: "text"; text: string }>;
|
|
27
|
+
}> {
|
|
28
|
+
const config = getConfig();
|
|
29
|
+
const repoPath = guardWorkspacePath(input.repo_path, config.workspaceRoot);
|
|
30
|
+
|
|
31
|
+
const result = await runReleaseGateCheck(
|
|
32
|
+
repoPath,
|
|
33
|
+
input.target_stage,
|
|
34
|
+
{
|
|
35
|
+
packageName: input.package_name,
|
|
36
|
+
version: input.version,
|
|
37
|
+
githubRepo: input.github_repo,
|
|
38
|
+
branch: input.branch,
|
|
39
|
+
},
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
return {
|
|
43
|
+
content: [{ type: "text" as const, text: JSON.stringify(result, null, 2) }],
|
|
44
|
+
};
|
|
45
|
+
}
|
package/src/tools/createTask.ts
CHANGED
|
@@ -36,16 +36,36 @@ import {
|
|
|
36
36
|
type PendingReason,
|
|
37
37
|
type WatcherStatusSnapshot,
|
|
38
38
|
} from "../watcherStatus.js";
|
|
39
|
+
import { routeAgent, type AgentRouteResult } from "../agents/agentRouter.js";
|
|
39
40
|
|
|
40
41
|
export type TaskStatus =
|
|
41
42
|
| "pending"
|
|
42
43
|
| "running"
|
|
43
|
-
| "
|
|
44
|
+
| "collecting_artifacts"
|
|
45
|
+
| "done_by_agent" // v0.7.0: agent self-reported done or status reconciled to done; acceptance_status defaults to "pending"
|
|
46
|
+
| "accepted" // v0.7.2: audit_task confirmed acceptance
|
|
47
|
+
| "rejected" // v0.7.2: audit_task confirmed rejection
|
|
48
|
+
| "needs_fix" // v0.7.2: audit_task requires fixes before acceptance
|
|
49
|
+
| "blocked" // v0.7.2: audit_task blocked by approval boundary
|
|
50
|
+
| "done" // legacy terminal status, kept for backward compatibility
|
|
44
51
|
| "failed"
|
|
45
52
|
| "failed_verification"
|
|
46
53
|
| "failed_scope_violation"
|
|
47
54
|
| "failed_policy_violation"
|
|
55
|
+
| "failed_stale" // v0.7.0: process dead / heartbeat expired
|
|
56
|
+
| "orphaned" // v0.7.0: watcher no longer owns the task
|
|
48
57
|
| "canceled";
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* v0.7.2 acceptance status — only meaningful for done_by_agent.
|
|
61
|
+
* - "pending": done_by_agent reached but not yet audited/accepted
|
|
62
|
+
* - "accepted": audit_task confirmed all evidence passes
|
|
63
|
+
* - "rejected": audit_task found fail-level issues
|
|
64
|
+
* - "needs_fix": audit_task found warn-level issues requiring fixes
|
|
65
|
+
* - "blocked": audit_task blocked by approval boundary (e.g. release claims)
|
|
66
|
+
* - null: status has no acceptance semantics (running, failed, etc.)
|
|
67
|
+
*/
|
|
68
|
+
export type AcceptanceStatus = "pending" | "accepted" | "rejected" | "needs_fix" | "blocked" | null;
|
|
49
69
|
export type TaskPhase =
|
|
50
70
|
| "queued"
|
|
51
71
|
| "preparing"
|
|
@@ -59,6 +79,13 @@ export type TaskPhase =
|
|
|
59
79
|
| "failed_verification"
|
|
60
80
|
| "failed_scope_violation"
|
|
61
81
|
| "failed_policy_violation"
|
|
82
|
+
| "failed_stale" // v0.7.0
|
|
83
|
+
| "orphaned" // v0.7.0
|
|
84
|
+
| "done_by_agent" // v0.7.0
|
|
85
|
+
| "accepted" // v0.7.2
|
|
86
|
+
| "rejected" // v0.7.2
|
|
87
|
+
| "needs_fix" // v0.7.2
|
|
88
|
+
| "blocked" // v0.7.2
|
|
62
89
|
| "canceled";
|
|
63
90
|
|
|
64
91
|
export interface CreateTaskInput {
|
|
@@ -75,6 +102,14 @@ export interface CreateTaskInput {
|
|
|
75
102
|
timeout_seconds?: number;
|
|
76
103
|
execution_mode?: "assess_only" | "execute";
|
|
77
104
|
assessment_id?: string;
|
|
105
|
+
// v0.7.2: 验收标准绑定到任务创建
|
|
106
|
+
scope?: string[]; // 允许修改的文件/目录范围(相对于 repo_path)
|
|
107
|
+
forbidden?: string[]; // 禁止修改的文件/目录
|
|
108
|
+
verification?: string[]; // 验收时必须通过的验证命令(独立于 verify_commands)
|
|
109
|
+
done_evidence?: string[]; // 完成证据要求(例如 ["result.md", "test.log", "diff.patch"])
|
|
110
|
+
// v0.8.0: Goal Session 关联
|
|
111
|
+
goal_id?: string;
|
|
112
|
+
subgoal_id?: string;
|
|
78
113
|
}
|
|
79
114
|
|
|
80
115
|
export interface AssessOnlyOutput {
|
|
@@ -167,12 +202,23 @@ export function createTask(input: CreateTaskInput): CreateTaskResult {
|
|
|
167
202
|
// ── Validate required fields ──
|
|
168
203
|
// agent and repo_path are required unless assessment_id fills them.
|
|
169
204
|
// assess_only always requires them because the risk engine needs to check them.
|
|
205
|
+
// v1.0.0: agent is now optional — if omitted, routeAgent recommends one based on scope/goal/plan.
|
|
206
|
+
let agentSelectionReason: AgentRouteResult | undefined;
|
|
170
207
|
if (!effectiveInput.agent || effectiveInput.agent.trim() === "") {
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
208
|
+
const configuredAgents = Object.keys(config.agents);
|
|
209
|
+
const routeResult = routeAgent({
|
|
210
|
+
goal: effectiveInput.goal,
|
|
211
|
+
scope: effectiveInput.scope,
|
|
212
|
+
inline_plan: effectiveInput.inline_plan,
|
|
213
|
+
template: effectiveInput.template,
|
|
214
|
+
configuredAgents,
|
|
215
|
+
});
|
|
216
|
+
effectiveInput.agent = routeResult.recommended_agent;
|
|
217
|
+
agentSelectionReason = {
|
|
218
|
+
recommended_agent: routeResult.recommended_agent,
|
|
219
|
+
reason: routeResult.reason,
|
|
220
|
+
fallback: routeResult.fallback,
|
|
221
|
+
};
|
|
176
222
|
}
|
|
177
223
|
|
|
178
224
|
const planSources = [
|
|
@@ -564,6 +610,18 @@ export function createTask(input: CreateTaskInput): CreateTaskResult {
|
|
|
564
610
|
last_heartbeat_at: new Date().toISOString(),
|
|
565
611
|
current_command: null as string | null,
|
|
566
612
|
error: null as string | null,
|
|
613
|
+
// v0.7.2: 验收标准绑定到任务创建
|
|
614
|
+
goal: effectiveInput.goal || null,
|
|
615
|
+
scope: effectiveInput.scope ?? null,
|
|
616
|
+
forbidden: effectiveInput.forbidden ?? null,
|
|
617
|
+
verification: effectiveInput.verification ?? null,
|
|
618
|
+
done_evidence: effectiveInput.done_evidence ?? null,
|
|
619
|
+
acceptance_status: null as AcceptanceStatus,
|
|
620
|
+
// v0.8.0: Goal Session 关联
|
|
621
|
+
goal_id: effectiveInput.goal_id ?? null,
|
|
622
|
+
subgoal_id: effectiveInput.subgoal_id ?? null,
|
|
623
|
+
// v1.0.0: Agent routing decision (only present when agent was auto-routed)
|
|
624
|
+
...(agentSelectionReason ? { agent_selection_reason: agentSelectionReason } : {}),
|
|
567
625
|
};
|
|
568
626
|
|
|
569
627
|
writeFileSync(statusFile, JSON.stringify(statusData, null, 2), "utf-8");
|
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
import { existsSync, readFileSync, statSync } from "node:fs";
|
|
2
|
+
import { join, resolve } from "node:path";
|
|
3
|
+
import { getTasksDir, getConfig, type PatchWardenConfig } from "../config.js";
|
|
4
|
+
import { guardReadPath } from "../security/pathGuard.js";
|
|
5
|
+
import { guardSensitivePath } from "../security/sensitiveGuard.js";
|
|
6
|
+
import { redactSensitiveContent } from "../security/contentRedaction.js";
|
|
7
|
+
import { readTaskRuntime } from "../taskRuntime.js";
|
|
8
|
+
import { isWatcherOwningTask, readWatcherInstanceId } from "../watcherStatus.js";
|
|
9
|
+
|
|
10
|
+
// ── v0.7.0: Task diagnosis types ──────────────────────────────────
|
|
11
|
+
|
|
12
|
+
export type DiagnosisType =
|
|
13
|
+
| "active_running"
|
|
14
|
+
| "stale_running"
|
|
15
|
+
| "possibly_stale_running"
|
|
16
|
+
| "orphaned_running"
|
|
17
|
+
| "artifact_collection_stuck"
|
|
18
|
+
| "done_candidate"
|
|
19
|
+
| "unknown"
|
|
20
|
+
| "terminal";
|
|
21
|
+
|
|
22
|
+
export type DiagnosisConfidence = "high" | "medium" | "low";
|
|
23
|
+
|
|
24
|
+
export type SafeAction =
|
|
25
|
+
| "leave_unchanged"
|
|
26
|
+
| "mark_failed_stale"
|
|
27
|
+
| "mark_orphaned"
|
|
28
|
+
| "mark_done_by_agent"
|
|
29
|
+
| "collect_artifacts"
|
|
30
|
+
| "recollect_artifacts"
|
|
31
|
+
| "needs_fix";
|
|
32
|
+
|
|
33
|
+
export interface DiagnosisEvidence {
|
|
34
|
+
heartbeat_age_seconds: number | null;
|
|
35
|
+
stdout_age_seconds: number | null;
|
|
36
|
+
stderr_age_seconds: number | null;
|
|
37
|
+
stdout_size_bytes: number | null;
|
|
38
|
+
stderr_size_bytes: number | null;
|
|
39
|
+
child_pid: number | null;
|
|
40
|
+
child_pid_alive: boolean | null;
|
|
41
|
+
child_started_at: string | null;
|
|
42
|
+
pid_reuse_suspected: boolean;
|
|
43
|
+
watcher_instance_id: string | null;
|
|
44
|
+
current_watcher_instance_id: string | null;
|
|
45
|
+
watcher_owns_task: boolean;
|
|
46
|
+
watcher_ownership_reason: string;
|
|
47
|
+
has_result_md: boolean;
|
|
48
|
+
has_test_log: boolean;
|
|
49
|
+
has_git_diff: boolean;
|
|
50
|
+
has_artifact_manifest: boolean;
|
|
51
|
+
task_started_at: string | null;
|
|
52
|
+
status_updated_at: string | null;
|
|
53
|
+
collecting_artifacts_phase: boolean;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface DiagnoseTaskOutput {
|
|
57
|
+
task_id: string;
|
|
58
|
+
status: string;
|
|
59
|
+
phase: string | null;
|
|
60
|
+
diagnosis: DiagnosisType;
|
|
61
|
+
confidence: DiagnosisConfidence;
|
|
62
|
+
reasons: string[];
|
|
63
|
+
safe_actions: SafeAction[];
|
|
64
|
+
evidence: DiagnosisEvidence;
|
|
65
|
+
logs: { stdout_tail: string | null; stderr_tail: string | null } | null;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface DiagnoseTaskInput {
|
|
69
|
+
task_id: string;
|
|
70
|
+
include_logs?: boolean;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// ── Thresholds (seconds) ──────────────────────────────────────────
|
|
74
|
+
//
|
|
75
|
+
// These are intentionally conservative. A task is only "stale" when multiple
|
|
76
|
+
// signals agree. A single stale signal produces "possibly_stale_running" or
|
|
77
|
+
// "unknown", never an automatic safe_fix.
|
|
78
|
+
|
|
79
|
+
const HEARTBEAT_STALE_SECONDS = 300; // 5 min: heartbeat this old is a strong stale signal
|
|
80
|
+
const HEARTBEAT_POSSIBLY_STALE_SECONDS = 120; // 2 min: heartbeat this old is a weak stale signal
|
|
81
|
+
const LOG_STALE_SECONDS = 300; // 5 min: stdout/stderr unchanged this long is stale
|
|
82
|
+
const LOG_TAIL_LINES = 40;
|
|
83
|
+
|
|
84
|
+
// ── PID liveness check ─────────────────────────────────────────────
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Check whether a PID is alive without sending a signal that could disturb it.
|
|
88
|
+
* Returns null when the check itself fails (e.g., permission denied),
|
|
89
|
+
* so callers can avoid treating "couldn't check" as "process is dead".
|
|
90
|
+
*/
|
|
91
|
+
function isPidAlive(pid: number): boolean | null {
|
|
92
|
+
if (!Number.isInteger(pid) || pid <= 0) return false;
|
|
93
|
+
try {
|
|
94
|
+
// process.kill(pid, 0) does not actually kill; it just checks liveness.
|
|
95
|
+
// It throws if the process does not exist or the caller lacks permission.
|
|
96
|
+
process.kill(pid, 0);
|
|
97
|
+
return true;
|
|
98
|
+
} catch (err) {
|
|
99
|
+
const code = (err as NodeJS.ErrnoException).code;
|
|
100
|
+
if (code === "ESRCH") return false; // No such process
|
|
101
|
+
if (code === "EPERM") return null; // Alive but not ours — treat as unknown
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ── File age helper ────────────────────────────────────────────────
|
|
107
|
+
|
|
108
|
+
function fileAgeSeconds(filePath: string, nowMs: number): { age: number; size: number } | null {
|
|
109
|
+
if (!existsSync(filePath)) return null;
|
|
110
|
+
try {
|
|
111
|
+
const stat = statSync(filePath);
|
|
112
|
+
return { age: Math.max(0, Math.round((nowMs - stat.mtimeMs) / 1000)), size: stat.size };
|
|
113
|
+
} catch {
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function readTailLines(filePath: string, lines: number): string | null {
|
|
119
|
+
if (!existsSync(filePath)) return null;
|
|
120
|
+
try {
|
|
121
|
+
const raw = readFileSync(filePath, "utf-8");
|
|
122
|
+
const tail = raw.split("\n").slice(-lines).join("\n");
|
|
123
|
+
return redactSensitiveContent(tail).content;
|
|
124
|
+
} catch {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Main diagnosis entry point ─────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
export function diagnoseTask(
|
|
132
|
+
input: DiagnoseTaskInput,
|
|
133
|
+
config: PatchWardenConfig = getConfig()
|
|
134
|
+
): DiagnoseTaskOutput {
|
|
135
|
+
const taskId = input.task_id;
|
|
136
|
+
const tasksDir = getTasksDir(config);
|
|
137
|
+
const taskDir = resolve(tasksDir, taskId);
|
|
138
|
+
const statusFile = join(taskDir, "status.json");
|
|
139
|
+
|
|
140
|
+
guardReadPath(statusFile, config.workspaceRoot, config.tasksDir);
|
|
141
|
+
guardSensitivePath(statusFile);
|
|
142
|
+
|
|
143
|
+
if (!existsSync(statusFile)) {
|
|
144
|
+
throw new Error(`Task not found: "${taskId}". Check the task ID or call list_tasks.`);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
const raw = readFileSync(statusFile, "utf-8");
|
|
148
|
+
const status = JSON.parse(raw) as Record<string, unknown>;
|
|
149
|
+
const runtime = readTaskRuntime(taskDir);
|
|
150
|
+
const nowMs = Date.now();
|
|
151
|
+
|
|
152
|
+
const statusStr = typeof status.status === "string" ? status.status : "unknown";
|
|
153
|
+
const phaseStr = (runtime.phase || (typeof status.phase === "string" ? status.phase : "queued")) as string;
|
|
154
|
+
|
|
155
|
+
// ── Terminal tasks: no diagnosis needed ──
|
|
156
|
+
const terminalStatuses = new Set([
|
|
157
|
+
"done", "done_by_agent", "failed", "failed_verification",
|
|
158
|
+
"failed_scope_violation", "failed_policy_violation",
|
|
159
|
+
"failed_stale", "orphaned", "canceled",
|
|
160
|
+
]);
|
|
161
|
+
if (terminalStatuses.has(statusStr)) {
|
|
162
|
+
return buildTerminalDiagnosis(taskId, statusStr, phaseStr);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ── Only diagnose running / collecting_artifacts / pending ──
|
|
166
|
+
if (statusStr !== "running" && statusStr !== "collecting_artifacts" && statusStr !== "pending") {
|
|
167
|
+
return buildUnknownDiagnosis(taskId, statusStr, phaseStr, [`unexpected status "${statusStr}" for diagnosis`]);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// ── Gather evidence ──
|
|
171
|
+
const heartbeatStr =
|
|
172
|
+
(typeof runtime.last_heartbeat_at === "string" && runtime.last_heartbeat_at) ||
|
|
173
|
+
(typeof status.last_heartbeat_at === "string" && status.last_heartbeat_at) ||
|
|
174
|
+
(typeof status.updated_at === "string" && status.updated_at) ||
|
|
175
|
+
null;
|
|
176
|
+
const heartbeatMs = heartbeatStr ? Date.parse(heartbeatStr) : NaN;
|
|
177
|
+
const heartbeatAgeSeconds = Number.isFinite(heartbeatMs)
|
|
178
|
+
? Math.max(0, Math.round((nowMs - heartbeatMs) / 1000))
|
|
179
|
+
: null;
|
|
180
|
+
|
|
181
|
+
const stdoutPath = join(taskDir, "stdout.log");
|
|
182
|
+
const stderrPath = join(taskDir, "stderr.log");
|
|
183
|
+
const stdoutStat = fileAgeSeconds(stdoutPath, nowMs);
|
|
184
|
+
const stderrStat = fileAgeSeconds(stderrPath, nowMs);
|
|
185
|
+
|
|
186
|
+
const resultMdPath = join(taskDir, "result.md");
|
|
187
|
+
const testLogPath = join(taskDir, "test.log");
|
|
188
|
+
const gitDiffPath = join(taskDir, "git.diff");
|
|
189
|
+
const artifactManifestPath = join(taskDir, "artifact_manifest.json");
|
|
190
|
+
|
|
191
|
+
const hasResultMd = existsSync(resultMdPath);
|
|
192
|
+
const hasTestLog = existsSync(testLogPath);
|
|
193
|
+
const hasGitDiff = existsSync(gitDiffPath);
|
|
194
|
+
const hasArtifactManifest = existsSync(artifactManifestPath);
|
|
195
|
+
|
|
196
|
+
const childPid = typeof runtime.child_pid === "number" ? runtime.child_pid : null;
|
|
197
|
+
const childStartedAt = typeof runtime.child_started_at === "string" ? runtime.child_started_at : null;
|
|
198
|
+
const childPidAlive = childPid !== null ? isPidAlive(childPid) : null;
|
|
199
|
+
|
|
200
|
+
// PID reuse heuristic: PID is alive but child_started_at is much older than
|
|
201
|
+
// the heartbeat threshold AND logs are stale. We cannot reliably read the
|
|
202
|
+
// OS process start time without platform-specific code, so we treat
|
|
203
|
+
// "PID alive + everything else stale" as a PID-reuse signal rather than
|
|
204
|
+
// "active". This is the conservative choice required by the safety contract.
|
|
205
|
+
const pidReuseSuspected =
|
|
206
|
+
childPidAlive === true &&
|
|
207
|
+
heartbeatAgeSeconds !== null &&
|
|
208
|
+
heartbeatAgeSeconds > HEARTBEAT_STALE_SECONDS &&
|
|
209
|
+
(stdoutStat === null || stdoutStat.age > LOG_STALE_SECONDS) &&
|
|
210
|
+
(stderrStat === null || stderrStat.age > LOG_STALE_SECONDS);
|
|
211
|
+
|
|
212
|
+
const ownership = isWatcherOwningTask(taskDir, config);
|
|
213
|
+
const currentWatcherInstanceId = readWatcherInstanceId(config);
|
|
214
|
+
|
|
215
|
+
const collectingArtifactsPhase =
|
|
216
|
+
phaseStr === "collecting_artifacts" || statusStr === "collecting_artifacts";
|
|
217
|
+
|
|
218
|
+
const taskStartedAt =
|
|
219
|
+
typeof runtime.task_started_at === "string"
|
|
220
|
+
? runtime.task_started_at
|
|
221
|
+
: typeof status.started_at === "string"
|
|
222
|
+
? (status.started_at as string)
|
|
223
|
+
: null;
|
|
224
|
+
const statusUpdatedAt = typeof status.updated_at === "string" ? (status.updated_at as string) : null;
|
|
225
|
+
|
|
226
|
+
const evidence: DiagnosisEvidence = {
|
|
227
|
+
heartbeat_age_seconds: heartbeatAgeSeconds,
|
|
228
|
+
stdout_age_seconds: stdoutStat?.age ?? null,
|
|
229
|
+
stderr_age_seconds: stderrStat?.age ?? null,
|
|
230
|
+
stdout_size_bytes: stdoutStat?.size ?? null,
|
|
231
|
+
stderr_size_bytes: stderrStat?.size ?? null,
|
|
232
|
+
child_pid: childPid,
|
|
233
|
+
child_pid_alive: childPidAlive,
|
|
234
|
+
child_started_at: childStartedAt,
|
|
235
|
+
pid_reuse_suspected: pidReuseSuspected,
|
|
236
|
+
watcher_instance_id: ownership.task_watcher_instance_id,
|
|
237
|
+
current_watcher_instance_id: currentWatcherInstanceId,
|
|
238
|
+
watcher_owns_task: ownership.owned,
|
|
239
|
+
watcher_ownership_reason: ownership.reason,
|
|
240
|
+
has_result_md: hasResultMd,
|
|
241
|
+
has_test_log: hasTestLog,
|
|
242
|
+
has_git_diff: hasGitDiff,
|
|
243
|
+
has_artifact_manifest: hasArtifactManifest,
|
|
244
|
+
task_started_at: taskStartedAt,
|
|
245
|
+
status_updated_at: statusUpdatedAt,
|
|
246
|
+
collecting_artifacts_phase: collectingArtifactsPhase,
|
|
247
|
+
};
|
|
248
|
+
|
|
249
|
+
// ── Decision tree ──
|
|
250
|
+
//
|
|
251
|
+
// The diagnosis must NEVER rely on a single signal. Each branch below
|
|
252
|
+
// combines at least two signals before producing a high-confidence
|
|
253
|
+
// conclusion. When signals conflict, confidence drops to medium/low.
|
|
254
|
+
|
|
255
|
+
const reasons: string[] = [];
|
|
256
|
+
const heartbeatStale = heartbeatAgeSeconds !== null && heartbeatAgeSeconds > HEARTBEAT_STALE_SECONDS;
|
|
257
|
+
const heartbeatPossiblyStale =
|
|
258
|
+
heartbeatAgeSeconds !== null &&
|
|
259
|
+
heartbeatAgeSeconds > HEARTBEAT_POSSIBLY_STALE_SECONDS &&
|
|
260
|
+
heartbeatAgeSeconds <= HEARTBEAT_STALE_SECONDS;
|
|
261
|
+
const heartbeatFresh = heartbeatAgeSeconds !== null && heartbeatAgeSeconds <= HEARTBEAT_POSSIBLY_STALE_SECONDS;
|
|
262
|
+
const logsStale =
|
|
263
|
+
(stdoutStat === null || stdoutStat.age > LOG_STALE_SECONDS) &&
|
|
264
|
+
(stderrStat === null || stderrStat.age > LOG_STALE_SECONDS);
|
|
265
|
+
const logsGrowing =
|
|
266
|
+
stdoutStat !== null && stdoutStat.age <= HEARTBEAT_POSSIBLY_STALE_SECONDS;
|
|
267
|
+
|
|
268
|
+
// Collect artifact evidence strings
|
|
269
|
+
if (heartbeatStale) reasons.push(`heartbeat older than ${HEARTBEAT_STALE_SECONDS}s (age: ${heartbeatAgeSeconds}s)`);
|
|
270
|
+
if (heartbeatPossiblyStale) reasons.push(`heartbeat older than ${HEARTBEAT_POSSIBLY_STALE_SECONDS}s (age: ${heartbeatAgeSeconds}s)`);
|
|
271
|
+
if (stdoutStat && stdoutStat.age > LOG_STALE_SECONDS) reasons.push(`stdout.log unchanged for ${stdoutStat.age}s`);
|
|
272
|
+
if (stderrStat && stderrStat.age > LOG_STALE_SECONDS) reasons.push(`stderr.log unchanged for ${stderrStat.age}s`);
|
|
273
|
+
if (childPidAlive === false) reasons.push(`child_pid ${childPid} is not alive`);
|
|
274
|
+
if (pidReuseSuspected) reasons.push(`PID ${childPid} is alive but all other signals are stale — suspected PID reuse`);
|
|
275
|
+
if (!ownership.owned && ownership.reason === "instance_mismatch") {
|
|
276
|
+
reasons.push(`watcher_instance_id mismatch: task=${ownership.task_watcher_instance_id}, current=${ownership.current_watcher_instance_id}`);
|
|
277
|
+
}
|
|
278
|
+
if (!ownership.owned && ownership.reason === "watcher_missing") {
|
|
279
|
+
reasons.push("watcher heartbeat is missing — no live watcher owns this task");
|
|
280
|
+
}
|
|
281
|
+
if (!ownership.owned && ownership.reason === "no_runtime_record") {
|
|
282
|
+
reasons.push("task runtime has no watcher_instance_id recorded — ownership cannot be confirmed");
|
|
283
|
+
}
|
|
284
|
+
if (hasResultMd) reasons.push("result.md already exists while status is still running");
|
|
285
|
+
if (hasTestLog) reasons.push("test.log already exists while status is still running");
|
|
286
|
+
|
|
287
|
+
// ── done_candidate: artifacts exist but status not converged ──
|
|
288
|
+
// Strong signal: result.md + test.log + git.diff all exist, status still running.
|
|
289
|
+
// Medium signal: only result.md exists.
|
|
290
|
+
if (hasResultMd && (hasTestLog || hasGitDiff)) {
|
|
291
|
+
return buildResult(taskId, statusStr, phaseStr, "done_candidate", "high", reasons, [
|
|
292
|
+
"mark_done_by_agent",
|
|
293
|
+
"leave_unchanged",
|
|
294
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
295
|
+
}
|
|
296
|
+
if (hasResultMd && collectingArtifactsPhase) {
|
|
297
|
+
return buildResult(taskId, statusStr, phaseStr, "done_candidate", "medium", reasons, [
|
|
298
|
+
"mark_done_by_agent",
|
|
299
|
+
"collect_artifacts",
|
|
300
|
+
"leave_unchanged",
|
|
301
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
// ── orphaned_running: watcher does not own the task ──
|
|
305
|
+
if (!ownership.owned && (ownership.reason === "instance_mismatch" || ownership.reason === "watcher_missing")) {
|
|
306
|
+
return buildResult(taskId, statusStr, phaseStr, "orphaned_running", "high", reasons, [
|
|
307
|
+
"mark_orphaned",
|
|
308
|
+
"leave_unchanged",
|
|
309
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ── artifact_collection_stuck: in collecting_artifacts phase and stuck ──
|
|
313
|
+
if (collectingArtifactsPhase) {
|
|
314
|
+
const collectionStuck =
|
|
315
|
+
(heartbeatStale || heartbeatPossiblyStale) && (logsStale || childPidAlive === false);
|
|
316
|
+
if (collectionStuck) {
|
|
317
|
+
return buildResult(taskId, statusStr, phaseStr, "artifact_collection_stuck", "high", reasons, [
|
|
318
|
+
"recollect_artifacts",
|
|
319
|
+
"mark_failed_stale",
|
|
320
|
+
"leave_unchanged",
|
|
321
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
322
|
+
}
|
|
323
|
+
// Collecting but not clearly stuck — possibly still active
|
|
324
|
+
if (heartbeatFresh || logsGrowing) {
|
|
325
|
+
return buildResult(taskId, statusStr, phaseStr, "active_running", "medium", reasons, [
|
|
326
|
+
"leave_unchanged",
|
|
327
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
328
|
+
}
|
|
329
|
+
return buildResult(taskId, statusStr, phaseStr, "artifact_collection_stuck", "medium", reasons, [
|
|
330
|
+
"recollect_artifacts",
|
|
331
|
+
"leave_unchanged",
|
|
332
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// ── active_running: heartbeat fresh, watcher owns, logs growing or PID alive ──
|
|
336
|
+
if (heartbeatFresh && ownership.owned && (logsGrowing || childPidAlive === true)) {
|
|
337
|
+
return buildResult(taskId, statusStr, phaseStr, "active_running", "high", reasons, [
|
|
338
|
+
"leave_unchanged",
|
|
339
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ── stale_running: heartbeat stale, PID dead, watcher doesn't own ──
|
|
343
|
+
if (heartbeatStale && (childPidAlive === false || !ownership.owned) && logsStale) {
|
|
344
|
+
return buildResult(taskId, statusStr, phaseStr, "stale_running", "high", reasons, [
|
|
345
|
+
"mark_failed_stale",
|
|
346
|
+
"collect_artifacts",
|
|
347
|
+
"leave_unchanged",
|
|
348
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
// ── possibly_stale_running: some stale signals but not enough for high confidence ──
|
|
352
|
+
// This branch catches the PID-reuse-suspected case as well: PID is alive
|
|
353
|
+
// but everything else is stale. We refuse to call it "active".
|
|
354
|
+
if (pidReuseSuspected) {
|
|
355
|
+
return buildResult(taskId, statusStr, phaseStr, "possibly_stale_running", "medium", reasons, [
|
|
356
|
+
"leave_unchanged",
|
|
357
|
+
"mark_failed_stale",
|
|
358
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
359
|
+
}
|
|
360
|
+
if (heartbeatStale || (heartbeatPossiblyStale && (logsStale || !ownership.owned))) {
|
|
361
|
+
return buildResult(taskId, statusStr, phaseStr, "possibly_stale_running", "medium", reasons, [
|
|
362
|
+
"leave_unchanged",
|
|
363
|
+
"mark_failed_stale",
|
|
364
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
365
|
+
}
|
|
366
|
+
|
|
367
|
+
// ── unknown: insufficient evidence ──
|
|
368
|
+
return buildResult(taskId, statusStr, phaseStr, "unknown", "low", reasons, [
|
|
369
|
+
"leave_unchanged",
|
|
370
|
+
], evidence, input.include_logs ? { stdoutPath, stderrPath } : null);
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// ── Result builders ─────────────────────────────────────────────────
|
|
374
|
+
|
|
375
|
+
function buildResult(
|
|
376
|
+
taskId: string,
|
|
377
|
+
status: string,
|
|
378
|
+
phase: string | null,
|
|
379
|
+
diagnosis: DiagnosisType,
|
|
380
|
+
confidence: DiagnosisConfidence,
|
|
381
|
+
reasons: string[],
|
|
382
|
+
safeActions: SafeAction[],
|
|
383
|
+
evidence: DiagnosisEvidence,
|
|
384
|
+
logPaths: { stdoutPath: string; stderrPath: string } | null
|
|
385
|
+
): DiagnoseTaskOutput {
|
|
386
|
+
return {
|
|
387
|
+
task_id: taskId,
|
|
388
|
+
status,
|
|
389
|
+
phase,
|
|
390
|
+
diagnosis,
|
|
391
|
+
confidence,
|
|
392
|
+
reasons,
|
|
393
|
+
safe_actions: safeActions,
|
|
394
|
+
evidence,
|
|
395
|
+
logs: logPaths
|
|
396
|
+
? {
|
|
397
|
+
stdout_tail: readTailLines(logPaths.stdoutPath, LOG_TAIL_LINES),
|
|
398
|
+
stderr_tail: readTailLines(logPaths.stderrPath, Math.min(LOG_TAIL_LINES, 20)),
|
|
399
|
+
}
|
|
400
|
+
: null,
|
|
401
|
+
};
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
function buildTerminalDiagnosis(taskId: string, status: string, phase: string | null): DiagnoseTaskOutput {
|
|
405
|
+
return {
|
|
406
|
+
task_id: taskId,
|
|
407
|
+
status,
|
|
408
|
+
phase,
|
|
409
|
+
diagnosis: "terminal",
|
|
410
|
+
confidence: "high",
|
|
411
|
+
reasons: [`status "${status}" is already terminal; no reconciliation needed`],
|
|
412
|
+
safe_actions: ["leave_unchanged"],
|
|
413
|
+
evidence: emptyEvidence(),
|
|
414
|
+
logs: null,
|
|
415
|
+
};
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
function buildUnknownDiagnosis(
|
|
419
|
+
taskId: string,
|
|
420
|
+
status: string,
|
|
421
|
+
phase: string | null,
|
|
422
|
+
extraReasons: string[]
|
|
423
|
+
): DiagnoseTaskOutput {
|
|
424
|
+
return {
|
|
425
|
+
task_id: taskId,
|
|
426
|
+
status,
|
|
427
|
+
phase,
|
|
428
|
+
diagnosis: "unknown",
|
|
429
|
+
confidence: "low",
|
|
430
|
+
reasons: ["insufficient evidence to diagnose", ...extraReasons],
|
|
431
|
+
safe_actions: ["leave_unchanged"],
|
|
432
|
+
evidence: emptyEvidence(),
|
|
433
|
+
logs: null,
|
|
434
|
+
};
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
function emptyEvidence(): DiagnosisEvidence {
|
|
438
|
+
return {
|
|
439
|
+
heartbeat_age_seconds: null,
|
|
440
|
+
stdout_age_seconds: null,
|
|
441
|
+
stderr_age_seconds: null,
|
|
442
|
+
stdout_size_bytes: null,
|
|
443
|
+
stderr_size_bytes: null,
|
|
444
|
+
child_pid: null,
|
|
445
|
+
child_pid_alive: null,
|
|
446
|
+
child_started_at: null,
|
|
447
|
+
pid_reuse_suspected: false,
|
|
448
|
+
watcher_instance_id: null,
|
|
449
|
+
current_watcher_instance_id: null,
|
|
450
|
+
watcher_owns_task: false,
|
|
451
|
+
watcher_ownership_reason: "no_runtime_record",
|
|
452
|
+
has_result_md: false,
|
|
453
|
+
has_test_log: false,
|
|
454
|
+
has_git_diff: false,
|
|
455
|
+
has_artifact_manifest: false,
|
|
456
|
+
task_started_at: null,
|
|
457
|
+
status_updated_at: null,
|
|
458
|
+
collecting_artifacts_phase: false,
|
|
459
|
+
};
|
|
460
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1.0.0 Part B: discard_worktree MCP 工具 handler — 移除隔离 worktree 与临时
|
|
3
|
+
* branch,并将最终状态归档到 .patchwarden/worktree-archive/。
|
|
4
|
+
*
|
|
5
|
+
* 参考 src/tools/auditTask.ts 的导出/返回风格。
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { getConfig } from "../config.js";
|
|
9
|
+
import { guardWorkspacePath } from "../security/pathGuard.js";
|
|
10
|
+
import { discardWorktree } from "../goal/worktreeManager.js";
|
|
11
|
+
|
|
12
|
+
export interface DiscardWorktreeInput {
|
|
13
|
+
worktree_id: string;
|
|
14
|
+
repo_path: string;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface DiscardWorktreeOutput {
|
|
18
|
+
worktree_id: string;
|
|
19
|
+
status: "discarded";
|
|
20
|
+
discarded_at: string;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* 丢弃指定 worktree:移除 worktree 目录、删除临时 branch、归档状态。
|
|
25
|
+
*
|
|
26
|
+
* @throws PatchWardenError("workspace_path_escape") 当 repo_path 逃逸出 workspaceRoot
|
|
27
|
+
* @throws PatchWardenError("worktree_not_found") 当 worktree 不存在
|
|
28
|
+
* @throws PatchWardenError("invalid_worktree_state") 当 worktree 非 active
|
|
29
|
+
* @throws PatchWardenError("worktree_discard_failed") 当 git worktree remove 失败
|
|
30
|
+
*/
|
|
31
|
+
export function discardWorktreeTool(input: DiscardWorktreeInput): DiscardWorktreeOutput {
|
|
32
|
+
const config = getConfig();
|
|
33
|
+
const repoPathSafe = guardWorkspacePath(input.repo_path, config.workspaceRoot);
|
|
34
|
+
|
|
35
|
+
discardWorktree(input.worktree_id, repoPathSafe);
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
worktree_id: input.worktree_id,
|
|
39
|
+
status: "discarded",
|
|
40
|
+
discarded_at: new Date().toISOString(),
|
|
41
|
+
};
|
|
42
|
+
}
|