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,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* v1.0.0 Part B: merge_worktree MCP 工具 handler — 把隔离 worktree 的 branch
|
|
3
|
+
* 合并回主工作区,并将 worktree 状态置为 "merged"。
|
|
4
|
+
*
|
|
5
|
+
* 参考 src/tools/auditTask.ts 的导出/返回风格。
|
|
6
|
+
*/
|
|
7
|
+
import { getConfig } from "../config.js";
|
|
8
|
+
import { guardWorkspacePath } from "../security/pathGuard.js";
|
|
9
|
+
import { mergeWorktree } from "../goal/worktreeManager.js";
|
|
10
|
+
/**
|
|
11
|
+
* 合并指定 worktree 的 branch 回 repo_path(主工作区)。
|
|
12
|
+
*
|
|
13
|
+
* @throws PatchWardenError("workspace_path_escape") 当 repo_path 逃逸出 workspaceRoot
|
|
14
|
+
* @throws PatchWardenError("worktree_not_found") 当 worktree 不存在
|
|
15
|
+
* @throws PatchWardenError("invalid_worktree_state") 当 worktree 非 active
|
|
16
|
+
* @throws PatchWardenError("worktree_merge_failed") 当 git merge 失败
|
|
17
|
+
*/
|
|
18
|
+
export function mergeWorktreeTool(input) {
|
|
19
|
+
const config = getConfig();
|
|
20
|
+
const repoPathSafe = guardWorkspacePath(input.repo_path, config.workspaceRoot);
|
|
21
|
+
mergeWorktree(input.worktree_id, repoPathSafe);
|
|
22
|
+
return {
|
|
23
|
+
worktree_id: input.worktree_id,
|
|
24
|
+
status: "merged",
|
|
25
|
+
merged_at: new Date().toISOString(),
|
|
26
|
+
};
|
|
27
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
export interface RecommendAgentForTaskInput {
|
|
2
|
+
repo_path: string;
|
|
3
|
+
goal: string;
|
|
4
|
+
scope_files?: string[];
|
|
5
|
+
template?: string;
|
|
6
|
+
risk_hint?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface RecommendAgentForTaskOutput {
|
|
9
|
+
repo_path: string;
|
|
10
|
+
resolved_repo_path: string;
|
|
11
|
+
recommended_agent: string;
|
|
12
|
+
fallback_agent: string | null;
|
|
13
|
+
fallback: boolean;
|
|
14
|
+
reason: string;
|
|
15
|
+
risk_notes: string[];
|
|
16
|
+
suggested_verify_commands: string[];
|
|
17
|
+
bounded: true;
|
|
18
|
+
}
|
|
19
|
+
export declare function recommendAgentForTask(input: RecommendAgentForTaskInput): RecommendAgentForTaskOutput;
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import { getConfig } from "../config.js";
|
|
2
|
+
import { guardWorkspacePath } from "../security/pathGuard.js";
|
|
3
|
+
import { routeAgent } from "../agents/agentRouter.js";
|
|
4
|
+
export function recommendAgentForTask(input) {
|
|
5
|
+
const config = getConfig();
|
|
6
|
+
const repoPath = String(input.repo_path || "").trim();
|
|
7
|
+
const goal = String(input.goal || "").trim();
|
|
8
|
+
if (!repoPath)
|
|
9
|
+
throw new Error("repo_path is required.");
|
|
10
|
+
if (!goal)
|
|
11
|
+
throw new Error("goal is required.");
|
|
12
|
+
const resolvedRepoPath = guardWorkspacePath(repoPath, config.workspaceRoot);
|
|
13
|
+
const configuredAgents = Object.keys(config.agents);
|
|
14
|
+
const route = routeAgent({
|
|
15
|
+
goal,
|
|
16
|
+
scope: normalizeScope(input.scope_files),
|
|
17
|
+
inline_plan: input.risk_hint,
|
|
18
|
+
template: input.template,
|
|
19
|
+
configuredAgents,
|
|
20
|
+
});
|
|
21
|
+
return {
|
|
22
|
+
repo_path: repoPath,
|
|
23
|
+
resolved_repo_path: resolvedRepoPath,
|
|
24
|
+
recommended_agent: route.recommended_agent,
|
|
25
|
+
fallback_agent: route.fallback ? route.recommended_agent : configuredAgents.find((name) => name !== route.recommended_agent) || null,
|
|
26
|
+
fallback: route.fallback,
|
|
27
|
+
reason: route.reason,
|
|
28
|
+
risk_notes: buildRiskNotes(goal, input.risk_hint),
|
|
29
|
+
suggested_verify_commands: suggestVerifyCommands(config.allowedTestCommands),
|
|
30
|
+
bounded: true,
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function normalizeScope(value) {
|
|
34
|
+
if (!Array.isArray(value))
|
|
35
|
+
return undefined;
|
|
36
|
+
return value.map((entry) => String(entry).trim()).filter(Boolean).slice(0, 50);
|
|
37
|
+
}
|
|
38
|
+
function buildRiskNotes(goal, riskHint) {
|
|
39
|
+
const text = `${goal} ${riskHint || ""}`.toLowerCase();
|
|
40
|
+
const notes = [];
|
|
41
|
+
if (/release|publish|push|tag|npm/.test(text)) {
|
|
42
|
+
notes.push("release_or_remote_write_language_detected");
|
|
43
|
+
}
|
|
44
|
+
if (/secret|token|cookie|\.env|ssh/.test(text)) {
|
|
45
|
+
notes.push("sensitive_file_language_detected");
|
|
46
|
+
}
|
|
47
|
+
if (/refactor|rewrite|redesign|migration/.test(text)) {
|
|
48
|
+
notes.push("broad_change_language_detected");
|
|
49
|
+
}
|
|
50
|
+
return notes.slice(0, 8);
|
|
51
|
+
}
|
|
52
|
+
function suggestVerifyCommands(commands) {
|
|
53
|
+
const preferred = ["npm test", "npm run test", "npm run build", "npm run lint"];
|
|
54
|
+
const configured = new Set(commands);
|
|
55
|
+
return preferred.filter((command) => configured.has(command)).slice(0, 4);
|
|
56
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type PatchWardenConfig } from "../config.js";
|
|
2
|
+
import { type DiagnosisType, type DiagnosisConfidence, type SafeAction } from "./diagnoseTask.js";
|
|
3
|
+
export type ReconcileMode = "report_only" | "safe_fix";
|
|
4
|
+
export interface ReconcileTasksInput {
|
|
5
|
+
max_age_minutes?: number;
|
|
6
|
+
mode?: ReconcileMode;
|
|
7
|
+
include_done_candidates?: boolean;
|
|
8
|
+
}
|
|
9
|
+
export interface ReconcileTaskReport {
|
|
10
|
+
task_id: string;
|
|
11
|
+
status: string;
|
|
12
|
+
phase: string | null;
|
|
13
|
+
diagnosis: DiagnosisType;
|
|
14
|
+
confidence: DiagnosisConfidence;
|
|
15
|
+
reasons: string[];
|
|
16
|
+
safe_actions: SafeAction[];
|
|
17
|
+
age_seconds: number | null;
|
|
18
|
+
action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent";
|
|
19
|
+
previous_status: string | null;
|
|
20
|
+
new_status: string | null;
|
|
21
|
+
applied_at: string | null;
|
|
22
|
+
applied_by: string | null;
|
|
23
|
+
evidence_summary: {
|
|
24
|
+
heartbeat_age_seconds: number | null;
|
|
25
|
+
stdout_age_seconds: number | null;
|
|
26
|
+
child_pid: number | null;
|
|
27
|
+
child_pid_alive: boolean | null;
|
|
28
|
+
watcher_owns_task: boolean;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
export interface ReconcileTasksOutput {
|
|
32
|
+
mode: ReconcileMode;
|
|
33
|
+
scanned: number;
|
|
34
|
+
candidates: number;
|
|
35
|
+
reconciled: number;
|
|
36
|
+
skipped_low_confidence: number;
|
|
37
|
+
skipped_active_watcher: number;
|
|
38
|
+
reports: ReconcileTaskReport[];
|
|
39
|
+
reconcile_log_path: string | null;
|
|
40
|
+
}
|
|
41
|
+
export declare function reconcileTasks(input?: ReconcileTasksInput, config?: PatchWardenConfig): ReconcileTasksOutput;
|
|
@@ -0,0 +1,352 @@
|
|
|
1
|
+
import { existsSync, readdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, mkdirSync, statSync, } from "node:fs";
|
|
2
|
+
import { join, resolve, dirname } from "node:path";
|
|
3
|
+
import { getTasksDir, getConfig } from "../config.js";
|
|
4
|
+
import { diagnoseTask } from "./diagnoseTask.js";
|
|
5
|
+
import { syncSubgoalOnTaskDone, readTaskGoalMeta } from "../goal/subgoalSync.js";
|
|
6
|
+
// ── Defaults ────────────────────────────────────────────────────────
|
|
7
|
+
const DEFAULT_MAX_AGE_MINUTES = 30;
|
|
8
|
+
const RECONCILE_LOG_NAME = "reconcile.log";
|
|
9
|
+
// ── Main entry point ──────────────────────────────────────────────
|
|
10
|
+
export function reconcileTasks(input = {}, config = getConfig()) {
|
|
11
|
+
const mode = input.mode === "safe_fix" ? "safe_fix" : "report_only";
|
|
12
|
+
const maxAgeMinutes = typeof input.max_age_minutes === "number" && input.max_age_minutes > 0
|
|
13
|
+
? Math.min(input.max_age_minutes, 24 * 60)
|
|
14
|
+
: DEFAULT_MAX_AGE_MINUTES;
|
|
15
|
+
const includeDoneCandidates = input.include_done_candidates !== false; // default true
|
|
16
|
+
const maxAgeSeconds = maxAgeMinutes * 60;
|
|
17
|
+
const tasksDir = getTasksDir(config);
|
|
18
|
+
const reports = [];
|
|
19
|
+
let scanned = 0;
|
|
20
|
+
let candidates = 0;
|
|
21
|
+
let reconciled = 0;
|
|
22
|
+
let skippedLowConfidence = 0;
|
|
23
|
+
let skippedActiveWatcher = 0;
|
|
24
|
+
const nowMs = Date.now();
|
|
25
|
+
if (!existsSync(tasksDir)) {
|
|
26
|
+
return {
|
|
27
|
+
mode,
|
|
28
|
+
scanned: 0,
|
|
29
|
+
candidates: 0,
|
|
30
|
+
reconciled: 0,
|
|
31
|
+
skipped_low_confidence: 0,
|
|
32
|
+
skipped_active_watcher: 0,
|
|
33
|
+
reports: [],
|
|
34
|
+
reconcile_log_path: null,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
let entries = [];
|
|
38
|
+
try {
|
|
39
|
+
entries = readdirSync(tasksDir, { withFileTypes: true });
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return {
|
|
43
|
+
mode,
|
|
44
|
+
scanned: 0,
|
|
45
|
+
candidates: 0,
|
|
46
|
+
reconciled: 0,
|
|
47
|
+
skipped_low_confidence: 0,
|
|
48
|
+
skipped_active_watcher: 0,
|
|
49
|
+
reports: [],
|
|
50
|
+
reconcile_log_path: null,
|
|
51
|
+
};
|
|
52
|
+
}
|
|
53
|
+
const taskDirs = entries.filter((e) => e.isDirectory());
|
|
54
|
+
for (const entry of taskDirs) {
|
|
55
|
+
scanned += 1;
|
|
56
|
+
const taskId = entry.name;
|
|
57
|
+
const taskDir = resolve(tasksDir, taskId);
|
|
58
|
+
const statusFile = join(taskDir, "status.json");
|
|
59
|
+
if (!existsSync(statusFile))
|
|
60
|
+
continue;
|
|
61
|
+
let statusData;
|
|
62
|
+
try {
|
|
63
|
+
statusData = JSON.parse(readFileSync(statusFile, "utf-8"));
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
continue; // corrupted status, skip
|
|
67
|
+
}
|
|
68
|
+
const statusStr = typeof statusData.status === "string" ? statusData.status : "unknown";
|
|
69
|
+
// Filter: only running / collecting_artifacts / (optionally) done_by_agent candidates
|
|
70
|
+
const isCandidate = statusStr === "running" ||
|
|
71
|
+
statusStr === "collecting_artifacts" ||
|
|
72
|
+
(includeDoneCandidates && statusStr === "done_by_agent");
|
|
73
|
+
if (!isCandidate)
|
|
74
|
+
continue;
|
|
75
|
+
// Filter by age — use the oldest reliable timestamp on the task
|
|
76
|
+
const ageSeconds = taskAgeSeconds(taskDir, statusData, nowMs);
|
|
77
|
+
if (ageSeconds !== null && ageSeconds < maxAgeSeconds)
|
|
78
|
+
continue;
|
|
79
|
+
candidates += 1;
|
|
80
|
+
// Diagnose the task
|
|
81
|
+
let diagnosis;
|
|
82
|
+
try {
|
|
83
|
+
diagnosis = diagnoseTask({ task_id: taskId }, config);
|
|
84
|
+
}
|
|
85
|
+
catch {
|
|
86
|
+
continue; // diagnosis failed — skip
|
|
87
|
+
}
|
|
88
|
+
const report = {
|
|
89
|
+
task_id: taskId,
|
|
90
|
+
status: diagnosis.status,
|
|
91
|
+
phase: diagnosis.phase,
|
|
92
|
+
diagnosis: diagnosis.diagnosis,
|
|
93
|
+
confidence: diagnosis.confidence,
|
|
94
|
+
reasons: diagnosis.reasons,
|
|
95
|
+
safe_actions: diagnosis.safe_actions,
|
|
96
|
+
age_seconds: ageSeconds,
|
|
97
|
+
action_taken: "left_unchanged",
|
|
98
|
+
previous_status: null,
|
|
99
|
+
new_status: null,
|
|
100
|
+
applied_at: null,
|
|
101
|
+
applied_by: null,
|
|
102
|
+
evidence_summary: {
|
|
103
|
+
heartbeat_age_seconds: diagnosis.evidence.heartbeat_age_seconds,
|
|
104
|
+
stdout_age_seconds: diagnosis.evidence.stdout_age_seconds,
|
|
105
|
+
child_pid: diagnosis.evidence.child_pid,
|
|
106
|
+
child_pid_alive: diagnosis.evidence.child_pid_alive,
|
|
107
|
+
watcher_owns_task: diagnosis.evidence.watcher_owns_task,
|
|
108
|
+
},
|
|
109
|
+
};
|
|
110
|
+
// ── safe_fix rules ──
|
|
111
|
+
//
|
|
112
|
+
// safe_fix is ONLY applied when:
|
|
113
|
+
// 1. mode === "safe_fix"
|
|
114
|
+
// 2. diagnosis.confidence === "high"
|
|
115
|
+
// 3. The task is NOT still owned by an active watcher (we must not
|
|
116
|
+
// touch tasks the live watcher is executing).
|
|
117
|
+
// 4. The diagnosis type maps to a reconcilable action.
|
|
118
|
+
//
|
|
119
|
+
// Anything else is left_unchanged and recorded for audit.
|
|
120
|
+
if (mode === "safe_fix" && diagnosis.confidence === "high") {
|
|
121
|
+
if (diagnosis.evidence.watcher_owns_task) {
|
|
122
|
+
// Hard rule: do not touch tasks the live watcher still owns.
|
|
123
|
+
skippedActiveWatcher += 1;
|
|
124
|
+
report.reasons = [
|
|
125
|
+
...report.reasons,
|
|
126
|
+
"safe_fix skipped: task is still owned by an active watcher instance",
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
else {
|
|
130
|
+
const fixResult = applySafeFix(taskDir, taskId, statusData, diagnosis.diagnosis, diagnosis.reasons, diagnosis.evidence, config);
|
|
131
|
+
if (fixResult.applied) {
|
|
132
|
+
report.action_taken = fixResult.action_taken;
|
|
133
|
+
report.previous_status = fixResult.previous_status;
|
|
134
|
+
report.new_status = fixResult.new_status;
|
|
135
|
+
report.applied_at = fixResult.applied_at;
|
|
136
|
+
report.applied_by = "reconcile_tasks";
|
|
137
|
+
reconciled += 1;
|
|
138
|
+
}
|
|
139
|
+
else {
|
|
140
|
+
// Diagnosis type does not map to a safe_fix action — leave unchanged
|
|
141
|
+
report.reasons = [
|
|
142
|
+
...report.reasons,
|
|
143
|
+
`safe_fix skipped: diagnosis "${diagnosis.diagnosis}" has no automatic safe_fix action`,
|
|
144
|
+
];
|
|
145
|
+
skippedLowConfidence += 1;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
else if (mode === "safe_fix" && diagnosis.confidence !== "high") {
|
|
150
|
+
skippedLowConfidence += 1;
|
|
151
|
+
report.reasons = [
|
|
152
|
+
...report.reasons,
|
|
153
|
+
`safe_fix skipped: confidence is "${diagnosis.confidence}", only "high" is eligible`,
|
|
154
|
+
];
|
|
155
|
+
}
|
|
156
|
+
if (reports.length < 200) {
|
|
157
|
+
reports.push(report);
|
|
158
|
+
}
|
|
159
|
+
if (reports.length >= 200)
|
|
160
|
+
break;
|
|
161
|
+
}
|
|
162
|
+
// ── Write reconcile.log when safe_fix applied any change ──
|
|
163
|
+
let reconcileLogPath = null;
|
|
164
|
+
if (mode === "safe_fix" && reconciled > 0) {
|
|
165
|
+
reconcileLogPath = writeReconcileLog(tasksDir, config, reports.filter((r) => r.action_taken !== "left_unchanged"));
|
|
166
|
+
}
|
|
167
|
+
return {
|
|
168
|
+
mode,
|
|
169
|
+
scanned,
|
|
170
|
+
candidates,
|
|
171
|
+
reconciled,
|
|
172
|
+
skipped_low_confidence: skippedLowConfidence,
|
|
173
|
+
skipped_active_watcher: skippedActiveWatcher,
|
|
174
|
+
reports,
|
|
175
|
+
reconcile_log_path: reconcileLogPath,
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, evidence, config) {
|
|
179
|
+
// Map diagnosis type to new status. Only high-confidence, well-understood
|
|
180
|
+
// diagnoses are eligible. "possibly_stale_running" and "unknown" are NOT
|
|
181
|
+
// eligible even if confidence were high (which they never are).
|
|
182
|
+
const previousStatus = typeof currentStatus.status === "string" ? currentStatus.status : "unknown";
|
|
183
|
+
let newStatus = null;
|
|
184
|
+
let actionTaken = "left_unchanged";
|
|
185
|
+
switch (diagnosis) {
|
|
186
|
+
case "stale_running":
|
|
187
|
+
newStatus = "failed_stale";
|
|
188
|
+
actionTaken = "marked_failed_stale";
|
|
189
|
+
break;
|
|
190
|
+
case "orphaned_running":
|
|
191
|
+
newStatus = "orphaned";
|
|
192
|
+
actionTaken = "marked_orphaned";
|
|
193
|
+
break;
|
|
194
|
+
case "done_candidate":
|
|
195
|
+
newStatus = "done_by_agent";
|
|
196
|
+
actionTaken = "marked_done_by_agent";
|
|
197
|
+
break;
|
|
198
|
+
case "artifact_collection_stuck":
|
|
199
|
+
// Treat as failed_stale — artifact collection should not hang.
|
|
200
|
+
newStatus = "failed_stale";
|
|
201
|
+
actionTaken = "marked_failed_stale";
|
|
202
|
+
break;
|
|
203
|
+
default:
|
|
204
|
+
// active_running, possibly_stale_running, unknown, terminal — not eligible
|
|
205
|
+
return {
|
|
206
|
+
applied: false,
|
|
207
|
+
action_taken: "left_unchanged",
|
|
208
|
+
previous_status: previousStatus,
|
|
209
|
+
new_status: previousStatus,
|
|
210
|
+
applied_at: new Date().toISOString(),
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
const appliedAt = new Date().toISOString();
|
|
214
|
+
const statusFile = join(taskDir, "status.json");
|
|
215
|
+
const backupFile = join(taskDir, "status.json.bak");
|
|
216
|
+
// 1. Write status.json.bak with current contents (overwrite any prior backup)
|
|
217
|
+
try {
|
|
218
|
+
const currentRaw = readFileSync(statusFile, "utf-8");
|
|
219
|
+
writeFileSync(backupFile, currentRaw, "utf-8");
|
|
220
|
+
}
|
|
221
|
+
catch {
|
|
222
|
+
// If we cannot back up, refuse to apply the fix.
|
|
223
|
+
return {
|
|
224
|
+
applied: false,
|
|
225
|
+
action_taken: "left_unchanged",
|
|
226
|
+
previous_status: previousStatus,
|
|
227
|
+
new_status: previousStatus,
|
|
228
|
+
applied_at: appliedAt,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
// 2. Build the new status record with full audit trail
|
|
232
|
+
const next = {
|
|
233
|
+
...currentStatus,
|
|
234
|
+
status: newStatus,
|
|
235
|
+
phase: newStatus,
|
|
236
|
+
updated_at: appliedAt,
|
|
237
|
+
last_heartbeat_at: appliedAt,
|
|
238
|
+
finished_at: appliedAt,
|
|
239
|
+
// v0.7.0 audit fields
|
|
240
|
+
previous_status: previousStatus,
|
|
241
|
+
diagnosis: {
|
|
242
|
+
type: diagnosis,
|
|
243
|
+
confidence: "high",
|
|
244
|
+
applied_by: "reconcile_tasks",
|
|
245
|
+
applied_at: appliedAt,
|
|
246
|
+
reasons,
|
|
247
|
+
evidence: {
|
|
248
|
+
heartbeat_age_seconds: evidence.heartbeat_age_seconds,
|
|
249
|
+
stdout_age_seconds: evidence.stdout_age_seconds,
|
|
250
|
+
child_pid: evidence.child_pid,
|
|
251
|
+
child_pid_alive: evidence.child_pid_alive,
|
|
252
|
+
watcher_instance_id: evidence.watcher_instance_id,
|
|
253
|
+
current_watcher_instance_id: evidence.current_watcher_instance_id,
|
|
254
|
+
},
|
|
255
|
+
},
|
|
256
|
+
};
|
|
257
|
+
// For done_by_agent, set acceptance_status=pending (per roadmap 3.6)
|
|
258
|
+
if (newStatus === "done_by_agent") {
|
|
259
|
+
next.acceptance_status = "pending";
|
|
260
|
+
next.legacy_status = "done";
|
|
261
|
+
}
|
|
262
|
+
// 3. Atomic write: write to temp file then rename
|
|
263
|
+
const tmpFile = join(taskDir, "status.json.tmp");
|
|
264
|
+
try {
|
|
265
|
+
writeFileSync(tmpFile, JSON.stringify(next, null, 2), "utf-8");
|
|
266
|
+
renameSync(tmpFile, statusFile);
|
|
267
|
+
}
|
|
268
|
+
catch {
|
|
269
|
+
// Best-effort cleanup of temp file
|
|
270
|
+
try {
|
|
271
|
+
if (existsSync(tmpFile)) {
|
|
272
|
+
unlinkSync(tmpFile);
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
catch { /* ignore */ }
|
|
276
|
+
return {
|
|
277
|
+
applied: false,
|
|
278
|
+
action_taken: "left_unchanged",
|
|
279
|
+
previous_status: previousStatus,
|
|
280
|
+
new_status: previousStatus,
|
|
281
|
+
applied_at: appliedAt,
|
|
282
|
+
};
|
|
283
|
+
}
|
|
284
|
+
// v0.8.0: 当状态变为 done_by_agent 时,同步关联 subgoal 状态(running → done_by_agent)
|
|
285
|
+
if (newStatus === "done_by_agent") {
|
|
286
|
+
const goalMeta = readTaskGoalMeta(taskDir);
|
|
287
|
+
if (goalMeta.subgoal_id) {
|
|
288
|
+
syncSubgoalOnTaskDone(taskId, goalMeta, config.workspaceRoot);
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return {
|
|
292
|
+
applied: true,
|
|
293
|
+
action_taken: actionTaken,
|
|
294
|
+
previous_status: previousStatus,
|
|
295
|
+
new_status: newStatus,
|
|
296
|
+
applied_at: appliedAt,
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
// ── reconcile.log writer ───────────────────────────────────────────
|
|
300
|
+
function writeReconcileLog(tasksDir, config, appliedReports) {
|
|
301
|
+
// The reconcile.log lives at the .patchwarden/ root (parent of tasksDir),
|
|
302
|
+
// so it captures every reconcile run across all tasks.
|
|
303
|
+
const logDir = dirname(tasksDir);
|
|
304
|
+
const logPath = join(logDir, RECONCILE_LOG_NAME);
|
|
305
|
+
try {
|
|
306
|
+
mkdirSync(logDir, { recursive: true });
|
|
307
|
+
}
|
|
308
|
+
catch { /* ignore */ }
|
|
309
|
+
const lines = [];
|
|
310
|
+
for (const report of appliedReports) {
|
|
311
|
+
lines.push(JSON.stringify({
|
|
312
|
+
timestamp: report.applied_at,
|
|
313
|
+
task_id: report.task_id,
|
|
314
|
+
previous_status: report.previous_status,
|
|
315
|
+
new_status: report.new_status,
|
|
316
|
+
diagnosis: report.diagnosis,
|
|
317
|
+
confidence: report.confidence,
|
|
318
|
+
applied_by: report.applied_by,
|
|
319
|
+
reasons: report.reasons,
|
|
320
|
+
evidence: report.evidence_summary,
|
|
321
|
+
}));
|
|
322
|
+
}
|
|
323
|
+
// Append — do not overwrite history
|
|
324
|
+
const existing = existsSync(logPath) ? readFileSync(logPath, "utf-8") : "";
|
|
325
|
+
const nextContent = existing + (existing && !existing.endsWith("\n") ? "\n" : "") + lines.join("\n") + "\n";
|
|
326
|
+
try {
|
|
327
|
+
writeFileSync(logPath, nextContent, "utf-8");
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
// If we cannot write the log, the status change still happened —
|
|
331
|
+
// the status.json itself contains the diagnosis audit fields.
|
|
332
|
+
}
|
|
333
|
+
return logPath;
|
|
334
|
+
}
|
|
335
|
+
// ── Task age helper ────────────────────────────────────────────────
|
|
336
|
+
function taskAgeSeconds(taskDir, status, nowMs) {
|
|
337
|
+
// Prefer created_at; fall back to status.json mtime.
|
|
338
|
+
const createdStr = typeof status.created_at === "string" ? status.created_at : null;
|
|
339
|
+
if (createdStr) {
|
|
340
|
+
const ms = Date.parse(createdStr);
|
|
341
|
+
if (Number.isFinite(ms)) {
|
|
342
|
+
return Math.max(0, Math.round((nowMs - ms) / 1000));
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
try {
|
|
346
|
+
const stat = statSync(join(taskDir, "status.json"));
|
|
347
|
+
return Math.max(0, Math.round((nowMs - stat.mtimeMs) / 1000));
|
|
348
|
+
}
|
|
349
|
+
catch {
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
}
|