vibe-coding-master 0.6.23 → 0.7.1
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.md +12 -10
- package/dist/backend/api/harness-routes.js +10 -4
- package/dist/backend/api/task-routes.js +2 -32
- package/dist/backend/cli/install-vcm-harness.js +9 -1
- package/dist/backend/gateway/gateway-service.js +4 -37
- package/dist/backend/server.js +35 -16
- package/dist/backend/services/auto-memory-service.js +57 -50
- package/dist/backend/services/claude-hook-service.js +42 -2
- package/dist/backend/services/claude-transcript-reply.js +81 -1
- package/dist/backend/services/harness-service.js +10 -1
- package/dist/backend/services/runtime-coordinator-service.js +43 -9
- package/dist/backend/services/runtime-recovery-service.js +6 -0
- package/dist/backend/services/task-close-service.js +88 -0
- package/dist/backend/services/task-service.js +152 -35
- package/dist/backend/services/turn-reconciler-service.js +122 -0
- package/dist/backend/templates/harness/claude-root.js +1 -0
- package/dist/backend/templates/harness/harness-engineer-agent.js +15 -17
- package/dist/backend/templates/harness/role-memory.js +11 -1
- package/dist/backend/templates/harness/vcm-propose-memory-skill.js +35 -0
- package/dist-frontend/assets/{index-9V9COJZy.js → index-e8Tqa8Qh.js} +26 -25
- package/dist-frontend/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorToolRoleName, isUserFacingRole, isVcmRoleName } from "../../shared/constants.js";
|
|
2
2
|
import { VcmError } from "../errors.js";
|
|
3
|
-
import { readLatestRoleTurnReply } from "./claude-transcript-reply.js";
|
|
3
|
+
import { readLatestRoleTurnReply, readTranscriptTurnEvidence } from "./claude-transcript-reply.js";
|
|
4
4
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
5
5
|
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
6
6
|
const MAX_ROLE_RETRY_ATTEMPTS = 20;
|
|
@@ -11,7 +11,9 @@ const NON_RETRYABLE_STOP_FAILURE_ERRORS = new Set([
|
|
|
11
11
|
"billing_error",
|
|
12
12
|
"invalid_request",
|
|
13
13
|
"model_not_found",
|
|
14
|
-
"max_output_tokens"
|
|
14
|
+
"max_output_tokens",
|
|
15
|
+
"terminal_session_exited",
|
|
16
|
+
"terminal_session_missing"
|
|
15
17
|
]);
|
|
16
18
|
const DIAGNOSTIC_SNIPPET_MAX_LENGTH = 2000;
|
|
17
19
|
export function createClaudeHookService(deps) {
|
|
@@ -220,6 +222,9 @@ export function createClaudeHookService(deps) {
|
|
|
220
222
|
throwUnsupportedEvent(eventName);
|
|
221
223
|
}
|
|
222
224
|
const context = await getHookContext(input);
|
|
225
|
+
if (await isDuplicateCompletedStop(context, input)) {
|
|
226
|
+
return completedHookResult(input, eventName);
|
|
227
|
+
}
|
|
223
228
|
const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
|
|
224
229
|
if (memoryResult) {
|
|
225
230
|
return memoryResult;
|
|
@@ -252,6 +257,31 @@ export function createClaudeHookService(deps) {
|
|
|
252
257
|
settleGuard: true
|
|
253
258
|
});
|
|
254
259
|
}
|
|
260
|
+
async function isDuplicateCompletedStop(context, input) {
|
|
261
|
+
const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, input.role);
|
|
262
|
+
if (!session || session.activityStatus === "running" || !session.lastTurnEndedAt) {
|
|
263
|
+
return false;
|
|
264
|
+
}
|
|
265
|
+
const evidence = await readTranscriptTurnEvidence(session);
|
|
266
|
+
if (!evidence.completion) {
|
|
267
|
+
return false;
|
|
268
|
+
}
|
|
269
|
+
const completionAt = Date.parse(evidence.completion.timestamp);
|
|
270
|
+
const recordedEndAt = Date.parse(session.lastTurnEndedAt);
|
|
271
|
+
return Number.isFinite(completionAt)
|
|
272
|
+
&& Number.isFinite(recordedEndAt)
|
|
273
|
+
&& completionAt <= recordedEndAt + 1_000;
|
|
274
|
+
}
|
|
275
|
+
function completedHookResult(input, eventName) {
|
|
276
|
+
return {
|
|
277
|
+
ok: true,
|
|
278
|
+
eventName,
|
|
279
|
+
taskSlug: input.taskSlug,
|
|
280
|
+
role: input.role,
|
|
281
|
+
sessionUpdated: false,
|
|
282
|
+
dispatchedCount: 0
|
|
283
|
+
};
|
|
284
|
+
}
|
|
255
285
|
async function processStopFailureHook(input) {
|
|
256
286
|
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
257
287
|
if (eventName !== "StopFailure") {
|
|
@@ -699,6 +729,16 @@ export function createClaudeHookService(deps) {
|
|
|
699
729
|
}
|
|
700
730
|
return processStopHook(input, { allowBlock: true });
|
|
701
731
|
},
|
|
732
|
+
handleReconciledTurnEnd(input) {
|
|
733
|
+
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
734
|
+
if (eventName === "Stop") {
|
|
735
|
+
return processStopHook(input, { allowBlock: false });
|
|
736
|
+
}
|
|
737
|
+
if (eventName === "StopFailure") {
|
|
738
|
+
return processStopFailureHook(input);
|
|
739
|
+
}
|
|
740
|
+
throwUnsupportedEvent(eventName);
|
|
741
|
+
},
|
|
702
742
|
handlePermissionRequestHook
|
|
703
743
|
};
|
|
704
744
|
}
|
|
@@ -1,9 +1,10 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
1
|
+
import { open, readFile } from "node:fs/promises";
|
|
2
2
|
import { parseAssistantContent, resolveExistingClaudeTranscriptPath } from "./claude-transcript-service.js";
|
|
3
3
|
/** Default maximum captured-reply length (characters). */
|
|
4
4
|
export const MAX_TURN_REPLY_CHARS = 8_000;
|
|
5
5
|
/** Tolerance applied when matching transcript events to the role's last-turn window. */
|
|
6
6
|
const TURN_WINDOW_TOLERANCE_MS = 1_000;
|
|
7
|
+
const TRANSCRIPT_EVIDENCE_TAIL_BYTES = 2 * 1024 * 1024;
|
|
7
8
|
/**
|
|
8
9
|
* Best-effort read of a role's latest user-facing turn reply.
|
|
9
10
|
*
|
|
@@ -48,6 +49,80 @@ export async function readTranscriptTextEvents(transcriptPath) {
|
|
|
48
49
|
}
|
|
49
50
|
return events;
|
|
50
51
|
}
|
|
52
|
+
/** Read transcript activity and a completed assistant turn after this turn began. */
|
|
53
|
+
export async function readTranscriptTurnEvidence(session) {
|
|
54
|
+
const transcriptPath = resolveExistingClaudeTranscriptPath(session);
|
|
55
|
+
if (!transcriptPath) {
|
|
56
|
+
return {};
|
|
57
|
+
}
|
|
58
|
+
let raw;
|
|
59
|
+
let modifiedAt;
|
|
60
|
+
let handle;
|
|
61
|
+
try {
|
|
62
|
+
handle = await open(transcriptPath, "r");
|
|
63
|
+
const metadata = await handle.stat();
|
|
64
|
+
modifiedAt = metadata.mtime.toISOString();
|
|
65
|
+
const readLength = Math.min(metadata.size, TRANSCRIPT_EVIDENCE_TAIL_BYTES);
|
|
66
|
+
const readOffset = Math.max(0, metadata.size - readLength);
|
|
67
|
+
const buffer = Buffer.alloc(readLength);
|
|
68
|
+
await handle.read(buffer, 0, readLength, readOffset);
|
|
69
|
+
raw = buffer.toString("utf8");
|
|
70
|
+
if (readOffset > 0) {
|
|
71
|
+
const firstNewline = raw.indexOf("\n");
|
|
72
|
+
raw = firstNewline >= 0 ? raw.slice(firstNewline + 1) : "";
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
catch {
|
|
76
|
+
return {};
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
await handle?.close().catch(() => undefined);
|
|
80
|
+
}
|
|
81
|
+
const turnStartedAtMs = timestampMs(session.lastTurnStartedAt);
|
|
82
|
+
let lastActivityAt;
|
|
83
|
+
let completion;
|
|
84
|
+
for (const line of raw.split("\n")) {
|
|
85
|
+
if (!line.trim()) {
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
let record;
|
|
89
|
+
try {
|
|
90
|
+
record = JSON.parse(line);
|
|
91
|
+
}
|
|
92
|
+
catch {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const timestamp = typeof record.timestamp === "string" ? record.timestamp : undefined;
|
|
96
|
+
if (timestamp && isLaterTimestamp(timestamp, lastActivityAt)) {
|
|
97
|
+
lastActivityAt = timestamp;
|
|
98
|
+
}
|
|
99
|
+
if (record.type !== "assistant" || !timestamp) {
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
const message = record.message;
|
|
103
|
+
if (message?.model === "<synthetic>" || message?.stop_reason !== "end_turn") {
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
const completionAtMs = timestampMs(timestamp);
|
|
107
|
+
if (completionAtMs === undefined
|
|
108
|
+
|| (turnStartedAtMs !== undefined && completionAtMs < turnStartedAtMs - TURN_WINDOW_TOLERANCE_MS)) {
|
|
109
|
+
continue;
|
|
110
|
+
}
|
|
111
|
+
if (!completion || isLaterTimestamp(timestamp, completion.timestamp)) {
|
|
112
|
+
completion = {
|
|
113
|
+
id: typeof record.uuid === "string" ? record.uuid : null,
|
|
114
|
+
timestamp
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (isLaterTimestamp(modifiedAt, lastActivityAt)) {
|
|
119
|
+
lastActivityAt = modifiedAt;
|
|
120
|
+
}
|
|
121
|
+
return {
|
|
122
|
+
...(lastActivityAt ? { lastActivityAt } : {}),
|
|
123
|
+
...(completion ? { completion } : {})
|
|
124
|
+
};
|
|
125
|
+
}
|
|
51
126
|
/** True for a text event that completed a turn (assistant stopped of its own accord). */
|
|
52
127
|
export function isFinalTurnTextEvent(event) {
|
|
53
128
|
return event.stopReason === "end_turn";
|
|
@@ -105,3 +180,8 @@ function timestampMs(value) {
|
|
|
105
180
|
const parsed = Date.parse(value);
|
|
106
181
|
return Number.isFinite(parsed) ? parsed : undefined;
|
|
107
182
|
}
|
|
183
|
+
function isLaterTimestamp(candidate, current) {
|
|
184
|
+
const candidateMs = timestampMs(candidate);
|
|
185
|
+
const currentMs = timestampMs(current);
|
|
186
|
+
return candidateMs !== undefined && (currentMs === undefined || candidateMs > currentMs);
|
|
187
|
+
}
|
|
@@ -17,6 +17,7 @@ import { renderTesterHarnessRules } from "../templates/harness/tester-agent.js";
|
|
|
17
17
|
import { renderVcmFinalAcceptanceSkillRules } from "../templates/harness/vcm-final-acceptance-skill.js";
|
|
18
18
|
import { renderVcmHarnessBootstrapSkillRules } from "../templates/harness/vcm-harness-bootstrap-skill.js";
|
|
19
19
|
import { renderVcmLongRunningValidationSkillRules } from "../templates/harness/vcm-long-running-validation-skill.js";
|
|
20
|
+
import { renderVcmProposeMemorySkillRules } from "../templates/harness/vcm-propose-memory-skill.js";
|
|
20
21
|
import { renderVcmReportHarnessIssueSkillRules } from "../templates/harness/vcm-report-harness-issue-skill.js";
|
|
21
22
|
import { renderVcmRouteMessageSkillRules } from "../templates/harness/vcm-route-message-skill.js";
|
|
22
23
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
@@ -37,7 +38,7 @@ const LEGACY_CODEX_HARNESS_PATHS = [
|
|
|
37
38
|
".ai/tools/request-codex-review"
|
|
38
39
|
];
|
|
39
40
|
const VCM_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code" -H "content-type: application/json" --data-binary @- >/dev/null || true'`;
|
|
40
|
-
const VCM_STOP_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time
|
|
41
|
+
const VCM_STOP_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --retry 2 --retry-delay 1 --retry-all-errors --connect-timeout 1 --max-time 2 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || true'`;
|
|
41
42
|
const VCM_PERMISSION_REQUEST_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ] || [ -z "\${VCM_API_URL:-}" ]; then exit 0; fi; node -e '"'"'let s="";process.stdin.setEncoding("utf8");process.stdin.on("data",d=>s+=d);process.stdin.on("end",()=>{let event={};try{event=s.trim()?JSON.parse(s):{};}catch{event={raw:s};}process.stdout.write(JSON.stringify({taskSlug:process.env.VCM_TASK_SLUG,role:process.env.VCM_ROLE,event}));});'"'"' | curl -fsS --max-time 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/permission-request" -H "content-type: application/json" --data-binary @- || true'`;
|
|
42
43
|
const VCM_BASH_GUARD_HOOK_COMMAND = `sh -c 'if [ -z "\${VCM_TASK_SLUG:-}" ] || [ -z "\${VCM_ROLE:-}" ]; then exit 0; fi; guard=""; repo="$(git rev-parse --show-toplevel 2>/dev/null || true)"; if [ -n "$repo" ] && [ -f "$repo/.ai/tools/vcm-bash-guard" ]; then guard="$repo/.ai/tools/vcm-bash-guard"; else cwd="$(pwd -P 2>/dev/null || pwd)"; dir="$cwd"; while [ -n "$dir" ] && [ "$dir" != "/" ]; do if [ -f "$dir/.ai/tools/vcm-bash-guard" ]; then guard="$dir/.ai/tools/vcm-bash-guard"; break; fi; dir="$(dirname "$dir")"; done; if [ -z "$guard" ] && [ -n "\${CLAUDE_PROJECT_DIR:-}" ] && [ -f "\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard" ]; then guard="\${CLAUDE_PROJECT_DIR}/.ai/tools/vcm-bash-guard"; fi; fi; [ -n "$guard" ] || exit 0; python3 "$guard" || exit 0'`;
|
|
43
44
|
const VCM_BASH_DEFAULT_TIMEOUT_MS = "600000";
|
|
@@ -142,6 +143,14 @@ const HARNESS_FILES = [
|
|
|
142
143
|
ownership: "whole-file",
|
|
143
144
|
renderRules: renderVcmReportHarnessIssueSkillRules
|
|
144
145
|
},
|
|
146
|
+
{
|
|
147
|
+
kind: "skill-vcm-propose-memory",
|
|
148
|
+
path: ".claude/skills/vcm-propose-memory/SKILL.md",
|
|
149
|
+
title: "VCM Propose Memory Skill",
|
|
150
|
+
frontmatter: renderSkillFrontmatter("vcm-propose-memory", "Use only when VCM requests a role memory proposal during Task Harness Review."),
|
|
151
|
+
ownership: "whole-file",
|
|
152
|
+
renderRules: renderVcmProposeMemorySkillRules
|
|
153
|
+
},
|
|
145
154
|
{
|
|
146
155
|
kind: "agent-gate-reviewer",
|
|
147
156
|
path: ".claude/agents/gate-reviewer.md",
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { isVcmRoleName } from "../../shared/constants.js";
|
|
2
2
|
import { VcmError } from "../errors.js";
|
|
3
3
|
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
4
|
+
const RUNTIME_RECONCILE_INTERVAL_MS = 10_000;
|
|
4
5
|
const EXPECTED_AUTO_RETROSPECTIVE_SKIP_CODES = new Set([
|
|
5
6
|
"HARNESS_FEEDBACK_ACTIVE",
|
|
6
7
|
"TASK_HARNESS_RETROSPECTIVE_EXISTS",
|
|
@@ -11,6 +12,9 @@ const EXPECTED_AUTO_RETROSPECTIVE_SKIP_CODES = new Set([
|
|
|
11
12
|
]);
|
|
12
13
|
export function createRuntimeCoordinatorService(deps) {
|
|
13
14
|
const locks = new Map();
|
|
15
|
+
const setTimer = deps.setInterval ?? ((callback, delayMs) => globalThis.setInterval(callback, delayMs));
|
|
16
|
+
const clearTimer = deps.clearInterval ?? ((timer) => globalThis.clearInterval(timer));
|
|
17
|
+
let reconcileTimer;
|
|
14
18
|
async function withRepoLock(repoRoot, run) {
|
|
15
19
|
const previous = locks.get(repoRoot) ?? Promise.resolve({
|
|
16
20
|
activeTask: null,
|
|
@@ -31,6 +35,22 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
31
35
|
}
|
|
32
36
|
}
|
|
33
37
|
return {
|
|
38
|
+
start() {
|
|
39
|
+
if (reconcileTimer !== undefined) {
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
reconcileTimer = setTimer(() => {
|
|
43
|
+
void reconcileCurrentProject().catch(() => undefined);
|
|
44
|
+
}, RUNTIME_RECONCILE_INTERVAL_MS);
|
|
45
|
+
void reconcileCurrentProject().catch(() => undefined);
|
|
46
|
+
},
|
|
47
|
+
stop() {
|
|
48
|
+
if (reconcileTimer === undefined) {
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
clearTimer(reconcileTimer);
|
|
52
|
+
reconcileTimer = undefined;
|
|
53
|
+
},
|
|
34
54
|
reconcileProject(repoRoot, input = {}) {
|
|
35
55
|
return withRepoLock(repoRoot, async () => {
|
|
36
56
|
const [activeTask, gatewayStatus] = await Promise.all([
|
|
@@ -42,6 +62,8 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
42
62
|
return { activeTask: null, gatewayStatus };
|
|
43
63
|
}
|
|
44
64
|
const taskRepoRoot = getTaskRuntimeRepoRoot(activeTask);
|
|
65
|
+
const stateRoot = await deps.getStateRoot(repoRoot);
|
|
66
|
+
await deps.turnReconciler.reconcileTask(repoRoot, activeTask, stateRoot);
|
|
45
67
|
const harnessInitialized = await deps.harnessService.getHarnessStatus(taskRepoRoot)
|
|
46
68
|
.then((status) => status.initialized)
|
|
47
69
|
.catch(() => false);
|
|
@@ -55,17 +77,28 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
55
77
|
else {
|
|
56
78
|
await deps.translationService.stopTask(taskRepoRoot, activeTask.taskSlug).catch(() => undefined);
|
|
57
79
|
}
|
|
58
|
-
await reconcileAutoMemory(repoRoot, activeTask);
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
await maybeStartTaskHarnessRetrospective(repoRoot, activeTask);
|
|
63
|
-
}
|
|
80
|
+
await reconcileAutoMemory(repoRoot, activeTask, preferences.autoTaskHarnessReviewEnabled ? "auto" : undefined);
|
|
81
|
+
const memoryReadiness = await getTaskRetrospectiveMemoryReadiness(repoRoot, activeTask);
|
|
82
|
+
if ((preferences.autoTaskHarnessReviewEnabled || memoryReadiness.trigger) && memoryReadiness.ready) {
|
|
83
|
+
await maybeStartTaskHarnessRetrospective(repoRoot, activeTask, memoryReadiness.trigger ?? "auto");
|
|
64
84
|
}
|
|
65
85
|
return { activeTask, gatewayStatus };
|
|
66
86
|
});
|
|
67
87
|
}
|
|
68
88
|
};
|
|
89
|
+
async function reconcileCurrentProject() {
|
|
90
|
+
const project = await deps.projectService.getCurrentProject();
|
|
91
|
+
if (!project) {
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
await withRepoLock(project.repoRoot, async () => {
|
|
95
|
+
const activeTask = await resolveActiveTask(project.repoRoot);
|
|
96
|
+
if (activeTask) {
|
|
97
|
+
await deps.turnReconciler.reconcileTask(project.repoRoot, activeTask, await deps.getStateRoot(project.repoRoot));
|
|
98
|
+
}
|
|
99
|
+
return { activeTask, gatewayStatus: null };
|
|
100
|
+
});
|
|
101
|
+
}
|
|
69
102
|
async function resolveActiveTask(repoRoot, requestedTaskSlug) {
|
|
70
103
|
const tasks = await deps.taskService.listTasks(repoRoot);
|
|
71
104
|
const activeTasks = tasks.filter((task) => task.cleanupStatus !== "cleaned");
|
|
@@ -134,7 +167,7 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
134
167
|
throw error;
|
|
135
168
|
}
|
|
136
169
|
}
|
|
137
|
-
async function maybeStartTaskHarnessRetrospective(repoRoot, task) {
|
|
170
|
+
async function maybeStartTaskHarnessRetrospective(repoRoot, task, trigger) {
|
|
138
171
|
const stateRoot = await deps.getStateRoot(repoRoot);
|
|
139
172
|
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
140
173
|
const roundState = await deps.roundService.getSessionRoundState({
|
|
@@ -153,7 +186,7 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
153
186
|
taskSlug: task.taskSlug,
|
|
154
187
|
taskRepoRoot,
|
|
155
188
|
handoffDir: task.handoffDir,
|
|
156
|
-
trigger
|
|
189
|
+
trigger
|
|
157
190
|
});
|
|
158
191
|
}
|
|
159
192
|
catch (error) {
|
|
@@ -163,7 +196,7 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
163
196
|
throw error;
|
|
164
197
|
}
|
|
165
198
|
}
|
|
166
|
-
async function reconcileAutoMemory(repoRoot, task) {
|
|
199
|
+
async function reconcileAutoMemory(repoRoot, task, requestTrigger) {
|
|
167
200
|
const stateRoot = await deps.getStateRoot(repoRoot);
|
|
168
201
|
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
169
202
|
const roundState = await deps.roundService.getSessionRoundState({
|
|
@@ -177,6 +210,7 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
177
210
|
taskRepoRoot,
|
|
178
211
|
taskSlug: task.taskSlug,
|
|
179
212
|
handoffDir: task.handoffDir,
|
|
213
|
+
requestTrigger,
|
|
180
214
|
roundReady: roundState.status === "stopped"
|
|
181
215
|
&& Boolean(roundState.roundId)
|
|
182
216
|
&& roundState.roleRecovery?.status !== "failed"
|
|
@@ -22,6 +22,12 @@ export function createRuntimeRecoveryService(deps) {
|
|
|
22
22
|
await runStep(context, "recover harness bootstrap", () => recoverHarnessBootstrap(repoRoot, recoveredAt, context));
|
|
23
23
|
await runStep(context, "recover harness feedback", () => recoverHarnessFeedback(repoRoot, recoveredAt, context));
|
|
24
24
|
const tasks = await deps.taskService.listTasks(repoRoot);
|
|
25
|
+
for (const task of tasks.filter((candidate) => candidate.cleanupStatus === "cleaned")) {
|
|
26
|
+
await runStep(context, `retry cleaned task ${task.taskSlug}`, async () => {
|
|
27
|
+
const result = await deps.taskService.cleanupTask(repoRoot, task.taskSlug);
|
|
28
|
+
context.warnings.push(...(result.warnings ?? []).map((warning) => `${task.taskSlug}: ${warning}`));
|
|
29
|
+
});
|
|
30
|
+
}
|
|
25
31
|
for (const task of tasks.filter((candidate) => candidate.cleanupStatus !== "cleaned")) {
|
|
26
32
|
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
27
33
|
await runStep(context, `recover task ${task.taskSlug}`, async () => {
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { VCM_ROLE_NAMES } from "../../shared/constants.js";
|
|
2
|
+
import { VcmError } from "../errors.js";
|
|
3
|
+
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
4
|
+
export function createTaskCloseService(deps) {
|
|
5
|
+
return {
|
|
6
|
+
async closeTask(repoRoot, taskSlug) {
|
|
7
|
+
const task = await deps.taskService.markTaskCleaned(repoRoot, taskSlug);
|
|
8
|
+
const warnings = [];
|
|
9
|
+
await stopTaskRoleSessions(repoRoot, taskSlug, warnings);
|
|
10
|
+
await moveOrStopProjectToolSession("Translator", () => deps.sessionService.moveProjectTranslatorSessionToSafeCwd(repoRoot), () => deps.sessionService.stopProjectTranslatorSession(repoRoot), warnings);
|
|
11
|
+
await moveOrStopProjectToolSession("Harness Engineer", () => deps.sessionService.moveProjectHarnessEngineerSessionToSafeCwd(repoRoot), () => deps.sessionService.stopProjectHarnessEngineerSession(repoRoot), warnings);
|
|
12
|
+
await bestEffort("Unable to stop task translation runtime", () => deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), taskSlug, { clearCache: true }), warnings);
|
|
13
|
+
await bestEffort("Unable to clear task round runtime", () => deps.roundService.stopTask(taskSlug), warnings);
|
|
14
|
+
try {
|
|
15
|
+
const result = await deps.taskService.cleanupTask(repoRoot, taskSlug);
|
|
16
|
+
const combinedWarnings = [...warnings, ...(result.warnings ?? [])];
|
|
17
|
+
return {
|
|
18
|
+
...result,
|
|
19
|
+
warnings: combinedWarnings.length > 0 ? combinedWarnings : undefined
|
|
20
|
+
};
|
|
21
|
+
}
|
|
22
|
+
catch (error) {
|
|
23
|
+
warnings.push(`Task was closed, but resource cleanup did not finish: ${describeError(error)}`);
|
|
24
|
+
return {
|
|
25
|
+
taskSlug,
|
|
26
|
+
taskClosed: true,
|
|
27
|
+
worktreeRemoved: false,
|
|
28
|
+
branchDeleted: false,
|
|
29
|
+
stateRemoved: false,
|
|
30
|
+
removedWorktreePath: null,
|
|
31
|
+
removedStatePaths: [],
|
|
32
|
+
deletedBranch: null,
|
|
33
|
+
cleanedAt: task.cleanedAt ?? task.updatedAt,
|
|
34
|
+
warnings
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
};
|
|
39
|
+
async function stopTaskRoleSessions(repoRoot, taskSlug, warnings) {
|
|
40
|
+
let sessions;
|
|
41
|
+
try {
|
|
42
|
+
sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|
|
43
|
+
}
|
|
44
|
+
catch (error) {
|
|
45
|
+
warnings.push(`Unable to list task role sessions during close: ${describeError(error)}`);
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
for (const session of sessions) {
|
|
49
|
+
if (session.status !== "running" || !VCM_ROLE_NAMES.some((role) => role === session.role)) {
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
await bestEffort(`Unable to stop ${session.role} session`, () => deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role), warnings);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
async function moveOrStopProjectToolSession(label, move, stop, warnings) {
|
|
57
|
+
try {
|
|
58
|
+
await move();
|
|
59
|
+
}
|
|
60
|
+
catch (error) {
|
|
61
|
+
if (isMissingSession(error)) {
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
warnings.push(`Unable to move ${label} session to the base repository: ${describeError(error)}`);
|
|
65
|
+
try {
|
|
66
|
+
await stop();
|
|
67
|
+
}
|
|
68
|
+
catch (stopError) {
|
|
69
|
+
if (!isMissingSession(stopError)) {
|
|
70
|
+
warnings.push(`Unable to stop ${label} session after cwd migration failed: ${describeError(stopError)}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
async function bestEffort(message, operation, warnings) {
|
|
76
|
+
try {
|
|
77
|
+
await operation();
|
|
78
|
+
}
|
|
79
|
+
catch (error) {
|
|
80
|
+
warnings.push(`${message}: ${describeError(error)}`);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
function isMissingSession(error) {
|
|
84
|
+
return error instanceof VcmError && error.code === "SESSION_MISSING";
|
|
85
|
+
}
|
|
86
|
+
function describeError(error) {
|
|
87
|
+
return error instanceof Error ? error.message : String(error);
|
|
88
|
+
}
|
|
@@ -118,61 +118,130 @@ export function createTaskService(deps) {
|
|
|
118
118
|
await this.saveTask(repoRoot, updated);
|
|
119
119
|
return updated;
|
|
120
120
|
},
|
|
121
|
-
async
|
|
122
|
-
assertValidTaskSlug(taskSlug);
|
|
123
|
-
if (!deps.fs.removePath) {
|
|
124
|
-
throw new VcmError({
|
|
125
|
-
code: "FILESYSTEM_REMOVE_UNAVAILABLE",
|
|
126
|
-
message: "This VCM runtime cannot remove task files.",
|
|
127
|
-
statusCode: 500
|
|
128
|
-
});
|
|
129
|
-
}
|
|
130
|
-
const config = await deps.projectService.loadConfig(repoRoot);
|
|
121
|
+
async markTaskCleaned(repoRoot, taskSlug) {
|
|
131
122
|
const task = await this.loadTask(repoRoot, taskSlug);
|
|
123
|
+
if (task.cleanupStatus === "cleaned" && task.cleanedAt) {
|
|
124
|
+
return task;
|
|
125
|
+
}
|
|
126
|
+
const timestamp = now();
|
|
127
|
+
const updated = {
|
|
128
|
+
...task,
|
|
129
|
+
status: "stopped",
|
|
130
|
+
cleanupStatus: "cleaned",
|
|
131
|
+
cleanedAt: timestamp,
|
|
132
|
+
updatedAt: timestamp
|
|
133
|
+
};
|
|
134
|
+
await this.saveTask(repoRoot, updated);
|
|
135
|
+
return updated;
|
|
136
|
+
},
|
|
137
|
+
async cleanupTask(repoRoot, taskSlug) {
|
|
138
|
+
assertValidTaskSlug(taskSlug);
|
|
139
|
+
const task = await this.markTaskCleaned(repoRoot, taskSlug);
|
|
132
140
|
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
133
141
|
const taskStoreRoot = deps.projectService.getProjectDataRoot(repoRoot);
|
|
134
142
|
const taskPath = getTaskPath(taskStoreRoot, taskSlug);
|
|
135
|
-
const statePaths = getTaskStatePaths(taskStoreRoot, taskRepoRoot, config.stateRoot, config.handoffRoot, taskSlug);
|
|
136
|
-
const removedStatePaths = [];
|
|
137
143
|
const warnings = [];
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
144
|
+
if (!deps.fs.removePath) {
|
|
145
|
+
warnings.push("This VCM runtime cannot remove task files; the task remains logically closed.");
|
|
146
|
+
}
|
|
147
|
+
let stateRoot = ".ai/vcm";
|
|
148
|
+
let handoffRoot = task.handoffDir;
|
|
149
|
+
try {
|
|
150
|
+
const config = await deps.projectService.loadConfig(repoRoot);
|
|
151
|
+
stateRoot = config.stateRoot;
|
|
152
|
+
handoffRoot = config.handoffRoot;
|
|
153
|
+
}
|
|
154
|
+
catch (error) {
|
|
155
|
+
warnings.push(`Unable to load project cleanup paths; using task defaults: ${describeError(error)}`);
|
|
156
|
+
}
|
|
157
|
+
const statePaths = getTaskStatePaths(taskStoreRoot, taskRepoRoot, stateRoot, handoffRoot, taskSlug);
|
|
158
|
+
const removedStatePaths = [];
|
|
159
|
+
let worktreeRemoved = false;
|
|
160
|
+
try {
|
|
161
|
+
assertTaskWorktreePath(repoRoot, task.worktreePath);
|
|
162
|
+
worktreeRemoved = await removeTaskWorktreeBestEffort(deps.fs, deps.git, repoRoot, task.worktreePath, warnings);
|
|
163
|
+
}
|
|
164
|
+
catch (error) {
|
|
165
|
+
warnings.push(`Skipped unsafe task worktree path ${task.worktreePath}: ${describeError(error)}`);
|
|
166
|
+
}
|
|
167
|
+
const branchCleanup = await deleteTaskBranchBestEffort(deps.git, repoRoot, task.branch, warnings);
|
|
142
168
|
for (const statePath of statePaths.filter((candidate) => candidate !== taskPath)) {
|
|
143
169
|
await removeWorktreeStatePathBestEffort(deps.fs, statePath, removedStatePaths, warnings);
|
|
144
170
|
}
|
|
145
|
-
|
|
146
|
-
|
|
171
|
+
let stateRemoved = false;
|
|
172
|
+
if (worktreeRemoved && branchCleanup.resolved) {
|
|
173
|
+
try {
|
|
174
|
+
await deps.fs.removePath?.(taskPath, { recursive: true, force: true });
|
|
175
|
+
stateRemoved = !(await deps.fs.pathExists(taskPath));
|
|
176
|
+
if (stateRemoved) {
|
|
177
|
+
removedStatePaths.push(taskPath);
|
|
178
|
+
}
|
|
179
|
+
else {
|
|
180
|
+
warnings.push(`Task state remained after cleanup: ${taskPath}`);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
catch (error) {
|
|
184
|
+
warnings.push(`Unable to remove cleaned task state ${taskPath}: ${describeError(error)}`);
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
else {
|
|
188
|
+
warnings.push("Retained cleaned task state so unresolved resource cleanup can be retried later.");
|
|
189
|
+
}
|
|
147
190
|
return {
|
|
148
191
|
taskSlug,
|
|
149
|
-
|
|
192
|
+
taskClosed: true,
|
|
193
|
+
worktreeRemoved,
|
|
194
|
+
branchDeleted: branchCleanup.resolved,
|
|
195
|
+
stateRemoved,
|
|
196
|
+
removedWorktreePath: worktreeRemoved ? task.worktreePath : null,
|
|
150
197
|
removedStatePaths,
|
|
151
|
-
deletedBranch: task.branch,
|
|
152
|
-
cleanedAt,
|
|
198
|
+
deletedBranch: branchCleanup.deleted ? task.branch : null,
|
|
199
|
+
cleanedAt: task.cleanedAt ?? now(),
|
|
153
200
|
warnings: warnings.length > 0 ? warnings : undefined
|
|
154
201
|
};
|
|
155
202
|
}
|
|
156
203
|
};
|
|
157
204
|
}
|
|
158
|
-
async function
|
|
159
|
-
|
|
160
|
-
|
|
205
|
+
async function removeTaskWorktreeBestEffort(fs, git, repoRoot, worktreePath, warnings) {
|
|
206
|
+
let wasRegistered;
|
|
207
|
+
try {
|
|
208
|
+
wasRegistered = await git.isWorktreeRegistered(repoRoot, worktreePath);
|
|
209
|
+
}
|
|
210
|
+
catch (error) {
|
|
211
|
+
warnings.push(`Unable to inspect task worktree registration for ${worktreePath}: ${describeError(error)}`);
|
|
212
|
+
}
|
|
213
|
+
if (wasRegistered !== false) {
|
|
161
214
|
try {
|
|
162
|
-
await git.removeWorktree(repoRoot, worktreePath, { force });
|
|
215
|
+
await git.removeWorktree(repoRoot, worktreePath, { force: true });
|
|
163
216
|
}
|
|
164
217
|
catch (error) {
|
|
165
218
|
await pruneWorktreesBestEffort(git, repoRoot, warnings);
|
|
166
|
-
|
|
167
|
-
|
|
219
|
+
let stillRegistered = true;
|
|
220
|
+
try {
|
|
221
|
+
stillRegistered = await git.isWorktreeRegistered(repoRoot, worktreePath);
|
|
222
|
+
}
|
|
223
|
+
catch (inspectionError) {
|
|
224
|
+
warnings.push(`Unable to verify task worktree registration after forced removal: ${describeError(inspectionError)}`);
|
|
225
|
+
}
|
|
226
|
+
if (stillRegistered) {
|
|
227
|
+
warnings.push(`Unable to force-remove Git worktree ${worktreePath}: ${describeError(error)}`);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
warnings.push(`Git worktree metadata was already cleared for ${worktreePath}; continuing cleanup.`);
|
|
168
231
|
}
|
|
169
|
-
warnings.push(`Git worktree metadata was already cleared for ${worktreePath}; continuing cleanup.`);
|
|
170
232
|
}
|
|
171
233
|
}
|
|
172
234
|
else {
|
|
173
235
|
await pruneWorktreesBestEffort(git, repoRoot, warnings);
|
|
174
236
|
}
|
|
175
|
-
|
|
237
|
+
let staleDirectoryExists = true;
|
|
238
|
+
try {
|
|
239
|
+
staleDirectoryExists = await fs.pathExists(worktreePath);
|
|
240
|
+
}
|
|
241
|
+
catch (error) {
|
|
242
|
+
warnings.push(`Unable to inspect stale task worktree directory ${worktreePath}: ${describeError(error)}`);
|
|
243
|
+
}
|
|
244
|
+
if (staleDirectoryExists) {
|
|
176
245
|
try {
|
|
177
246
|
await fs.removePath?.(worktreePath, { recursive: true, force: true });
|
|
178
247
|
}
|
|
@@ -180,19 +249,67 @@ async function removeTaskWorktreeIdempotent(fs, git, repoRoot, worktreePath, for
|
|
|
180
249
|
warnings.push(`Unable to remove stale task worktree directory ${worktreePath}: ${describeError(error)}`);
|
|
181
250
|
}
|
|
182
251
|
}
|
|
252
|
+
let pathExists = true;
|
|
253
|
+
let registered = true;
|
|
254
|
+
try {
|
|
255
|
+
pathExists = await fs.pathExists(worktreePath);
|
|
256
|
+
}
|
|
257
|
+
catch (error) {
|
|
258
|
+
warnings.push(`Unable to verify task worktree directory cleanup: ${describeError(error)}`);
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
registered = await git.isWorktreeRegistered(repoRoot, worktreePath);
|
|
262
|
+
}
|
|
263
|
+
catch (error) {
|
|
264
|
+
warnings.push(`Unable to verify task worktree metadata cleanup: ${describeError(error)}`);
|
|
265
|
+
}
|
|
266
|
+
return !pathExists && !registered;
|
|
183
267
|
}
|
|
184
|
-
async function
|
|
185
|
-
|
|
186
|
-
|
|
268
|
+
async function deleteTaskBranchBestEffort(git, repoRoot, branch, warnings) {
|
|
269
|
+
let branchExists;
|
|
270
|
+
try {
|
|
271
|
+
branchExists = await git.branchExists(repoRoot, branch);
|
|
272
|
+
}
|
|
273
|
+
catch (error) {
|
|
274
|
+
warnings.push(`Unable to inspect task branch ${branch}: ${describeError(error)}`);
|
|
187
275
|
}
|
|
276
|
+
if (branchExists === false) {
|
|
277
|
+
return { resolved: true, deleted: false };
|
|
278
|
+
}
|
|
279
|
+
await warnAboutDiscardedCommits(git, repoRoot, branch, warnings);
|
|
188
280
|
try {
|
|
189
|
-
await git.deleteBranch(repoRoot, branch, { force });
|
|
281
|
+
await git.deleteBranch(repoRoot, branch, { force: true });
|
|
282
|
+
return { resolved: true, deleted: true };
|
|
190
283
|
}
|
|
191
284
|
catch (error) {
|
|
192
|
-
|
|
285
|
+
try {
|
|
286
|
+
if (!(await git.branchExists(repoRoot, branch))) {
|
|
287
|
+
return { resolved: true, deleted: true };
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
catch (inspectionError) {
|
|
291
|
+
warnings.push(`Unable to verify task branch cleanup for ${branch}: ${describeError(inspectionError)}`);
|
|
292
|
+
}
|
|
293
|
+
warnings.push(`Unable to force-delete task branch ${branch}: ${describeError(error)}`);
|
|
294
|
+
return { resolved: false, deleted: false };
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
async function warnAboutDiscardedCommits(git, repoRoot, branch, warnings) {
|
|
298
|
+
try {
|
|
299
|
+
const baseBranch = await git.getCurrentBranch(repoRoot);
|
|
300
|
+
const commits = await git.getCommitList(repoRoot, `${baseBranch}..${branch}`);
|
|
301
|
+
if (commits.length === 0) {
|
|
193
302
|
return;
|
|
194
303
|
}
|
|
195
|
-
|
|
304
|
+
const summary = commits
|
|
305
|
+
.slice(0, 5)
|
|
306
|
+
.map((commit) => `${commit.sha.slice(0, 8)} ${commit.subject}`)
|
|
307
|
+
.join("; ");
|
|
308
|
+
const remainder = commits.length > 5 ? `; and ${commits.length - 5} more` : "";
|
|
309
|
+
warnings.push(`${branch} has ${commits.length} commit(s) not contained in ${baseBranch}; close force-deletes the branch: ${summary}${remainder}`);
|
|
310
|
+
}
|
|
311
|
+
catch (error) {
|
|
312
|
+
warnings.push(`Unable to inspect commits before force-deleting ${branch}: ${describeError(error)}`);
|
|
196
313
|
}
|
|
197
314
|
}
|
|
198
315
|
async function pruneWorktreesBestEffort(git, repoRoot, warnings) {
|