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
|
@@ -11,8 +11,11 @@ import { getTasksDir, getConfig, type PatchWardenConfig } from "../../config.js"
|
|
|
11
11
|
import { diagnoseTask, type DiagnosisType, type DiagnosisConfidence, type SafeAction } from "./diagnoseTask.js";
|
|
12
12
|
import { syncSubgoalOnTaskDone, readTaskGoalMeta } from "../../goal/subgoalSync.js";
|
|
13
13
|
import { mutateTaskStatus } from "../../runner/taskStatusStore.js";
|
|
14
|
-
import {
|
|
14
|
+
import { writeTaskRuntime } from "../../runner/taskRuntime.js";
|
|
15
|
+
import type { TaskPhase } from "./createTask.js";
|
|
16
|
+
import { atomicWriteFileSync, atomicWriteJsonFileSync } from "../../utils/atomicFile.js";
|
|
15
17
|
import { appendBoundedTextFileSync } from "../../utils/boundedFile.js";
|
|
18
|
+
import { isActiveTaskStatus } from "./taskStates.js";
|
|
16
19
|
|
|
17
20
|
// ── v0.7.0: reconcile_tasks types ──────────────────────────────────
|
|
18
21
|
|
|
@@ -22,6 +25,7 @@ export interface ReconcileTasksInput {
|
|
|
22
25
|
max_age_minutes?: number;
|
|
23
26
|
mode?: ReconcileMode;
|
|
24
27
|
include_done_candidates?: boolean;
|
|
28
|
+
task_ids?: string[];
|
|
25
29
|
}
|
|
26
30
|
|
|
27
31
|
export interface ReconcileTaskReport {
|
|
@@ -33,7 +37,7 @@ export interface ReconcileTaskReport {
|
|
|
33
37
|
reasons: string[];
|
|
34
38
|
safe_actions: SafeAction[];
|
|
35
39
|
age_seconds: number | null;
|
|
36
|
-
action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent";
|
|
40
|
+
action_taken: "left_unchanged" | "marked_failed_stale" | "marked_orphaned" | "marked_done_by_agent" | "marked_canceled" | "marked_timed_out";
|
|
37
41
|
previous_status: string | null;
|
|
38
42
|
new_status: string | null;
|
|
39
43
|
applied_at: string | null;
|
|
@@ -75,6 +79,9 @@ export function reconcileTasks(
|
|
|
75
79
|
? Math.min(input.max_age_minutes, 24 * 60)
|
|
76
80
|
: DEFAULT_MAX_AGE_MINUTES;
|
|
77
81
|
const includeDoneCandidates = input.include_done_candidates !== false; // default true
|
|
82
|
+
const requestedTaskIds = input.task_ids?.length
|
|
83
|
+
? new Set(input.task_ids.filter((taskId) => /^task[-_][a-zA-Z0-9_-]+$/.test(taskId)))
|
|
84
|
+
: null;
|
|
78
85
|
const maxAgeSeconds = maxAgeMinutes * 60;
|
|
79
86
|
|
|
80
87
|
const tasksDir = getTasksDir(config);
|
|
@@ -118,8 +125,9 @@ export function reconcileTasks(
|
|
|
118
125
|
const taskDirs = entries.filter((e) => e.isDirectory());
|
|
119
126
|
|
|
120
127
|
for (const entry of taskDirs) {
|
|
121
|
-
scanned += 1;
|
|
122
128
|
const taskId = entry.name;
|
|
129
|
+
if (requestedTaskIds && !requestedTaskIds.has(taskId)) continue;
|
|
130
|
+
scanned += 1;
|
|
123
131
|
const taskDir = resolve(tasksDir, taskId);
|
|
124
132
|
const statusFile = join(taskDir, "status.json");
|
|
125
133
|
if (!existsSync(statusFile)) continue;
|
|
@@ -133,18 +141,17 @@ export function reconcileTasks(
|
|
|
133
141
|
|
|
134
142
|
const statusStr = typeof statusData.status === "string" ? statusData.status : "unknown";
|
|
135
143
|
|
|
136
|
-
//
|
|
144
|
+
// Scan every persisted non-terminal lifecycle spelling, including legacy
|
|
145
|
+
// phase-as-status records left by older Watchers.
|
|
137
146
|
const isCandidate =
|
|
138
|
-
statusStr
|
|
139
|
-
statusStr === "collecting_artifacts" ||
|
|
147
|
+
isActiveTaskStatus(statusStr) ||
|
|
140
148
|
(includeDoneCandidates && statusStr === "done_by_agent");
|
|
141
149
|
if (!isCandidate) continue;
|
|
142
150
|
|
|
143
|
-
//
|
|
151
|
+
// Use the oldest reliable timestamp for ordinary stale detection. Explicit
|
|
152
|
+
// cancel, persisted deadline, and Watcher-instance mismatch bypass this
|
|
153
|
+
// age gate so a restart cannot extend the task's total budget.
|
|
144
154
|
const ageSeconds = taskAgeSeconds(taskDir, statusData, nowMs);
|
|
145
|
-
if (ageSeconds !== null && ageSeconds < maxAgeSeconds) continue;
|
|
146
|
-
|
|
147
|
-
candidates += 1;
|
|
148
155
|
|
|
149
156
|
// Diagnose the task
|
|
150
157
|
let diagnosis;
|
|
@@ -153,6 +160,14 @@ export function reconcileTasks(
|
|
|
153
160
|
} catch {
|
|
154
161
|
continue; // diagnosis failed — skip
|
|
155
162
|
}
|
|
163
|
+
const cancellationRequested = statusData.cancel_requested === true || statusStr === "cancel_requested";
|
|
164
|
+
const deadlineExceeded = taskDeadlineExceeded(statusData, nowMs);
|
|
165
|
+
const urgentRecovery = cancellationRequested
|
|
166
|
+
|| deadlineExceeded
|
|
167
|
+
|| diagnosis.diagnosis === "orphaned_running";
|
|
168
|
+
if (ageSeconds !== null && ageSeconds < maxAgeSeconds && !urgentRecovery) continue;
|
|
169
|
+
|
|
170
|
+
candidates += 1;
|
|
156
171
|
|
|
157
172
|
const report: ReconcileTaskReport = {
|
|
158
173
|
task_id: taskId,
|
|
@@ -188,7 +203,9 @@ export function reconcileTasks(
|
|
|
188
203
|
//
|
|
189
204
|
// Anything else is left_unchanged and recorded for audit.
|
|
190
205
|
|
|
191
|
-
|
|
206
|
+
const deterministicRecovery = !diagnosis.evidence.watcher_owns_task
|
|
207
|
+
&& (cancellationRequested || deadlineExceeded);
|
|
208
|
+
if (mode === "safe_fix" && (diagnosis.confidence === "high" || deterministicRecovery)) {
|
|
192
209
|
if (diagnosis.evidence.watcher_owns_task) {
|
|
193
210
|
// Hard rule: do not touch tasks the live watcher still owns.
|
|
194
211
|
skippedActiveWatcher += 1;
|
|
@@ -284,7 +301,14 @@ function applySafeFix(
|
|
|
284
301
|
let newStatus: string | null = null;
|
|
285
302
|
let actionTaken: ReconcileTaskReport["action_taken"] = "left_unchanged";
|
|
286
303
|
|
|
287
|
-
|
|
304
|
+
const cancellationRequested = currentStatus.cancel_requested === true || previousStatus === "cancel_requested";
|
|
305
|
+
if (!evidence.watcher_owns_task && cancellationRequested) {
|
|
306
|
+
newStatus = "canceled";
|
|
307
|
+
actionTaken = "marked_canceled";
|
|
308
|
+
} else if (!evidence.watcher_owns_task && taskDeadlineExceeded(currentStatus)) {
|
|
309
|
+
newStatus = "timeout";
|
|
310
|
+
actionTaken = "marked_timed_out";
|
|
311
|
+
} else switch (diagnosis) {
|
|
288
312
|
case "stale_running":
|
|
289
313
|
newStatus = "failed_stale";
|
|
290
314
|
actionTaken = "marked_failed_stale";
|
|
@@ -350,8 +374,32 @@ function applySafeFix(
|
|
|
350
374
|
current_watcher_instance_id: evidence.current_watcher_instance_id,
|
|
351
375
|
},
|
|
352
376
|
},
|
|
377
|
+
process_cleanup_required: !evidence.watcher_owns_task,
|
|
378
|
+
process_cleanup_reason: !evidence.watcher_owns_task
|
|
379
|
+
? "The previous runner is no longer owned by this Watcher; child-process termination could not be confirmed and no untrusted PID was killed."
|
|
380
|
+
: null,
|
|
353
381
|
};
|
|
354
382
|
|
|
383
|
+
if (newStatus === "canceled") {
|
|
384
|
+
next.canceled_at = appliedAt;
|
|
385
|
+
next.cancel_reason = "Cancellation converged after the original task runner stopped responding.";
|
|
386
|
+
next.termination_reason = "canceled";
|
|
387
|
+
next.error_code = "runner_lost_during_cancel";
|
|
388
|
+
} else if (actionTaken === "marked_timed_out") {
|
|
389
|
+
next.error = `Task exceeded its configured timeout of ${Number(current.timeout_seconds)} seconds after the original runner stopped responding.`;
|
|
390
|
+
next.termination_reason = "timeout";
|
|
391
|
+
next.error_code = "task_timeout";
|
|
392
|
+
} else if (diagnosis === "artifact_collection_stuck") {
|
|
393
|
+
next.error = "Artifact collection was interrupted and could not be safely resumed after the original runner stopped responding.";
|
|
394
|
+
next.error_code = "artifact_collection_interrupted";
|
|
395
|
+
} else if (diagnosis === "orphaned_running") {
|
|
396
|
+
next.error = "The original task runner was lost and its child process ownership could not be re-established.";
|
|
397
|
+
next.error_code = "agent_lost";
|
|
398
|
+
} else if (diagnosis === "stale_running") {
|
|
399
|
+
next.error = "The task runner heartbeat expired and no current Runner ownership could be proven.";
|
|
400
|
+
next.error_code = "agent_lost";
|
|
401
|
+
}
|
|
402
|
+
|
|
355
403
|
if (newStatus === "done_by_agent") {
|
|
356
404
|
next.acceptance_status = "pending";
|
|
357
405
|
next.legacy_status = "done";
|
|
@@ -359,7 +407,7 @@ function applySafeFix(
|
|
|
359
407
|
|
|
360
408
|
// The backup is created while the same lock protects the exact record
|
|
361
409
|
// being replaced, so it cannot capture an unrelated newer state.
|
|
362
|
-
atomicWriteFileSync(backupFile, readFileSync(statusFile, "utf-8"));
|
|
410
|
+
if (!existsSync(backupFile)) atomicWriteFileSync(backupFile, readFileSync(statusFile, "utf-8"));
|
|
363
411
|
return {
|
|
364
412
|
next,
|
|
365
413
|
result: { applied: true as const },
|
|
@@ -386,6 +434,22 @@ function applySafeFix(
|
|
|
386
434
|
};
|
|
387
435
|
}
|
|
388
436
|
|
|
437
|
+
if (newStatus !== "done_by_agent") {
|
|
438
|
+
writeRecoveryArtifacts(taskDir, taskId, currentStatus, newStatus, actionTaken, appliedAt);
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
// Keep the persisted runtime view terminal as well. get_task_status merges
|
|
442
|
+
// runtime.json over status.json, so leaving an old executing phase here
|
|
443
|
+
// would make a successfully reconciled task appear active again.
|
|
444
|
+
writeTaskRuntime(taskDir, {
|
|
445
|
+
phase: newStatus as TaskPhase,
|
|
446
|
+
current_command: null,
|
|
447
|
+
last_heartbeat_at: appliedAt,
|
|
448
|
+
child_pid: undefined,
|
|
449
|
+
child_started_at: undefined,
|
|
450
|
+
child_owned_by_runner_instance_id: undefined,
|
|
451
|
+
});
|
|
452
|
+
|
|
389
453
|
// v0.8.0: 当状态变为 done_by_agent 时,同步关联 subgoal 状态(running → done_by_agent)
|
|
390
454
|
if (newStatus === "done_by_agent") {
|
|
391
455
|
const goalMeta = readTaskGoalMeta(taskDir);
|
|
@@ -404,6 +468,91 @@ function applySafeFix(
|
|
|
404
468
|
};
|
|
405
469
|
}
|
|
406
470
|
|
|
471
|
+
function writeRecoveryArtifacts(
|
|
472
|
+
taskDir: string,
|
|
473
|
+
taskId: string,
|
|
474
|
+
status: Record<string, unknown>,
|
|
475
|
+
newStatus: string,
|
|
476
|
+
actionTaken: ReconcileTaskReport["action_taken"],
|
|
477
|
+
recoveredAt: string,
|
|
478
|
+
): void {
|
|
479
|
+
const timeoutSeconds = Number(status.timeout_seconds);
|
|
480
|
+
const summary = actionTaken === "marked_timed_out"
|
|
481
|
+
? `Task exceeded its configured timeout of ${timeoutSeconds} seconds after the original runner stopped responding.`
|
|
482
|
+
: actionTaken === "marked_canceled"
|
|
483
|
+
? "Cancellation converged after the original task runner stopped responding."
|
|
484
|
+
: `Task was reconciled to ${newStatus} after the original runner stopped responding.`;
|
|
485
|
+
const errorCode = actionTaken === "marked_timed_out"
|
|
486
|
+
? "task_timeout"
|
|
487
|
+
: actionTaken === "marked_canceled"
|
|
488
|
+
? "runner_lost_during_cancel"
|
|
489
|
+
: newStatus === "orphaned"
|
|
490
|
+
? "agent_lost"
|
|
491
|
+
: newStatus === "failed_stale" && String(status.phase || status.status) === "collecting_artifacts"
|
|
492
|
+
? "artifact_collection_interrupted"
|
|
493
|
+
: "agent_lost";
|
|
494
|
+
const requestedCommands = Array.isArray(status.verify_commands)
|
|
495
|
+
? status.verify_commands.map(String)
|
|
496
|
+
: [];
|
|
497
|
+
const artifacts = {
|
|
498
|
+
"verify.json": {
|
|
499
|
+
status: "failed",
|
|
500
|
+
requested_commands: requestedCommands,
|
|
501
|
+
commands: [],
|
|
502
|
+
failure_reason: "runner_recovery",
|
|
503
|
+
error_code: errorCode,
|
|
504
|
+
},
|
|
505
|
+
"result.json": {
|
|
506
|
+
task_id: taskId,
|
|
507
|
+
status: newStatus,
|
|
508
|
+
agent: status.agent || "",
|
|
509
|
+
repo_path: status.repo_path || "",
|
|
510
|
+
resolved_repo_path: status.resolved_repo_path || "",
|
|
511
|
+
summary,
|
|
512
|
+
artifact_status: "partial",
|
|
513
|
+
changed_files: [],
|
|
514
|
+
diff_available: false,
|
|
515
|
+
verify_status: "failed",
|
|
516
|
+
failure_reason: "runner_recovery",
|
|
517
|
+
error_code: errorCode,
|
|
518
|
+
termination_reason: actionTaken === "marked_timed_out"
|
|
519
|
+
? "timeout"
|
|
520
|
+
: actionTaken === "marked_canceled"
|
|
521
|
+
? "canceled"
|
|
522
|
+
: "runner_stale",
|
|
523
|
+
recovered_at: recoveredAt,
|
|
524
|
+
warnings: ["The original runner stopped before complete change and verification evidence could be collected."],
|
|
525
|
+
},
|
|
526
|
+
};
|
|
527
|
+
for (const [name, value] of Object.entries(artifacts)) {
|
|
528
|
+
const path = join(taskDir, name);
|
|
529
|
+
if (!existsSync(path)) atomicWriteJsonFileSync(path, value);
|
|
530
|
+
}
|
|
531
|
+
const textArtifacts: Record<string, string> = {
|
|
532
|
+
"result.md": `# PatchWarden Task Result\n\n## Status\n${newStatus}\n\n## Summary\n${summary}\n\n## Artifact Status\npartial\n`,
|
|
533
|
+
"test.log": `(not run)\nExit code: not run\n${summary}\n`,
|
|
534
|
+
"git.diff": `(unavailable: ${summary})\n`,
|
|
535
|
+
"diff.patch": `(unavailable: ${summary})\n`,
|
|
536
|
+
};
|
|
537
|
+
for (const [name, content] of Object.entries(textArtifacts)) {
|
|
538
|
+
const path = join(taskDir, name);
|
|
539
|
+
if (!existsSync(path)) atomicWriteFileSync(path, content);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
|
|
543
|
+
function taskDeadlineExceeded(status: Record<string, unknown>, nowMs = Date.now()): boolean {
|
|
544
|
+
const timeoutSeconds = Number(status.timeout_seconds);
|
|
545
|
+
const startedAt = typeof status.started_at === "string"
|
|
546
|
+
? Date.parse(status.started_at)
|
|
547
|
+
: typeof status.created_at === "string"
|
|
548
|
+
? Date.parse(status.created_at)
|
|
549
|
+
: NaN;
|
|
550
|
+
return Number.isFinite(startedAt)
|
|
551
|
+
&& Number.isFinite(timeoutSeconds)
|
|
552
|
+
&& timeoutSeconds > 0
|
|
553
|
+
&& nowMs >= startedAt + timeoutSeconds * 1000;
|
|
554
|
+
}
|
|
555
|
+
|
|
407
556
|
// ── reconcile.log writer ───────────────────────────────────────────
|
|
408
557
|
|
|
409
558
|
function writeReconcileLog(
|
|
@@ -9,6 +9,7 @@ import { runDirectVerificationBundle } from "../direct/runDirectVerificationBund
|
|
|
9
9
|
import { waitForTask } from "./waitForTask.js";
|
|
10
10
|
import { safeAudit, safeAuditDirectSession, safeFinalizeDirectSession, safeResult, safeTestSummary } from "../diagnostics/safeViews.js";
|
|
11
11
|
import type { TaskTemplateName } from "../taskTemplates.js";
|
|
12
|
+
import { isTerminalTaskStatus } from "./taskStates.js";
|
|
12
13
|
import {
|
|
13
14
|
createLineageId,
|
|
14
15
|
writeTaskLineage,
|
|
@@ -86,16 +87,6 @@ const DEFAULT_DEPS: RunTaskLoopDeps = {
|
|
|
86
87
|
sleep,
|
|
87
88
|
};
|
|
88
89
|
|
|
89
|
-
const TERMINAL_STATUSES = new Set([
|
|
90
|
-
"done",
|
|
91
|
-
"done_by_agent",
|
|
92
|
-
"failed",
|
|
93
|
-
"failed_verification",
|
|
94
|
-
"failed_scope_violation",
|
|
95
|
-
"failed_policy_violation",
|
|
96
|
-
"canceled",
|
|
97
|
-
]);
|
|
98
|
-
|
|
99
90
|
export async function runTaskLoop(input: RunTaskLoopInput): Promise<RunTaskLoopOutput> {
|
|
100
91
|
return runTaskLoopWithDeps(input, DEFAULT_DEPS);
|
|
101
92
|
}
|
|
@@ -273,7 +264,7 @@ async function waitUntilTerminal(
|
|
|
273
264
|
const deadline = Date.now() + timeoutSeconds * 1000;
|
|
274
265
|
while (Date.now() < deadline) {
|
|
275
266
|
const waited = await deps.waitForTask(taskId, 30);
|
|
276
|
-
if (waited.terminal ||
|
|
267
|
+
if (waited.terminal || isTerminalTaskStatus(String(waited.status))) {
|
|
277
268
|
return { next_action: waited.next_action || "safe_audit" };
|
|
278
269
|
}
|
|
279
270
|
if (waited.next_tool_call?.name === "health_check" || waited.continuation_required === false) {
|
|
@@ -340,7 +331,7 @@ function isSuccessfulRound(round: TaskLineageRound): boolean {
|
|
|
340
331
|
}
|
|
341
332
|
|
|
342
333
|
function isHardStop(round: TaskLineageRound, resultValue: unknown, auditValue: unknown, stopOnHighRisk: boolean): boolean {
|
|
343
|
-
if (["failed_scope_violation", "failed_policy_violation", "canceled"].includes(round.status)) return true;
|
|
334
|
+
if (["failed_scope_violation", "failed_policy_violation", "canceled", "timeout"].includes(round.status)) return true;
|
|
344
335
|
if (!stopOnHighRisk) return false;
|
|
345
336
|
const checkNames = [...round.fail_checks, ...round.warn_checks].join(" ").toLowerCase();
|
|
346
337
|
const result = asRecord(resultValue);
|
|
@@ -352,6 +343,7 @@ function isHardStop(round: TaskLineageRound, resultValue: unknown, auditValue: u
|
|
|
352
343
|
function hardStopReason(round: TaskLineageRound, resultValue: unknown): TaskLoopStopReason {
|
|
353
344
|
const result = asRecord(resultValue);
|
|
354
345
|
if (round.status === "failed_scope_violation" || round.status === "failed_policy_violation") return "policy_blocked";
|
|
346
|
+
if (round.status === "timeout") return "agent_timeout";
|
|
355
347
|
if (String(result.failure_reason || "").toLowerCase().includes("timeout")) return "agent_timeout";
|
|
356
348
|
return "high_risk_blocked";
|
|
357
349
|
}
|
|
@@ -0,0 +1,54 @@
|
|
|
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
|
+
|
|
10
|
+
export const TERMINAL_TASK_STATUSES = new Set([
|
|
11
|
+
"done",
|
|
12
|
+
"done_by_agent",
|
|
13
|
+
"accepted",
|
|
14
|
+
"rejected",
|
|
15
|
+
"needs_fix",
|
|
16
|
+
"blocked",
|
|
17
|
+
"failed",
|
|
18
|
+
"failed_verification",
|
|
19
|
+
"failed_scope_violation",
|
|
20
|
+
"failed_policy_violation",
|
|
21
|
+
"failed_stale",
|
|
22
|
+
"orphaned",
|
|
23
|
+
"timeout",
|
|
24
|
+
"canceled",
|
|
25
|
+
]);
|
|
26
|
+
|
|
27
|
+
export function isTerminalTaskStatus(status: string): boolean {
|
|
28
|
+
return TERMINAL_TASK_STATUSES.has(status);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function isActiveTaskStatus(status: string): boolean {
|
|
32
|
+
return ACTIVE_TASK_STATUSES.has(status);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Terminal states are immutable; active states may only advance or terminate. */
|
|
36
|
+
export function isAllowedTaskStatusTransition(from: string, to: string): boolean {
|
|
37
|
+
if (from === to) return true;
|
|
38
|
+
if (isTerminalTaskStatus(from)) return false;
|
|
39
|
+
if (!isActiveTaskStatus(from) || !isActiveTaskStatus(to) && !isTerminalTaskStatus(to)) return false;
|
|
40
|
+
|
|
41
|
+
if (from === "pending") {
|
|
42
|
+
return to === "running" || isTerminalTaskStatus(to);
|
|
43
|
+
}
|
|
44
|
+
if (to === "pending") return false;
|
|
45
|
+
|
|
46
|
+
const forwardActive: Record<string, Set<string>> = {
|
|
47
|
+
running: new Set(["executing_agent", "verifying", "collecting_artifacts", "cancel_requested"]),
|
|
48
|
+
executing_agent: new Set(["verifying", "collecting_artifacts", "cancel_requested"]),
|
|
49
|
+
verifying: new Set(["collecting_artifacts", "cancel_requested"]),
|
|
50
|
+
collecting_artifacts: new Set(["cancel_requested"]),
|
|
51
|
+
cancel_requested: new Set(),
|
|
52
|
+
};
|
|
53
|
+
return isTerminalTaskStatus(to) || Boolean(forwardActive[from]?.has(to));
|
|
54
|
+
}
|
|
@@ -1,16 +1,7 @@
|
|
|
1
1
|
import { setTimeout as sleep } from "node:timers/promises";
|
|
2
2
|
import { getTaskStatus } from "./getTaskStatus.js";
|
|
3
3
|
import { getTaskSummary, type TaskSummaryResult } from "./getTaskSummary.js";
|
|
4
|
-
|
|
5
|
-
const TERMINAL_STATUSES = new Set([
|
|
6
|
-
"done",
|
|
7
|
-
"done_by_agent",
|
|
8
|
-
"failed",
|
|
9
|
-
"failed_verification",
|
|
10
|
-
"failed_scope_violation",
|
|
11
|
-
"failed_policy_violation",
|
|
12
|
-
"canceled",
|
|
13
|
-
]);
|
|
4
|
+
import { isTerminalTaskStatus } from "./taskStates.js";
|
|
14
5
|
|
|
15
6
|
export interface WaitForTaskProgressSummary {
|
|
16
7
|
phase: string;
|
|
@@ -45,12 +36,12 @@ export async function waitForTask(taskId: string, waitSeconds = 25): Promise<Wai
|
|
|
45
36
|
const deadline = started + waitSeconds * 1000;
|
|
46
37
|
let status = getTaskStatus(taskId);
|
|
47
38
|
|
|
48
|
-
while (!
|
|
39
|
+
while (!isTerminalTaskStatus(status.status) && Date.now() < deadline) {
|
|
49
40
|
await sleep(Math.min(500, Math.max(1, deadline - Date.now())));
|
|
50
41
|
status = getTaskStatus(taskId);
|
|
51
42
|
}
|
|
52
43
|
|
|
53
|
-
const terminal =
|
|
44
|
+
const terminal = isTerminalTaskStatus(status.status);
|
|
54
45
|
const executionBlocked = !terminal && status.execution_blocked;
|
|
55
46
|
const elapsed = Math.round((Date.now() - started) / 1000);
|
|
56
47
|
|
|
@@ -1,5 +1,5 @@
|
|
|
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
|
|
|
@@ -13,15 +13,25 @@ export interface AgentAvailability {
|
|
|
13
13
|
adapter: string | null;
|
|
14
14
|
model: string | null;
|
|
15
15
|
capabilities: { model_override: boolean };
|
|
16
|
+
availability_scope: "executable_only";
|
|
17
|
+
provider_status: "not_checked";
|
|
18
|
+
invocation_ready?: boolean;
|
|
19
|
+
model_argument_present?: boolean;
|
|
16
20
|
}
|
|
17
21
|
|
|
18
|
-
export function listAgents(): { agents: AgentAvailability[]; total: number } {
|
|
22
|
+
export function listAgents(): { agents: AgentAvailability[]; total: number; config_path: string; workspace_root: string } {
|
|
19
23
|
const config = getConfig();
|
|
20
24
|
const checkedAt = new Date().toISOString();
|
|
25
|
+
const configPath = process.env.PATCHWARDEN_CONFIG
|
|
26
|
+
? resolve(process.env.PATCHWARDEN_CONFIG)
|
|
27
|
+
: resolve(process.cwd(), "patchwarden.config.json");
|
|
21
28
|
const agents = Object.entries(config.agents)
|
|
22
29
|
.sort(([left], [right]) => left.localeCompare(right))
|
|
23
30
|
.map(([name, agent]) => {
|
|
24
31
|
const available = commandExists(agent.command, config.workspaceRoot);
|
|
32
|
+
const modelArgumentPresent = typeof agent.model === "string"
|
|
33
|
+
? agent.args.some((arg, index) => arg === "--model" && agent.args[index + 1] === agent.model)
|
|
34
|
+
: true;
|
|
25
35
|
return {
|
|
26
36
|
name,
|
|
27
37
|
configured: true as const,
|
|
@@ -32,9 +42,13 @@ export function listAgents(): { agents: AgentAvailability[]; total: number } {
|
|
|
32
42
|
adapter: typeof agent.adapter === "string" ? agent.adapter : ["codex", "opencode"].includes(name) ? name : null,
|
|
33
43
|
model: typeof agent.model === "string" ? agent.model : null,
|
|
34
44
|
capabilities: { model_override: typeof agent.adapter === "string" || ["codex", "opencode"].includes(name) },
|
|
45
|
+
availability_scope: "executable_only" as const,
|
|
46
|
+
provider_status: "not_checked" as const,
|
|
47
|
+
invocation_ready: available && modelArgumentPresent,
|
|
48
|
+
model_argument_present: modelArgumentPresent,
|
|
35
49
|
};
|
|
36
50
|
});
|
|
37
|
-
return { agents, total: agents.length };
|
|
51
|
+
return { agents, total: agents.length, config_path: configPath, workspace_root: config.workspaceRoot };
|
|
38
52
|
}
|
|
39
53
|
|
|
40
54
|
function commandExists(command: string, workspaceRoot: string): boolean {
|
package/src/utils/atomicFile.ts
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
|
|
|
4
4
|
export interface AtomicWriteOptions {
|
|
5
5
|
encoding?: BufferEncoding;
|
|
@@ -24,6 +24,22 @@ export function atomicWriteFileSync(
|
|
|
24
24
|
flag: "wx",
|
|
25
25
|
...(options.mode === undefined ? {} : { mode: options.mode }),
|
|
26
26
|
});
|
|
27
|
+
const descriptor = openSync(temporaryPath, "r");
|
|
28
|
+
try {
|
|
29
|
+
try {
|
|
30
|
+
fsyncSync(descriptor);
|
|
31
|
+
} catch (error) {
|
|
32
|
+
const code = error && typeof error === "object" && "code" in error
|
|
33
|
+
? String((error as NodeJS.ErrnoException).code || "")
|
|
34
|
+
: "";
|
|
35
|
+
// Some Windows filesystems and restricted runtimes reject fsync even
|
|
36
|
+
// after a successful complete write. Keep the same-directory atomic
|
|
37
|
+
// rename guarantee there; unexpected I/O errors still fail closed.
|
|
38
|
+
if (!new Set(["EPERM", "EINVAL", "ENOSYS", "ENOTSUP"]).has(code)) throw error;
|
|
39
|
+
}
|
|
40
|
+
} finally {
|
|
41
|
+
closeSync(descriptor);
|
|
42
|
+
}
|
|
27
43
|
replaceFileSync(temporaryPath, path);
|
|
28
44
|
} catch (error) {
|
|
29
45
|
try { rmSync(temporaryPath, { force: true }); } catch { /* best effort */ }
|
package/src/version.ts
CHANGED
package/ui/pages/dashboard.html
CHANGED
|
@@ -1132,6 +1132,7 @@ html.dark {
|
|
|
1132
1132
|
const svc = (status && status[prefix]) || {};
|
|
1133
1133
|
const tunnel = (status && status.tunnel && status.tunnel[prefix]) || {};
|
|
1134
1134
|
const tools = (status && status.tools && status.tools[prefix]) || {};
|
|
1135
|
+
const connection = (status && status.connections && status.connections[prefix]) || {};
|
|
1135
1136
|
|
|
1136
1137
|
const available = !!svc.available;
|
|
1137
1138
|
const kind = available ? 'success' : 'error';
|
|
@@ -1140,6 +1141,9 @@ html.dark {
|
|
|
1140
1141
|
const reasonText = svc.reason ? escapeHtml(svc.reason) : '—';
|
|
1141
1142
|
|
|
1142
1143
|
const rows = [
|
|
1144
|
+
{ label: 'profile', html: connection.profile ? escapeHtml(connection.profile) : '—' },
|
|
1145
|
+
{ label: 'tunnel_id', html: connection.tunnel_id_masked ? escapeHtml(connection.tunnel_id_masked) : '—' },
|
|
1146
|
+
{ label: 'reconnect', html: connection.reconnect_guidance ? escapeHtml(connection.reconnect_guidance) : '—' },
|
|
1143
1147
|
{ label: 'PID', html: tunnel.pid != null ? escapeHtml(tunnel.pid) : '—' },
|
|
1144
1148
|
{ label: 'tool_profile', html: tools.tool_profile ? escapeHtml(tools.tool_profile) : '—' },
|
|
1145
1149
|
{ label: 'tool_count', html: tools.tool_count != null ? escapeHtml(tools.tool_count) : '—' },
|
package/ui/pages/logs.html
CHANGED
|
@@ -97,6 +97,35 @@ html.dark {
|
|
|
97
97
|
<script src="/vendor/tailwindcss-browser.js"></script>
|
|
98
98
|
<script src="/vendor/lucide.js"></script>
|
|
99
99
|
<script src="/log-parser.js"></script>
|
|
100
|
+
<script>
|
|
101
|
+
// Keep the log page usable when an older packaged Control Center omits the
|
|
102
|
+
// parser asset. The server serves the canonical parser; this is only a
|
|
103
|
+
// bounded display fallback and never changes log contents.
|
|
104
|
+
if (!window.PatchWardenLogParser || typeof window.PatchWardenLogParser.parseLine !== 'function') {
|
|
105
|
+
window.PatchWardenLogParser = {
|
|
106
|
+
parseLine: function (rawLine, stream) {
|
|
107
|
+
var line = String(rawLine || '');
|
|
108
|
+
var source = String(stream || 'log');
|
|
109
|
+
var entry = null;
|
|
110
|
+
try {
|
|
111
|
+
var parsed = JSON.parse(line);
|
|
112
|
+
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) entry = parsed;
|
|
113
|
+
} catch (_) {}
|
|
114
|
+
var level = entry ? String(entry.level || entry.severity || '').toLowerCase() : '';
|
|
115
|
+
if (level === 'warning') level = 'warn';
|
|
116
|
+
return {
|
|
117
|
+
raw: line,
|
|
118
|
+
time: entry ? String(entry.time || entry.timestamp || '') : '',
|
|
119
|
+
level: level,
|
|
120
|
+
component: entry ? String(entry.component || entry.scope || entry.source || source) : source,
|
|
121
|
+
summary: entry ? String(entry.summary || entry.msg || entry.message || line) : line,
|
|
122
|
+
detail: entry ? JSON.stringify(entry, null, 2) : line,
|
|
123
|
+
structured: entry !== null
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
</script>
|
|
100
129
|
<style>
|
|
101
130
|
.no-scrollbar::-webkit-scrollbar { display: none; }
|
|
102
131
|
.no-scrollbar { -ms-overflow-style: none; scrollbar-width: none; }
|
package/ui/pages/settings.html
CHANGED
|
@@ -45,7 +45,7 @@
|
|
|
45
45
|
<section>
|
|
46
46
|
<h2 data-i18n="settings.mcpTunnel"></h2>
|
|
47
47
|
<div class="setting-row">
|
|
48
|
-
<div class="min-w-0"><strong>tunnel-client.exe</strong><p id="tunnelClientPath" class="mono break-path"
|
|
48
|
+
<div class="min-w-0"><strong>tunnel-client.exe</strong><p id="tunnelClientPath" class="mono break-path"></p></div>
|
|
49
49
|
<div class="inline-actions"><button id="detectTunnel"><i data-lucide="scan-search"></i><span data-i18n="settings.detect"></span></button><button id="chooseTunnel"><i data-lucide="file-search"></i><span data-i18n="settings.choose"></span></button></div>
|
|
50
50
|
</div>
|
|
51
51
|
<div class="setting-row">
|
|
@@ -82,8 +82,8 @@
|
|
|
82
82
|
</section>
|
|
83
83
|
<section>
|
|
84
84
|
<h2 data-i18n="settings.workspaceDiagnostics"></h2>
|
|
85
|
-
<div class="setting-row"><div class="min-w-0"><strong data-i18n="settings.currentConfig"></strong><p id="configPath" class="mono truncate"
|
|
86
|
-
<div class="setting-row"><div><strong data-i18n="settings.workspace"></strong><p id="workspacePath" class="mono"
|
|
85
|
+
<div class="setting-row"><div class="min-w-0"><strong data-i18n="settings.currentConfig"></strong><p id="configPath" class="mono truncate"></p></div><button id="openConfig" data-i18n-title="settings.openConfig"><i data-lucide="external-link"></i></button></div>
|
|
86
|
+
<div class="setting-row"><div><strong data-i18n="settings.workspace"></strong><p id="workspacePath" class="mono"></p></div><button id="changeWorkspace" data-i18n="settings.change"></button></div>
|
|
87
87
|
<div class="setting-actions"><button id="openLogs"><i data-lucide="folder-open"></i><span data-i18n="settings.openLogs"></span></button><button id="runDoctor"><i data-lucide="stethoscope"></i><span data-i18n="settings.runDoctor"></span></button></div>
|
|
88
88
|
<pre id="doctorOutput" class="hidden"></pre>
|
|
89
89
|
</section>
|
package/ui/settings.js
CHANGED
|
@@ -108,28 +108,37 @@
|
|
|
108
108
|
syncProxyControls();
|
|
109
109
|
}
|
|
110
110
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
111
|
+
async function initializeSettings() {
|
|
112
|
+
try {
|
|
113
|
+
var state = await api.getState();
|
|
114
|
+
var settings = state.runtimeSettings || await api.getRuntimeSettings();
|
|
115
|
+
renderRuntime(settings);
|
|
116
|
+
document.getElementById("configPath").textContent = state.configPath || tr("settings.notConfigured");
|
|
117
|
+
document.getElementById("workspacePath").textContent = state.workspaceRoot || tr("settings.workspaceHelp");
|
|
118
|
+
theme.value = state.preferences.theme;
|
|
119
|
+
language.value = state.preferences.language || "system";
|
|
120
|
+
closeBehavior.value = state.preferences.closeBehavior;
|
|
121
|
+
|
|
122
|
+
if (!settings.tunnelClientPath && state.tunnelClient && state.tunnelClient.available) {
|
|
123
|
+
selectedTunnelPath = state.tunnelClient.path;
|
|
124
|
+
tunnelClientPath.textContent = tr("settings.autoDetectedPath", { path: state.tunnelClient.path, source: state.tunnelClient.source });
|
|
125
|
+
setRuntimeMessage("settings.autoDetected");
|
|
126
|
+
} else if (!settings.tunnelClientPath) {
|
|
127
|
+
setRuntimeMessage("settings.autoDetecting");
|
|
128
|
+
var detected = await api.detectTunnelClient();
|
|
129
|
+
if (detected.available) {
|
|
130
|
+
selectedTunnelPath = detected.path;
|
|
131
|
+
tunnelClientPath.textContent = tr("settings.autoDetectedPath", { path: detected.path, source: detected.source });
|
|
126
132
|
setRuntimeMessage("settings.autoDetected");
|
|
127
133
|
} else {
|
|
128
134
|
setRuntimeMessage("settings.tunnelNotFound");
|
|
129
135
|
}
|
|
130
|
-
}
|
|
136
|
+
}
|
|
137
|
+
} catch (error) {
|
|
138
|
+
runtimeStatus.textContent = error && error.message ? error.message : tr("settings.loadFailed");
|
|
131
139
|
}
|
|
132
|
-
}
|
|
140
|
+
}
|
|
141
|
+
void initializeSettings();
|
|
133
142
|
void loadAgents(false);
|
|
134
143
|
document.getElementById("detectAgents").addEventListener("click", function () { void loadAgents(true); });
|
|
135
144
|
document.getElementById("saveAgents").addEventListener("click", async function () {
|