patchwarden 1.6.1 → 1.6.2
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 +17 -12
- package/README.md +14 -11
- package/dist/assessments/assessmentDiagnostics.d.ts +29 -0
- package/dist/assessments/assessmentDiagnostics.js +132 -0
- package/dist/assessments/assessmentStore.d.ts +39 -0
- package/dist/assessments/assessmentStore.js +166 -12
- package/dist/assessments/securitySnapshot.d.ts +50 -0
- package/dist/assessments/securitySnapshot.js +158 -0
- package/dist/control/routes/status.d.ts +14 -0
- package/dist/control/routes/status.js +22 -0
- package/dist/control/runtime.js +2 -10
- package/dist/control/server.js +1 -0
- package/dist/diagnostics/allowedTestCommandSafety.d.ts +1 -0
- package/dist/diagnostics/allowedTestCommandSafety.js +11 -0
- package/dist/direct/directSessionStore.js +5 -4
- package/dist/doctor.js +3 -5
- package/dist/runner/runTask.d.ts +1 -0
- package/dist/runner/runTask.js +47 -6
- package/dist/runner/taskRuntime.d.ts +2 -0
- package/dist/runner/taskStatusStore.js +12 -1
- package/dist/runner/watch.js +64 -1
- package/dist/smoke-test.js +3 -3
- package/dist/tools/definitions/toolDefs.js +3 -3
- package/dist/tools/diagnostics/auditTask.d.ts +1 -0
- package/dist/tools/diagnostics/auditTask.js +35 -7
- package/dist/tools/diagnostics/healthCheck.d.ts +12 -0
- package/dist/tools/diagnostics/healthCheck.js +29 -1
- package/dist/tools/tasks/cancelTask.js +2 -1
- package/dist/tools/tasks/createTask.d.ts +2 -2
- package/dist/tools/tasks/createTask.js +70 -9
- package/dist/tools/tasks/diagnoseTask.js +4 -8
- package/dist/tools/tasks/getTaskStatus.js +6 -1
- package/dist/tools/tasks/getTaskSummary.js +2 -10
- package/dist/tools/tasks/listTasks.js +2 -1
- package/dist/tools/tasks/reconcileTasks.d.ts +2 -1
- package/dist/tools/tasks/reconcileTasks.js +185 -39
- package/dist/tools/tasks/runTaskLoop.js +5 -11
- package/dist/tools/tasks/taskOutputs.d.ts +2 -2
- package/dist/tools/tasks/taskStates.d.ts +6 -0
- package/dist/tools/tasks/taskStates.js +52 -0
- package/dist/tools/tasks/waitForTask.js +3 -11
- package/dist/tools/workspace/listAgents.d.ts +6 -0
- package/dist/tools/workspace/listAgents.js +12 -2
- package/dist/utils/atomicFile.js +20 -1
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/docs/CODE_WIKI.md +4 -4
- package/docs/open-source-application.md +11 -12
- package/docs/release-evidence.md +33 -47
- package/package.json +1 -1
- package/scripts/checks/control-center-smoke.js +11 -4
- package/scripts/checks/lifecycle-smoke.js +5 -2
- package/scripts/checks/mcp-smoke.js +31 -1
- package/scripts/checks/watcher-supervisor-smoke.js +29 -1
- package/scripts/control/start-patchwarden-tunnel.ps1 +65 -1
- package/scripts/e2e/demo-runtime.mjs +153 -0
- package/scripts/release/desktop-preflight.js +1 -1
- package/src/assessments/assessmentDiagnostics.ts +172 -0
- package/src/assessments/assessmentStore.ts +237 -12
- package/src/assessments/securitySnapshot.ts +231 -0
- package/src/control/routes/status.ts +37 -0
- package/src/control/runtime.ts +2 -10
- package/src/control/server.ts +1 -0
- package/src/diagnostics/allowedTestCommandSafety.ts +12 -0
- package/src/direct/directSessionStore.ts +6 -4
- package/src/doctor.ts +3 -5
- package/src/runner/runTask.ts +59 -6
- package/src/runner/taskRuntime.ts +2 -0
- package/src/runner/taskStatusStore.ts +16 -1
- package/src/runner/watch.ts +67 -1
- package/src/smoke-test.ts +2 -2
- package/src/tools/definitions/toolDefs.ts +3 -3
- package/src/tools/diagnostics/auditTask.ts +37 -7
- package/src/tools/diagnostics/healthCheck.ts +29 -1
- package/src/tools/tasks/cancelTask.ts +2 -1
- package/src/tools/tasks/createTask.ts +113 -8
- package/src/tools/tasks/diagnoseTask.ts +4 -8
- package/src/tools/tasks/getTaskStatus.ts +6 -1
- package/src/tools/tasks/getTaskSummary.ts +2 -11
- package/src/tools/tasks/listTasks.ts +2 -1
- package/src/tools/tasks/reconcileTasks.ts +162 -13
- package/src/tools/tasks/runTaskLoop.ts +4 -12
- package/src/tools/tasks/taskStates.ts +54 -0
- package/src/tools/tasks/waitForTask.ts +3 -12
- package/src/tools/workspace/listAgents.ts +17 -3
- package/src/utils/atomicFile.ts +17 -1
- package/src/version.ts +1 -1
- package/ui/pages/dashboard.html +4 -0
- package/ui/pages/logs.html +29 -0
- package/ui/pages/settings.html +3 -3
- package/ui/settings.js +26 -17
|
@@ -5,6 +5,7 @@ export interface ReconcileTasksInput {
|
|
|
5
5
|
max_age_minutes?: number;
|
|
6
6
|
mode?: ReconcileMode;
|
|
7
7
|
include_done_candidates?: boolean;
|
|
8
|
+
task_ids?: string[];
|
|
8
9
|
}
|
|
9
10
|
export interface ReconcileTaskReport {
|
|
10
11
|
task_id: string;
|
|
@@ -15,7 +16,7 @@ export interface ReconcileTaskReport {
|
|
|
15
16
|
reasons: string[];
|
|
16
17
|
safe_actions: SafeAction[];
|
|
17
18
|
age_seconds: number | null;
|
|
18
|
-
action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent";
|
|
19
|
+
action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent" | "marked_canceled" | "marked_timed_out";
|
|
19
20
|
previous_status: string | null;
|
|
20
21
|
new_status: string | null;
|
|
21
22
|
applied_at: string | null;
|
|
@@ -4,8 +4,10 @@ import { getTasksDir, getConfig } from "../../config.js";
|
|
|
4
4
|
import { diagnoseTask } from "./diagnoseTask.js";
|
|
5
5
|
import { syncSubgoalOnTaskDone, readTaskGoalMeta } from "../../goal/subgoalSync.js";
|
|
6
6
|
import { mutateTaskStatus } from "../../runner/taskStatusStore.js";
|
|
7
|
-
import {
|
|
7
|
+
import { writeTaskRuntime } from "../../runner/taskRuntime.js";
|
|
8
|
+
import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../../utils/atomicFile.js";
|
|
8
9
|
import { appendBoundedTextFileSync } from "../../utils/boundedFile.js";
|
|
10
|
+
import { isActiveTaskStatus } from "./taskStates.js";
|
|
9
11
|
// ── Defaults ────────────────────────────────────────────────────────
|
|
10
12
|
const DEFAULT_MAX_AGE_MINUTES = 30;
|
|
11
13
|
const RECONCILE_LOG_NAME = "reconcile.log";
|
|
@@ -16,6 +18,9 @@ export function reconcileTasks(input = {}, config = getConfig()) {
|
|
|
16
18
|
? Math.min(input.max_age_minutes, 24 * 60)
|
|
17
19
|
: DEFAULT_MAX_AGE_MINUTES;
|
|
18
20
|
const includeDoneCandidates = input.include_done_candidates !== false; // default true
|
|
21
|
+
const requestedTaskIds = input.task_ids?.length
|
|
22
|
+
? new Set(input.task_ids.filter((taskId) => /^task[-_][a-zA-Z0-9_-]+$/.test(taskId)))
|
|
23
|
+
: null;
|
|
19
24
|
const maxAgeSeconds = maxAgeMinutes * 60;
|
|
20
25
|
const tasksDir = getTasksDir(config);
|
|
21
26
|
const reports = [];
|
|
@@ -55,8 +60,10 @@ export function reconcileTasks(input = {}, config = getConfig()) {
|
|
|
55
60
|
}
|
|
56
61
|
const taskDirs = entries.filter((e) => e.isDirectory());
|
|
57
62
|
for (const entry of taskDirs) {
|
|
58
|
-
scanned += 1;
|
|
59
63
|
const taskId = entry.name;
|
|
64
|
+
if (requestedTaskIds && !requestedTaskIds.has(taskId))
|
|
65
|
+
continue;
|
|
66
|
+
scanned += 1;
|
|
60
67
|
const taskDir = resolve(tasksDir, taskId);
|
|
61
68
|
const statusFile = join(taskDir, "status.json");
|
|
62
69
|
if (!existsSync(statusFile))
|
|
@@ -69,17 +76,16 @@ export function reconcileTasks(input = {}, config = getConfig()) {
|
|
|
69
76
|
continue; // corrupted status, skip
|
|
70
77
|
}
|
|
71
78
|
const statusStr = typeof statusData.status === "string" ? statusData.status : "unknown";
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
79
|
+
// Scan every persisted non-terminal lifecycle spelling, including legacy
|
|
80
|
+
// phase-as-status records left by older Watchers.
|
|
81
|
+
const isCandidate = isActiveTaskStatus(statusStr) ||
|
|
75
82
|
(includeDoneCandidates && statusStr === "done_by_agent");
|
|
76
83
|
if (!isCandidate)
|
|
77
84
|
continue;
|
|
78
|
-
//
|
|
85
|
+
// Use the oldest reliable timestamp for ordinary stale detection. Explicit
|
|
86
|
+
// cancel, persisted deadline, and Watcher-instance mismatch bypass this
|
|
87
|
+
// age gate so a restart cannot extend the task's total budget.
|
|
79
88
|
const ageSeconds = taskAgeSeconds(taskDir, statusData, nowMs);
|
|
80
|
-
if (ageSeconds !== null && ageSeconds < maxAgeSeconds)
|
|
81
|
-
continue;
|
|
82
|
-
candidates += 1;
|
|
83
89
|
// Diagnose the task
|
|
84
90
|
let diagnosis;
|
|
85
91
|
try {
|
|
@@ -88,6 +94,14 @@ export function reconcileTasks(input = {}, config = getConfig()) {
|
|
|
88
94
|
catch {
|
|
89
95
|
continue; // diagnosis failed — skip
|
|
90
96
|
}
|
|
97
|
+
const cancellationRequested = statusData.cancel_requested === true || statusStr === "cancel_requested";
|
|
98
|
+
const deadlineExceeded = taskDeadlineExceeded(statusData, nowMs);
|
|
99
|
+
const urgentRecovery = cancellationRequested
|
|
100
|
+
|| deadlineExceeded
|
|
101
|
+
|| diagnosis.diagnosis === "orphaned_running";
|
|
102
|
+
if (ageSeconds !== null && ageSeconds < maxAgeSeconds && !urgentRecovery)
|
|
103
|
+
continue;
|
|
104
|
+
candidates += 1;
|
|
91
105
|
const report = {
|
|
92
106
|
task_id: taskId,
|
|
93
107
|
status: diagnosis.status,
|
|
@@ -120,7 +134,9 @@ export function reconcileTasks(input = {}, config = getConfig()) {
|
|
|
120
134
|
// 4. The diagnosis type maps to a reconcilable action.
|
|
121
135
|
//
|
|
122
136
|
// Anything else is left_unchanged and recorded for audit.
|
|
123
|
-
|
|
137
|
+
const deterministicRecovery = !diagnosis.evidence.watcher_owns_task
|
|
138
|
+
&& (cancellationRequested || deadlineExceeded);
|
|
139
|
+
if (mode === "safe_fix" && (diagnosis.confidence === "high" || deterministicRecovery)) {
|
|
124
140
|
if (diagnosis.evidence.watcher_owns_task) {
|
|
125
141
|
// Hard rule: do not touch tasks the live watcher still owns.
|
|
126
142
|
skippedActiveWatcher += 1;
|
|
@@ -184,35 +200,45 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
|
|
|
184
200
|
const previousStatus = typeof currentStatus.status === "string" ? currentStatus.status : "unknown";
|
|
185
201
|
let newStatus = null;
|
|
186
202
|
let actionTaken = "left_unchanged";
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
break;
|
|
196
|
-
case "done_candidate":
|
|
197
|
-
newStatus = "done_by_agent";
|
|
198
|
-
actionTaken = "marked_done_by_agent";
|
|
199
|
-
break;
|
|
200
|
-
case "artifact_collection_stuck":
|
|
201
|
-
// Treat as failed_stale — artifact collection should not hang.
|
|
202
|
-
newStatus = "failed_stale";
|
|
203
|
-
actionTaken = "marked_failed_stale";
|
|
204
|
-
break;
|
|
205
|
-
default:
|
|
206
|
-
// active_running, possibly_stale_running, unknown, terminal — not eligible
|
|
207
|
-
return {
|
|
208
|
-
applied: false,
|
|
209
|
-
action_taken: "left_unchanged",
|
|
210
|
-
previous_status: previousStatus,
|
|
211
|
-
new_status: previousStatus,
|
|
212
|
-
applied_at: new Date().toISOString(),
|
|
213
|
-
skip_reason: `diagnosis "${diagnosis}" has no automatic safe_fix action`,
|
|
214
|
-
};
|
|
203
|
+
const cancellationRequested = currentStatus.cancel_requested === true || previousStatus === "cancel_requested";
|
|
204
|
+
if (!evidence.watcher_owns_task && cancellationRequested) {
|
|
205
|
+
newStatus = "canceled";
|
|
206
|
+
actionTaken = "marked_canceled";
|
|
207
|
+
}
|
|
208
|
+
else if (!evidence.watcher_owns_task && taskDeadlineExceeded(currentStatus)) {
|
|
209
|
+
newStatus = "timeout";
|
|
210
|
+
actionTaken = "marked_timed_out";
|
|
215
211
|
}
|
|
212
|
+
else
|
|
213
|
+
switch (diagnosis) {
|
|
214
|
+
case "stale_running":
|
|
215
|
+
newStatus = "failed_stale";
|
|
216
|
+
actionTaken = "marked_failed_stale";
|
|
217
|
+
break;
|
|
218
|
+
case "orphaned_running":
|
|
219
|
+
newStatus = "orphaned";
|
|
220
|
+
actionTaken = "marked_orphaned";
|
|
221
|
+
break;
|
|
222
|
+
case "done_candidate":
|
|
223
|
+
newStatus = "done_by_agent";
|
|
224
|
+
actionTaken = "marked_done_by_agent";
|
|
225
|
+
break;
|
|
226
|
+
case "artifact_collection_stuck":
|
|
227
|
+
// Treat as failed_stale — artifact collection should not hang.
|
|
228
|
+
newStatus = "failed_stale";
|
|
229
|
+
actionTaken = "marked_failed_stale";
|
|
230
|
+
break;
|
|
231
|
+
default:
|
|
232
|
+
// active_running, possibly_stale_running, unknown, terminal — not eligible
|
|
233
|
+
return {
|
|
234
|
+
applied: false,
|
|
235
|
+
action_taken: "left_unchanged",
|
|
236
|
+
previous_status: previousStatus,
|
|
237
|
+
new_status: previousStatus,
|
|
238
|
+
applied_at: new Date().toISOString(),
|
|
239
|
+
skip_reason: `diagnosis "${diagnosis}" has no automatic safe_fix action`,
|
|
240
|
+
};
|
|
241
|
+
}
|
|
216
242
|
const appliedAt = new Date().toISOString();
|
|
217
243
|
const statusFile = join(taskDir, "status.json");
|
|
218
244
|
const backupFile = join(taskDir, "status.json.bak");
|
|
@@ -247,14 +273,42 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
|
|
|
247
273
|
current_watcher_instance_id: evidence.current_watcher_instance_id,
|
|
248
274
|
},
|
|
249
275
|
},
|
|
276
|
+
process_cleanup_required: !evidence.watcher_owns_task,
|
|
277
|
+
process_cleanup_reason: !evidence.watcher_owns_task
|
|
278
|
+
? "The previous runner is no longer owned by this Watcher; child-process termination could not be confirmed and no untrusted PID was killed."
|
|
279
|
+
: null,
|
|
250
280
|
};
|
|
281
|
+
if (newStatus === "canceled") {
|
|
282
|
+
next.canceled_at = appliedAt;
|
|
283
|
+
next.cancel_reason = "Cancellation converged after the original task runner stopped responding.";
|
|
284
|
+
next.termination_reason = "canceled";
|
|
285
|
+
next.error_code = "runner_lost_during_cancel";
|
|
286
|
+
}
|
|
287
|
+
else if (actionTaken === "marked_timed_out") {
|
|
288
|
+
next.error = `Task exceeded its configured timeout of ${Number(current.timeout_seconds)} seconds after the original runner stopped responding.`;
|
|
289
|
+
next.termination_reason = "timeout";
|
|
290
|
+
next.error_code = "task_timeout";
|
|
291
|
+
}
|
|
292
|
+
else if (diagnosis === "artifact_collection_stuck") {
|
|
293
|
+
next.error = "Artifact collection was interrupted and could not be safely resumed after the original runner stopped responding.";
|
|
294
|
+
next.error_code = "artifact_collection_interrupted";
|
|
295
|
+
}
|
|
296
|
+
else if (diagnosis === "orphaned_running") {
|
|
297
|
+
next.error = "The original task runner was lost and its child process ownership could not be re-established.";
|
|
298
|
+
next.error_code = "agent_lost";
|
|
299
|
+
}
|
|
300
|
+
else if (diagnosis === "stale_running") {
|
|
301
|
+
next.error = "The task runner heartbeat expired and no current Runner ownership could be proven.";
|
|
302
|
+
next.error_code = "agent_lost";
|
|
303
|
+
}
|
|
251
304
|
if (newStatus === "done_by_agent") {
|
|
252
305
|
next.acceptance_status = "pending";
|
|
253
306
|
next.legacy_status = "done";
|
|
254
307
|
}
|
|
255
308
|
// The backup is created while the same lock protects the exact record
|
|
256
309
|
// being replaced, so it cannot capture an unrelated newer state.
|
|
257
|
-
|
|
310
|
+
if (!existsSync(backupFile))
|
|
311
|
+
atomicWriteFileSync(backupFile, readFileSync(statusFile, "utf-8"));
|
|
258
312
|
return {
|
|
259
313
|
next,
|
|
260
314
|
result: { applied: true },
|
|
@@ -281,6 +335,20 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
|
|
|
281
335
|
skip_reason: "task status could not be locked, backed up, and updated",
|
|
282
336
|
};
|
|
283
337
|
}
|
|
338
|
+
if (newStatus !== "done_by_agent") {
|
|
339
|
+
writeRecoveryArtifacts(taskDir, taskId, currentStatus, newStatus, actionTaken, appliedAt);
|
|
340
|
+
}
|
|
341
|
+
// Keep the persisted runtime view terminal as well. get_task_status merges
|
|
342
|
+
// runtime.json over status.json, so leaving an old executing phase here
|
|
343
|
+
// would make a successfully reconciled task appear active again.
|
|
344
|
+
writeTaskRuntime(taskDir, {
|
|
345
|
+
phase: newStatus,
|
|
346
|
+
current_command: null,
|
|
347
|
+
last_heartbeat_at: appliedAt,
|
|
348
|
+
child_pid: undefined,
|
|
349
|
+
child_started_at: undefined,
|
|
350
|
+
child_owned_by_runner_instance_id: undefined,
|
|
351
|
+
});
|
|
284
352
|
// v0.8.0: 当状态变为 done_by_agent 时,同步关联 subgoal 状态(running → done_by_agent)
|
|
285
353
|
if (newStatus === "done_by_agent") {
|
|
286
354
|
const goalMeta = readTaskGoalMeta(taskDir);
|
|
@@ -297,6 +365,84 @@ function applySafeFix(taskDir, taskId, currentStatus, diagnosis, reasons, eviden
|
|
|
297
365
|
skip_reason: null,
|
|
298
366
|
};
|
|
299
367
|
}
|
|
368
|
+
function writeRecoveryArtifacts(taskDir, taskId, status, newStatus, actionTaken, recoveredAt) {
|
|
369
|
+
const timeoutSeconds = Number(status.timeout_seconds);
|
|
370
|
+
const summary = actionTaken === "marked_timed_out"
|
|
371
|
+
? `Task exceeded its configured timeout of ${timeoutSeconds} seconds after the original runner stopped responding.`
|
|
372
|
+
: actionTaken === "marked_canceled"
|
|
373
|
+
? "Cancellation converged after the original task runner stopped responding."
|
|
374
|
+
: `Task was reconciled to ${newStatus} after the original runner stopped responding.`;
|
|
375
|
+
const errorCode = actionTaken === "marked_timed_out"
|
|
376
|
+
? "task_timeout"
|
|
377
|
+
: actionTaken === "marked_canceled"
|
|
378
|
+
? "runner_lost_during_cancel"
|
|
379
|
+
: newStatus === "orphaned"
|
|
380
|
+
? "agent_lost"
|
|
381
|
+
: newStatus === "failed_stale" && String(status.phase || status.status) === "collecting_artifacts"
|
|
382
|
+
? "artifact_collection_interrupted"
|
|
383
|
+
: "agent_lost";
|
|
384
|
+
const requestedCommands = Array.isArray(status.verify_commands)
|
|
385
|
+
? status.verify_commands.map(String)
|
|
386
|
+
: [];
|
|
387
|
+
const artifacts = {
|
|
388
|
+
"verify.json": {
|
|
389
|
+
status: "failed",
|
|
390
|
+
requested_commands: requestedCommands,
|
|
391
|
+
commands: [],
|
|
392
|
+
failure_reason: "runner_recovery",
|
|
393
|
+
error_code: errorCode,
|
|
394
|
+
},
|
|
395
|
+
"result.json": {
|
|
396
|
+
task_id: taskId,
|
|
397
|
+
status: newStatus,
|
|
398
|
+
agent: status.agent || "",
|
|
399
|
+
repo_path: status.repo_path || "",
|
|
400
|
+
resolved_repo_path: status.resolved_repo_path || "",
|
|
401
|
+
summary,
|
|
402
|
+
artifact_status: "partial",
|
|
403
|
+
changed_files: [],
|
|
404
|
+
diff_available: false,
|
|
405
|
+
verify_status: "failed",
|
|
406
|
+
failure_reason: "runner_recovery",
|
|
407
|
+
error_code: errorCode,
|
|
408
|
+
termination_reason: actionTaken === "marked_timed_out"
|
|
409
|
+
? "timeout"
|
|
410
|
+
: actionTaken === "marked_canceled"
|
|
411
|
+
? "canceled"
|
|
412
|
+
: "runner_stale",
|
|
413
|
+
recovered_at: recoveredAt,
|
|
414
|
+
warnings: ["The original runner stopped before complete change and verification evidence could be collected."],
|
|
415
|
+
},
|
|
416
|
+
};
|
|
417
|
+
for (const [name, value] of Object.entries(artifacts)) {
|
|
418
|
+
const path = join(taskDir, name);
|
|
419
|
+
if (!existsSync(path))
|
|
420
|
+
atomicWriteJsonFileSync(path, value);
|
|
421
|
+
}
|
|
422
|
+
const textArtifacts = {
|
|
423
|
+
"result.md": `# PatchWarden Task Result\n\n## Status\n${newStatus}\n\n## Summary\n${summary}\n\n## Artifact Status\npartial\n`,
|
|
424
|
+
"test.log": `(not run)\nExit code: not run\n${summary}\n`,
|
|
425
|
+
"git.diff": `(unavailable: ${summary})\n`,
|
|
426
|
+
"diff.patch": `(unavailable: ${summary})\n`,
|
|
427
|
+
};
|
|
428
|
+
for (const [name, content] of Object.entries(textArtifacts)) {
|
|
429
|
+
const path = join(taskDir, name);
|
|
430
|
+
if (!existsSync(path))
|
|
431
|
+
atomicWriteFileSync(path, content);
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
function taskDeadlineExceeded(status, nowMs = Date.now()) {
|
|
435
|
+
const timeoutSeconds = Number(status.timeout_seconds);
|
|
436
|
+
const startedAt = typeof status.started_at === "string"
|
|
437
|
+
? Date.parse(status.started_at)
|
|
438
|
+
: typeof status.created_at === "string"
|
|
439
|
+
? Date.parse(status.created_at)
|
|
440
|
+
: NaN;
|
|
441
|
+
return Number.isFinite(startedAt)
|
|
442
|
+
&& Number.isFinite(timeoutSeconds)
|
|
443
|
+
&& timeoutSeconds > 0
|
|
444
|
+
&& nowMs >= startedAt + timeoutSeconds * 1000;
|
|
445
|
+
}
|
|
300
446
|
// ── reconcile.log writer ───────────────────────────────────────────
|
|
301
447
|
function writeReconcileLog(tasksDir, config, appliedReports) {
|
|
302
448
|
// The reconcile.log lives at the .patchwarden/ root (parent of tasksDir),
|
|
@@ -8,6 +8,7 @@ import { recommendAgentForTask } from "../workspace/recommendAgentForTask.js";
|
|
|
8
8
|
import { runDirectVerificationBundle } from "../direct/runDirectVerificationBundle.js";
|
|
9
9
|
import { waitForTask } from "./waitForTask.js";
|
|
10
10
|
import { safeAudit, safeAuditDirectSession, safeFinalizeDirectSession, safeResult, safeTestSummary } from "../diagnostics/safeViews.js";
|
|
11
|
+
import { isTerminalTaskStatus } from "./taskStates.js";
|
|
11
12
|
import { createLineageId, writeTaskLineage, } from "./taskLineage.js";
|
|
12
13
|
const DEFAULT_DEPS = {
|
|
13
14
|
createTask,
|
|
@@ -26,15 +27,6 @@ const DEFAULT_DEPS = {
|
|
|
26
27
|
now: () => new Date(),
|
|
27
28
|
sleep,
|
|
28
29
|
};
|
|
29
|
-
const TERMINAL_STATUSES = new Set([
|
|
30
|
-
"done",
|
|
31
|
-
"done_by_agent",
|
|
32
|
-
"failed",
|
|
33
|
-
"failed_verification",
|
|
34
|
-
"failed_scope_violation",
|
|
35
|
-
"failed_policy_violation",
|
|
36
|
-
"canceled",
|
|
37
|
-
]);
|
|
38
30
|
export async function runTaskLoop(input) {
|
|
39
31
|
return runTaskLoopWithDeps(input, DEFAULT_DEPS);
|
|
40
32
|
}
|
|
@@ -186,7 +178,7 @@ async function waitUntilTerminal(taskId, timeoutSeconds, deps) {
|
|
|
186
178
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
187
179
|
while (Date.now() < deadline) {
|
|
188
180
|
const waited = await deps.waitForTask(taskId, 30);
|
|
189
|
-
if (waited.terminal ||
|
|
181
|
+
if (waited.terminal || isTerminalTaskStatus(String(waited.status))) {
|
|
190
182
|
return { next_action: waited.next_action || "safe_audit" };
|
|
191
183
|
}
|
|
192
184
|
if (waited.next_tool_call?.name === "health_check" || waited.continuation_required === false) {
|
|
@@ -241,7 +233,7 @@ function isSuccessfulRound(round) {
|
|
|
241
233
|
round.audit_verdict === "pass");
|
|
242
234
|
}
|
|
243
235
|
function isHardStop(round, resultValue, auditValue, stopOnHighRisk) {
|
|
244
|
-
if (["failed_scope_violation", "failed_policy_violation", "canceled"].includes(round.status))
|
|
236
|
+
if (["failed_scope_violation", "failed_policy_violation", "canceled", "timeout"].includes(round.status))
|
|
245
237
|
return true;
|
|
246
238
|
if (!stopOnHighRisk)
|
|
247
239
|
return false;
|
|
@@ -255,6 +247,8 @@ function hardStopReason(round, resultValue) {
|
|
|
255
247
|
const result = asRecord(resultValue);
|
|
256
248
|
if (round.status === "failed_scope_violation" || round.status === "failed_policy_violation")
|
|
257
249
|
return "policy_blocked";
|
|
250
|
+
if (round.status === "timeout")
|
|
251
|
+
return "agent_timeout";
|
|
258
252
|
if (String(result.failure_reason || "").toLowerCase().includes("timeout"))
|
|
259
253
|
return "agent_timeout";
|
|
260
254
|
return "high_risk_blocked";
|
|
@@ -14,7 +14,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
|
|
|
14
14
|
lines?: number;
|
|
15
15
|
redact?: boolean;
|
|
16
16
|
}): {
|
|
17
|
-
file: "
|
|
17
|
+
file: "stdout" | "stderr" | "test" | "verify";
|
|
18
18
|
lines: number;
|
|
19
19
|
total_bytes: number;
|
|
20
20
|
task_id: string;
|
|
@@ -37,7 +37,7 @@ export declare function getTaskLogTail(taskId: string, file: "stdout" | "stderr"
|
|
|
37
37
|
truncated?: undefined;
|
|
38
38
|
} | {
|
|
39
39
|
task_id: string;
|
|
40
|
-
file: "
|
|
40
|
+
file: "stdout" | "stderr" | "test" | "verify";
|
|
41
41
|
filename: string;
|
|
42
42
|
content: string;
|
|
43
43
|
available: boolean;
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export declare const ACTIVE_TASK_STATUSES: Set<string>;
|
|
2
|
+
export declare const TERMINAL_TASK_STATUSES: Set<string>;
|
|
3
|
+
export declare function isTerminalTaskStatus(status: string): boolean;
|
|
4
|
+
export declare function isActiveTaskStatus(status: string): boolean;
|
|
5
|
+
/** Terminal states are immutable; active states may only advance or terminate. */
|
|
6
|
+
export declare function isAllowedTaskStatusTransition(from: string, to: string): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
export const ACTIVE_TASK_STATUSES = new Set([
|
|
2
|
+
"pending",
|
|
3
|
+
"running",
|
|
4
|
+
"executing_agent",
|
|
5
|
+
"verifying",
|
|
6
|
+
"collecting_artifacts",
|
|
7
|
+
"cancel_requested",
|
|
8
|
+
]);
|
|
9
|
+
export const TERMINAL_TASK_STATUSES = new Set([
|
|
10
|
+
"done",
|
|
11
|
+
"done_by_agent",
|
|
12
|
+
"accepted",
|
|
13
|
+
"rejected",
|
|
14
|
+
"needs_fix",
|
|
15
|
+
"blocked",
|
|
16
|
+
"failed",
|
|
17
|
+
"failed_verification",
|
|
18
|
+
"failed_scope_violation",
|
|
19
|
+
"failed_policy_violation",
|
|
20
|
+
"failed_stale",
|
|
21
|
+
"orphaned",
|
|
22
|
+
"timeout",
|
|
23
|
+
"canceled",
|
|
24
|
+
]);
|
|
25
|
+
export function isTerminalTaskStatus(status) {
|
|
26
|
+
return TERMINAL_TASK_STATUSES.has(status);
|
|
27
|
+
}
|
|
28
|
+
export function isActiveTaskStatus(status) {
|
|
29
|
+
return ACTIVE_TASK_STATUSES.has(status);
|
|
30
|
+
}
|
|
31
|
+
/** Terminal states are immutable; active states may only advance or terminate. */
|
|
32
|
+
export function isAllowedTaskStatusTransition(from, to) {
|
|
33
|
+
if (from === to)
|
|
34
|
+
return true;
|
|
35
|
+
if (isTerminalTaskStatus(from))
|
|
36
|
+
return false;
|
|
37
|
+
if (!isActiveTaskStatus(from) || !isActiveTaskStatus(to) && !isTerminalTaskStatus(to))
|
|
38
|
+
return false;
|
|
39
|
+
if (from === "pending") {
|
|
40
|
+
return to === "running" || isTerminalTaskStatus(to);
|
|
41
|
+
}
|
|
42
|
+
if (to === "pending")
|
|
43
|
+
return false;
|
|
44
|
+
const forwardActive = {
|
|
45
|
+
running: new Set(["executing_agent", "verifying", "collecting_artifacts", "cancel_requested"]),
|
|
46
|
+
executing_agent: new Set(["verifying", "collecting_artifacts", "cancel_requested"]),
|
|
47
|
+
verifying: new Set(["collecting_artifacts", "cancel_requested"]),
|
|
48
|
+
collecting_artifacts: new Set(["cancel_requested"]),
|
|
49
|
+
cancel_requested: new Set(),
|
|
50
|
+
};
|
|
51
|
+
return isTerminalTaskStatus(to) || Boolean(forwardActive[from]?.has(to));
|
|
52
|
+
}
|
|
@@ -1,15 +1,7 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { getTaskStatus } from "./getTaskStatus.js";
|
|
3
3
|
import { getTaskSummary } from "./getTaskSummary.js";
|
|
4
|
-
|
|
5
|
-
"done",
|
|
6
|
-
"done_by_agent",
|
|
7
|
-
"failed",
|
|
8
|
-
"failed_verification",
|
|
9
|
-
"failed_scope_violation",
|
|
10
|
-
"failed_policy_violation",
|
|
11
|
-
"canceled",
|
|
12
|
-
]);
|
|
4
|
+
import { isTerminalTaskStatus } from "./taskStates.js";
|
|
13
5
|
export async function waitForTask(taskId, waitSeconds = 25) {
|
|
14
6
|
if (!Number.isInteger(waitSeconds) || waitSeconds < 1 || waitSeconds > 30) {
|
|
15
7
|
throw new Error("wait_seconds must be an integer from 1 to 30.");
|
|
@@ -17,11 +9,11 @@ export async function waitForTask(taskId, waitSeconds = 25) {
|
|
|
17
9
|
const started = Date.now();
|
|
18
10
|
const deadline = started + waitSeconds * 1000;
|
|
19
11
|
let status = getTaskStatus(taskId);
|
|
20
|
-
while (!
|
|
12
|
+
while (!isTerminalTaskStatus(status.status) && Date.now() < deadline) {
|
|
21
13
|
await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
22
14
|
status = getTaskStatus(taskId);
|
|
23
15
|
}
|
|
24
|
-
const terminal =
|
|
16
|
+
const terminal = isTerminalTaskStatus(status.status);
|
|
25
17
|
const executionBlocked = !terminal && status.execution_blocked;
|
|
26
18
|
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
27
19
|
const progressSummary = {
|
|
@@ -10,8 +10,14 @@ export interface AgentAvailability {
|
|
|
10
10
|
capabilities: {
|
|
11
11
|
model_override: boolean;
|
|
12
12
|
};
|
|
13
|
+
availability_scope: "executable_only";
|
|
14
|
+
provider_status: "not_checked";
|
|
15
|
+
invocation_ready?: boolean;
|
|
16
|
+
model_argument_present?: boolean;
|
|
13
17
|
}
|
|
14
18
|
export declare function listAgents(): {
|
|
15
19
|
agents: AgentAvailability[];
|
|
16
20
|
total: number;
|
|
21
|
+
config_path: string;
|
|
22
|
+
workspace_root: string;
|
|
17
23
|
};
|
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
import { existsSync, statSync } from "node:fs";
|
|
2
|
-
import { basename, delimiter, extname, isAbsolute, join } from "node:path";
|
|
2
|
+
import { basename, delimiter, extname, isAbsolute, join, resolve } from "node:path";
|
|
3
3
|
import { getConfig } from "../../config.js";
|
|
4
4
|
import { sanitizeTrustedPath } from "../../runner/processSecurity.js";
|
|
5
5
|
export function listAgents() {
|
|
6
6
|
const config = getConfig();
|
|
7
7
|
const checkedAt = new Date().toISOString();
|
|
8
|
+
const configPath = process.env.PATCHWARDEN_CONFIG
|
|
9
|
+
? resolve(process.env.PATCHWARDEN_CONFIG)
|
|
10
|
+
: resolve(process.cwd(), "patchwarden.config.json");
|
|
8
11
|
const agents = Object.entries(config.agents)
|
|
9
12
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
10
13
|
.map(([name, agent]) => {
|
|
11
14
|
const available = commandExists(agent.command, config.workspaceRoot);
|
|
15
|
+
const modelArgumentPresent = typeof agent.model === "string"
|
|
16
|
+
? agent.args.some((arg, index) => arg === "--model" && agent.args[index + 1] === agent.model)
|
|
17
|
+
: true;
|
|
12
18
|
return {
|
|
13
19
|
name,
|
|
14
20
|
configured: true,
|
|
@@ -19,9 +25,13 @@ export function listAgents() {
|
|
|
19
25
|
adapter: typeof agent.adapter === "string" ? agent.adapter : ["codex", "opencode"].includes(name) ? name : null,
|
|
20
26
|
model: typeof agent.model === "string" ? agent.model : null,
|
|
21
27
|
capabilities: { model_override: typeof agent.adapter === "string" || ["codex", "opencode"].includes(name) },
|
|
28
|
+
availability_scope: "executable_only",
|
|
29
|
+
provider_status: "not_checked",
|
|
30
|
+
invocation_ready: available && modelArgumentPresent,
|
|
31
|
+
model_argument_present: modelArgumentPresent,
|
|
22
32
|
};
|
|
23
33
|
});
|
|
24
|
-
return { agents, total: agents.length };
|
|
34
|
+
return { agents, total: agents.length, config_path: configPath, workspace_root: config.workspaceRoot };
|
|
25
35
|
}
|
|
26
36
|
function commandExists(command, workspaceRoot) {
|
|
27
37
|
if (isAbsolute(command) || command.includes("/") || command.includes("\\")) {
|
package/dist/utils/atomicFile.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { randomBytes } from "node:crypto";
|
|
2
|
-
import { renameSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { closeSync, fsyncSync, openSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
3
3
|
const transientWindowsRenameErrors = new Set(["EACCES", "EBUSY", "EPERM"]);
|
|
4
4
|
const renameRetryDeadlineMs = 1_000;
|
|
5
5
|
const renameRetryDelayMs = 10;
|
|
@@ -13,6 +13,25 @@ export function atomicWriteFileSync(path, content, options = {}) {
|
|
|
13
13
|
flag: "wx",
|
|
14
14
|
...(options.mode === undefined ? {} : { mode: options.mode }),
|
|
15
15
|
});
|
|
16
|
+
const descriptor = openSync(temporaryPath, "r");
|
|
17
|
+
try {
|
|
18
|
+
try {
|
|
19
|
+
fsyncSync(descriptor);
|
|
20
|
+
}
|
|
21
|
+
catch (error) {
|
|
22
|
+
const code = error && typeof error === "object" && "code" in error
|
|
23
|
+
? String(error.code || "")
|
|
24
|
+
: "";
|
|
25
|
+
// Some Windows filesystems and restricted runtimes reject fsync even
|
|
26
|
+
// after a successful complete write. Keep the same-directory atomic
|
|
27
|
+
// rename guarantee there; unexpected I/O errors still fail closed.
|
|
28
|
+
if (!new Set(["EPERM", "EINVAL", "ENOSYS", "ENOTSUP"]).has(code))
|
|
29
|
+
throw error;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
finally {
|
|
33
|
+
closeSync(descriptor);
|
|
34
|
+
}
|
|
16
35
|
replaceFileSync(temporaryPath, path);
|
|
17
36
|
}
|
|
18
37
|
catch (error) {
|
package/dist/version.d.ts
CHANGED
package/dist/version.js
CHANGED
package/docs/CODE_WIKI.md
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# PatchWarden Code Wiki
|
|
2
2
|
|
|
3
3
|
> 本文档是对 PatchWarden 仓库的结构化代码导览,覆盖项目整体架构、主要模块职责、关键类与函数说明、依赖关系、运行方式,以及现有缺陷分析。
|
|
4
|
-
> 源码版本:**v1.6.
|
|
4
|
+
> 源码版本:**v1.6.2** · Schema Epoch:`2026-07-19-v15` · 复核日期:2026-07-25 · License:MIT
|
|
5
5
|
|
|
6
6
|
## 目录
|
|
7
7
|
|
|
@@ -254,7 +254,7 @@ PatchWarden/
|
|
|
254
254
|
│ └── release/ # 发布打包
|
|
255
255
|
├── docs/ # 文档
|
|
256
256
|
├── examples/ # 配置与 Tunnel 示例
|
|
257
|
-
├── package.json # v1.6.
|
|
257
|
+
├── package.json # v1.6.2,单一直接运行时依赖
|
|
258
258
|
├── tsconfig.json
|
|
259
259
|
└── PatchWarden.cmd # Windows 统一控制入口
|
|
260
260
|
```
|
|
@@ -278,7 +278,7 @@ PatchWarden/
|
|
|
278
278
|
| --- | --- |
|
|
279
279
|
| [src/config.ts](../src/config.ts) | 加载并校验 `patchwarden.config.json`,提供 `loadConfig`/`getConfig`/`getTasksDir`/`getPlansDir`/`resolveWorkspaceRoot`/`getRepoAllowedTestCommands`/`getRepoDirectAllowedCommands` 等路径解析;严格校验 `workspaceRoot`、`agents`、`allowedTestCommands`、`watcherStaleSeconds`、`toolProfile`、`tunnelProxy`、Direct 数值范围等字段;含 `normalizeRepoKey` 与 `comparablePath` 工具 |
|
|
280
280
|
| [src/errors.ts](../src/errors.ts) | 定义 `PatchWardenError`(含 `reason`/`suggestion`/`blocked`/`details`)与 `errorPayload` 序列化 |
|
|
281
|
-
| [src/version.ts](../src/version.ts) | 导出 `PATCHWARDEN_VERSION = "1.6.
|
|
281
|
+
| [src/version.ts](../src/version.ts) | 导出 `PATCHWARDEN_VERSION = "1.6.2"` 与 `TOOL_SCHEMA_EPOCH = "2026-07-19-v15"` |
|
|
282
282
|
| [src/logging.ts](../src/logging.ts) | `Logger` 类输出 stderr JSON 日志,记录 `audit`/`info`/`warn`/`error`;`logToolInvocation` 仅写参数 digest,不写原参数,并通过跨进程锁有界追加到 5 MiB;`installGlobalHandlers` 捕获未处理异常但不吞错 |
|
|
283
283
|
|
|
284
284
|
### 4.3 安全模块(src/security/)
|
|
@@ -1555,7 +1555,7 @@ npm.cmd run desktop:package # 打包:先 stage 主仓 dist/,再 electron-b
|
|
|
1555
1555
|
| 主包 `@types/node` | `^26.1.0` | `^18.19.0`(与 Node.js 18 最低运行时对齐) |
|
|
1556
1556
|
| `chatgpt_direct` 工具数 | 15 | 14 |
|
|
1557
1557
|
|
|
1558
|
-
>
|
|
1558
|
+
> 根目录中英文 README 的下载链接、安装器哈希和公开版本事实已同步到 v1.6.1。
|
|
1559
1559
|
|
|
1560
1560
|
#### ✅ 11.7.2 v1.5.1/v1.6.0/v1.6.1 新功能导览 — 已修复
|
|
1561
1561
|
|