vibe-coding-master 0.6.22 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +37 -3
- package/dist/backend/adapters/filesystem.js +8 -0
- package/dist/backend/api/harness-routes.js +54 -0
- package/dist/backend/api/runtime-state-routes.js +6 -2
- package/dist/backend/api/task-routes.js +2 -32
- package/dist/backend/cli/install-vcm-harness.js +1 -1
- package/dist/backend/gateway/gateway-service.js +4 -37
- package/dist/backend/server.js +50 -15
- package/dist/backend/services/app-settings-service.js +1 -0
- package/dist/backend/services/auto-memory-service.js +760 -0
- package/dist/backend/services/claude-hook-service.js +108 -2
- package/dist/backend/services/claude-transcript-reply.js +81 -1
- package/dist/backend/services/harness-service.js +1 -1
- package/dist/backend/services/runtime-coordinator-service.js +69 -1
- package/dist/backend/services/runtime-recovery-service.js +6 -0
- package/dist/backend/services/session-service.js +3 -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/architect-agent.js +3 -0
- package/dist/backend/templates/harness/claude-root.js +3 -1
- package/dist/backend/templates/harness/coder-agent.js +3 -0
- package/dist/backend/templates/harness/gate-review.js +3 -0
- package/dist/backend/templates/harness/harness-engineer-agent.js +32 -8
- package/dist/backend/templates/harness/project-manager-agent.js +3 -0
- package/dist/backend/templates/harness/role-memory.js +9 -0
- package/dist/backend/templates/harness/tester-agent.js +3 -0
- package/dist/shared/types/memory.js +8 -0
- package/dist-frontend/assets/{index-DmSHDyiQ.css → index-C2QzumXk.css} +1 -1
- package/dist-frontend/assets/index-e8Tqa8Qh.js +97 -0
- package/dist-frontend/index.html +2 -2
- package/package.json +1 -1
- package/dist-frontend/assets/index-DYBg_qYS.js +0 -96
|
@@ -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) {
|
|
@@ -97,6 +99,28 @@ export function createClaudeHookService(deps) {
|
|
|
97
99
|
transcriptPath: stringOrUndefined(input.event.transcript_path),
|
|
98
100
|
cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
|
|
99
101
|
});
|
|
102
|
+
const activeTask = deps.autoMemoryService
|
|
103
|
+
? (await deps.taskService.listTasks(context.project.repoRoot))
|
|
104
|
+
.find((task) => task.cleanupStatus !== "cleaned")
|
|
105
|
+
: undefined;
|
|
106
|
+
const memoryHandled = activeTask
|
|
107
|
+
? await deps.autoMemoryService?.handleHarnessEngineerHook({
|
|
108
|
+
baseRepoRoot: context.project.repoRoot,
|
|
109
|
+
taskRepoRoot: getTaskRuntimeRepoRoot(activeTask),
|
|
110
|
+
taskSlug: activeTask.taskSlug,
|
|
111
|
+
eventName
|
|
112
|
+
})
|
|
113
|
+
: false;
|
|
114
|
+
if (memoryHandled) {
|
|
115
|
+
return {
|
|
116
|
+
ok: true,
|
|
117
|
+
eventName,
|
|
118
|
+
taskSlug: activeTask?.taskSlug ?? input.taskSlug,
|
|
119
|
+
role: input.role,
|
|
120
|
+
sessionUpdated: Boolean(session),
|
|
121
|
+
dispatchedCount: 0
|
|
122
|
+
};
|
|
123
|
+
}
|
|
100
124
|
await deps.harnessService?.recordHarnessBootstrapHook(context.project.repoRoot, {
|
|
101
125
|
eventName,
|
|
102
126
|
sessionId: session?.id,
|
|
@@ -131,6 +155,10 @@ export function createClaudeHookService(deps) {
|
|
|
131
155
|
throwUnsupportedEvent(eventName);
|
|
132
156
|
}
|
|
133
157
|
const context = await getHookContext(input);
|
|
158
|
+
const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
|
|
159
|
+
if (memoryResult) {
|
|
160
|
+
return memoryResult;
|
|
161
|
+
}
|
|
134
162
|
const boundToTask = await isHookSessionBoundToTask(context, input.role);
|
|
135
163
|
if (boundToTask) {
|
|
136
164
|
deps.jobGuard?.notePromptSubmitted({
|
|
@@ -194,6 +222,13 @@ export function createClaudeHookService(deps) {
|
|
|
194
222
|
throwUnsupportedEvent(eventName);
|
|
195
223
|
}
|
|
196
224
|
const context = await getHookContext(input);
|
|
225
|
+
if (await isDuplicateCompletedStop(context, input)) {
|
|
226
|
+
return completedHookResult(input, eventName);
|
|
227
|
+
}
|
|
228
|
+
const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
|
|
229
|
+
if (memoryResult) {
|
|
230
|
+
return memoryResult;
|
|
231
|
+
}
|
|
197
232
|
await clearStopFailureRecoveryState(context, input.role);
|
|
198
233
|
if (options.allowBlock && deps.jobGuard) {
|
|
199
234
|
const verdict = await deps.jobGuard.evaluateStop({
|
|
@@ -222,12 +257,41 @@ export function createClaudeHookService(deps) {
|
|
|
222
257
|
settleGuard: true
|
|
223
258
|
});
|
|
224
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
|
+
}
|
|
225
285
|
async function processStopFailureHook(input) {
|
|
226
286
|
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
227
287
|
if (eventName !== "StopFailure") {
|
|
228
288
|
throwUnsupportedEvent(eventName);
|
|
229
289
|
}
|
|
230
290
|
const context = await getHookContext(input);
|
|
291
|
+
const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
|
|
292
|
+
if (memoryResult) {
|
|
293
|
+
return memoryResult;
|
|
294
|
+
}
|
|
231
295
|
const routeDispatchInput = createRouteDispatchInput(input, context);
|
|
232
296
|
const pending = await deps.messageService.listPendingRouteFiles(routeDispatchInput);
|
|
233
297
|
const hasCompletionEvidence = pending.some((routeFile) => routeFile.fromRole === input.role);
|
|
@@ -284,6 +348,10 @@ export function createClaudeHookService(deps) {
|
|
|
284
348
|
throwUnsupportedEvent(eventName);
|
|
285
349
|
}
|
|
286
350
|
const context = await getHookContext(input);
|
|
351
|
+
const memoryResult = await processAutoMemoryRoleHook(input, context, eventName);
|
|
352
|
+
if (memoryResult) {
|
|
353
|
+
return memoryResult;
|
|
354
|
+
}
|
|
287
355
|
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
288
356
|
taskSlug: context.taskSlug,
|
|
289
357
|
role: input.role,
|
|
@@ -380,6 +448,34 @@ export function createClaudeHookService(deps) {
|
|
|
380
448
|
dispatchedCount: dispatched.filter((result) => result.delivered).length
|
|
381
449
|
};
|
|
382
450
|
}
|
|
451
|
+
async function processAutoMemoryRoleHook(input, context, eventName) {
|
|
452
|
+
if (!deps.autoMemoryService || !(await deps.autoMemoryService.isRoleMemoryTurn(context.taskRepoRoot, input.role))) {
|
|
453
|
+
return undefined;
|
|
454
|
+
}
|
|
455
|
+
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
456
|
+
taskSlug: context.taskSlug,
|
|
457
|
+
role: input.role,
|
|
458
|
+
eventName,
|
|
459
|
+
claudeSessionId: stringOrUndefined(input.event.session_id),
|
|
460
|
+
transcriptPath: stringOrUndefined(input.event.transcript_path),
|
|
461
|
+
cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
|
|
462
|
+
});
|
|
463
|
+
await deps.autoMemoryService.handleRoleHook({
|
|
464
|
+
baseRepoRoot: context.project.repoRoot,
|
|
465
|
+
taskRepoRoot: context.taskRepoRoot,
|
|
466
|
+
taskSlug: context.taskSlug,
|
|
467
|
+
role: input.role,
|
|
468
|
+
eventName
|
|
469
|
+
});
|
|
470
|
+
return {
|
|
471
|
+
ok: true,
|
|
472
|
+
eventName,
|
|
473
|
+
taskSlug: context.taskSlug,
|
|
474
|
+
role: input.role,
|
|
475
|
+
sessionUpdated: Boolean(session),
|
|
476
|
+
dispatchedCount: 0
|
|
477
|
+
};
|
|
478
|
+
}
|
|
383
479
|
function createRouteDispatchInput(input, context, stoppedRole) {
|
|
384
480
|
return {
|
|
385
481
|
repoRoot: context.project.repoRoot,
|
|
@@ -633,6 +729,16 @@ export function createClaudeHookService(deps) {
|
|
|
633
729
|
}
|
|
634
730
|
return processStopHook(input, { allowBlock: true });
|
|
635
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
|
+
},
|
|
636
742
|
handlePermissionRequestHook
|
|
637
743
|
};
|
|
638
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
|
+
}
|
|
@@ -37,7 +37,7 @@ const LEGACY_CODEX_HARNESS_PATHS = [
|
|
|
37
37
|
".ai/tools/request-codex-review"
|
|
38
38
|
];
|
|
39
39
|
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
|
|
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 --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
41
|
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
42
|
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
43
|
const VCM_BASH_DEFAULT_TIMEOUT_MS = "600000";
|
|
@@ -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,13 +77,30 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
55
77
|
else {
|
|
56
78
|
await deps.translationService.stopTask(taskRepoRoot, activeTask.taskSlug).catch(() => undefined);
|
|
57
79
|
}
|
|
80
|
+
await reconcileAutoMemory(repoRoot, activeTask);
|
|
58
81
|
if (preferences.autoTaskHarnessReviewEnabled) {
|
|
59
|
-
await
|
|
82
|
+
const memoryReadiness = await getTaskRetrospectiveMemoryReadiness(repoRoot, activeTask);
|
|
83
|
+
if (memoryReadiness.ready) {
|
|
84
|
+
await maybeStartTaskHarnessRetrospective(repoRoot, activeTask);
|
|
85
|
+
}
|
|
60
86
|
}
|
|
61
87
|
return { activeTask, gatewayStatus };
|
|
62
88
|
});
|
|
63
89
|
}
|
|
64
90
|
};
|
|
91
|
+
async function reconcileCurrentProject() {
|
|
92
|
+
const project = await deps.projectService.getCurrentProject();
|
|
93
|
+
if (!project) {
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
await withRepoLock(project.repoRoot, async () => {
|
|
97
|
+
const activeTask = await resolveActiveTask(project.repoRoot);
|
|
98
|
+
if (activeTask) {
|
|
99
|
+
await deps.turnReconciler.reconcileTask(project.repoRoot, activeTask, await deps.getStateRoot(project.repoRoot));
|
|
100
|
+
}
|
|
101
|
+
return { activeTask, gatewayStatus: null };
|
|
102
|
+
});
|
|
103
|
+
}
|
|
65
104
|
async function resolveActiveTask(repoRoot, requestedTaskSlug) {
|
|
66
105
|
const tasks = await deps.taskService.listTasks(repoRoot);
|
|
67
106
|
const activeTasks = tasks.filter((task) => task.cleanupStatus !== "cleaned");
|
|
@@ -159,4 +198,33 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
159
198
|
throw error;
|
|
160
199
|
}
|
|
161
200
|
}
|
|
201
|
+
async function reconcileAutoMemory(repoRoot, task) {
|
|
202
|
+
const stateRoot = await deps.getStateRoot(repoRoot);
|
|
203
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
204
|
+
const roundState = await deps.roundService.getSessionRoundState({
|
|
205
|
+
repoRoot,
|
|
206
|
+
stateRepoRoot: taskRepoRoot,
|
|
207
|
+
stateRoot,
|
|
208
|
+
taskSlug: task.taskSlug
|
|
209
|
+
});
|
|
210
|
+
return deps.autoMemoryService.reconcileTask({
|
|
211
|
+
baseRepoRoot: repoRoot,
|
|
212
|
+
taskRepoRoot,
|
|
213
|
+
taskSlug: task.taskSlug,
|
|
214
|
+
handoffDir: task.handoffDir,
|
|
215
|
+
roundReady: roundState.status === "stopped"
|
|
216
|
+
&& Boolean(roundState.roundId)
|
|
217
|
+
&& roundState.roleRecovery?.status !== "failed"
|
|
218
|
+
});
|
|
219
|
+
}
|
|
220
|
+
async function getTaskRetrospectiveMemoryReadiness(repoRoot, task) {
|
|
221
|
+
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
222
|
+
return deps.autoMemoryService.getTaskRetrospectiveReadiness({
|
|
223
|
+
baseRepoRoot: repoRoot,
|
|
224
|
+
taskRepoRoot,
|
|
225
|
+
taskSlug: task.taskSlug,
|
|
226
|
+
handoffDir: task.handoffDir,
|
|
227
|
+
roundReady: true
|
|
228
|
+
});
|
|
229
|
+
}
|
|
162
230
|
}
|
|
@@ -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 () => {
|
|
@@ -3,6 +3,7 @@ import { VCM_ROLE_NAMES, isDispatchableRole } from "../../shared/constants.js";
|
|
|
3
3
|
import { VcmError } from "../errors.js";
|
|
4
4
|
import { resolveRepoPath } from "../adapters/filesystem.js";
|
|
5
5
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
6
|
+
import { ensureTaskMemorySnapshot } from "./auto-memory-service.js";
|
|
6
7
|
import { claudeTranscriptPath } from "./claude-transcript-service.js";
|
|
7
8
|
import { readHarnessRevisionState } from "./harness-revision.js";
|
|
8
9
|
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
@@ -49,6 +50,7 @@ export function createSessionService(deps) {
|
|
|
49
50
|
const config = await deps.projectService.loadConfig(repoRoot);
|
|
50
51
|
const task = await deps.taskService.loadTask(repoRoot, taskSlug);
|
|
51
52
|
const taskRepoRoot = getTaskRuntimeRepoRoot(task);
|
|
53
|
+
await ensureTaskMemorySnapshot(deps.fs, repoRoot, taskRepoRoot);
|
|
52
54
|
const paths = deps.artifactService.getHandoffPaths(taskRepoRoot, task.handoffDir);
|
|
53
55
|
const persisted = await loadPersistedRoleRecordForRole(deps.fs, repoRoot, taskRepoRoot, config.stateRoot, taskSlug, role);
|
|
54
56
|
const permissionMode = normalizeClaudePermissionMode(input.permissionMode ?? persisted?.permissionMode);
|
|
@@ -235,6 +237,7 @@ export function createSessionService(deps) {
|
|
|
235
237
|
}
|
|
236
238
|
async function launchProjectHarnessEngineerSession(repoRoot, input, launchMode) {
|
|
237
239
|
const taskContext = await resolveProjectToolTaskContext(repoRoot, input, "Harness Engineer");
|
|
240
|
+
await ensureTaskMemorySnapshot(deps.fs, repoRoot, taskContext.taskRepoRoot);
|
|
238
241
|
const live = toRoleSessionRecordView(getRegisteredProjectHarnessEngineerSession(deps.registry, deps.runtime), deps.runtime);
|
|
239
242
|
if (live && live.status === "running") {
|
|
240
243
|
return withHarnessRevisionView(repoRoot, await migrateRunningProjectToolSessionCwd(repoRoot, live, taskContext.taskRepoRoot));
|
|
@@ -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
|
+
}
|