vibe-coding-master 0.6.23 → 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/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 +35 -16
- 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 +1 -1
- package/dist/backend/services/runtime-coordinator-service.js +35 -0
- 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-frontend/assets/{index-9V9COJZy.js → index-e8Tqa8Qh.js} +26 -25
- package/dist-frontend/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { DISPATCHABLE_ROLES
|
|
1
|
+
import { DISPATCHABLE_ROLES } from "../../shared/constants.js";
|
|
2
2
|
import { isOpenFileLimitError, VcmError } from "../errors.js";
|
|
3
3
|
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
4
4
|
export function registerTaskRoutes(app, deps) {
|
|
@@ -92,39 +92,9 @@ export function registerTaskRoutes(app, deps) {
|
|
|
92
92
|
});
|
|
93
93
|
app.post("/api/tasks/:taskSlug/cleanup", async (request) => {
|
|
94
94
|
const project = await requireCurrentProject(deps.projectService);
|
|
95
|
-
|
|
96
|
-
await stopRunningRoleSessions(deps, project.repoRoot, request.params.taskSlug);
|
|
97
|
-
await moveProjectToolSessionsToSafeCwd(deps, project.repoRoot);
|
|
98
|
-
await deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), request.params.taskSlug, { clearCache: true });
|
|
99
|
-
deps.roundService.stopTask(request.params.taskSlug);
|
|
100
|
-
return deps.taskService.cleanupTask(project.repoRoot, request.params.taskSlug, request.body ?? {});
|
|
95
|
+
return deps.taskCloseService.closeTask(project.repoRoot, request.params.taskSlug);
|
|
101
96
|
});
|
|
102
97
|
}
|
|
103
|
-
async function stopRunningRoleSessions(deps, repoRoot, taskSlug) {
|
|
104
|
-
const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|
|
105
|
-
for (const session of sessions) {
|
|
106
|
-
if (session.status === "running" && VCM_ROLE_NAMES.some((role) => role === session.role)) {
|
|
107
|
-
await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
}
|
|
111
|
-
async function moveProjectToolSessionsToSafeCwd(deps, repoRoot) {
|
|
112
|
-
await Promise.all([
|
|
113
|
-
ignoreMissingSession(deps.sessionService.moveProjectTranslatorSessionToSafeCwd(repoRoot)),
|
|
114
|
-
ignoreMissingSession(deps.sessionService.moveProjectHarnessEngineerSessionToSafeCwd(repoRoot))
|
|
115
|
-
]);
|
|
116
|
-
}
|
|
117
|
-
async function ignoreMissingSession(operation) {
|
|
118
|
-
try {
|
|
119
|
-
await operation;
|
|
120
|
-
}
|
|
121
|
-
catch (error) {
|
|
122
|
-
if (error instanceof VcmError && error.code === "SESSION_MISSING") {
|
|
123
|
-
return;
|
|
124
|
-
}
|
|
125
|
-
throw error;
|
|
126
|
-
}
|
|
127
|
-
}
|
|
128
98
|
async function requireCurrentProject(projectService) {
|
|
129
99
|
const project = await projectService.getCurrentProject();
|
|
130
100
|
if (!project) {
|
|
@@ -36,7 +36,7 @@ const LEGACY_CODEX_HARNESS_PATHS = [
|
|
|
36
36
|
".ai/tools/request-codex-review"
|
|
37
37
|
];
|
|
38
38
|
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'`;
|
|
39
|
-
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
|
|
39
|
+
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'`;
|
|
40
40
|
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'`;
|
|
41
41
|
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'`;
|
|
42
42
|
const VCM_BASH_DEFAULT_TIMEOUT_MS = "600000";
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import { VCM_ROLE_NAMES } from "../../shared/constants.js";
|
|
2
1
|
import { VcmError } from "../errors.js";
|
|
3
2
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
4
3
|
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
@@ -605,15 +604,7 @@ export function createGatewayService(deps) {
|
|
|
605
604
|
hint: settings.currentTaskSlug ? `Use /close-task confirm ${settings.currentTaskSlug}` : undefined
|
|
606
605
|
});
|
|
607
606
|
}
|
|
608
|
-
const
|
|
609
|
-
await stopRunningRoleSessions(project.repoRoot, taskSlug);
|
|
610
|
-
await moveProjectToolSessionsToSafeCwd(project.repoRoot);
|
|
611
|
-
await deps.translationService.stopTask(getTaskRuntimeRepoRoot(task), taskSlug, { clearCache: true });
|
|
612
|
-
deps.roundService.stopTask(taskSlug);
|
|
613
|
-
const result = await deps.taskService.cleanupTask(project.repoRoot, taskSlug, {
|
|
614
|
-
force: true,
|
|
615
|
-
forceDeleteBranch: true
|
|
616
|
-
});
|
|
607
|
+
const result = await deps.taskCloseService.closeTask(project.repoRoot, taskSlug);
|
|
617
608
|
clearFailedTranslation(project.repoRoot, taskSlug);
|
|
618
609
|
const latestPmReplies = { ...settings.latestPmReplies };
|
|
619
610
|
delete latestPmReplies[latestPmReplyKey(project.repoRoot, taskSlug)];
|
|
@@ -629,8 +620,9 @@ export function createGatewayService(deps) {
|
|
|
629
620
|
});
|
|
630
621
|
const lines = [
|
|
631
622
|
`Closed task: ${result.taskSlug}`,
|
|
632
|
-
|
|
633
|
-
|
|
623
|
+
`worktree removed: ${result.worktreeRemoved ? "yes" : "no"}`,
|
|
624
|
+
`branch deleted: ${result.branchDeleted ? "yes" : "no"}`,
|
|
625
|
+
`task state removed: ${result.stateRemoved ? "yes" : "no"}`,
|
|
634
626
|
`removed state paths: ${result.removedStatePaths.length}`
|
|
635
627
|
];
|
|
636
628
|
if (result.warnings?.length) {
|
|
@@ -638,31 +630,6 @@ export function createGatewayService(deps) {
|
|
|
638
630
|
}
|
|
639
631
|
return lines.join("\n");
|
|
640
632
|
}
|
|
641
|
-
async function stopRunningRoleSessions(repoRoot, taskSlug) {
|
|
642
|
-
const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|
|
643
|
-
for (const session of sessions) {
|
|
644
|
-
if (session.status === "running" && VCM_ROLE_NAMES.some((role) => role === session.role)) {
|
|
645
|
-
await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
|
|
646
|
-
}
|
|
647
|
-
}
|
|
648
|
-
}
|
|
649
|
-
async function moveProjectToolSessionsToSafeCwd(repoRoot) {
|
|
650
|
-
await Promise.all([
|
|
651
|
-
ignoreMissingSession(deps.sessionService.moveProjectTranslatorSessionToSafeCwd(repoRoot)),
|
|
652
|
-
ignoreMissingSession(deps.sessionService.moveProjectHarnessEngineerSessionToSafeCwd(repoRoot))
|
|
653
|
-
]);
|
|
654
|
-
}
|
|
655
|
-
async function ignoreMissingSession(operation) {
|
|
656
|
-
try {
|
|
657
|
-
await operation;
|
|
658
|
-
}
|
|
659
|
-
catch (error) {
|
|
660
|
-
if (error instanceof VcmError && error.code === "SESSION_MISSING") {
|
|
661
|
-
return;
|
|
662
|
-
}
|
|
663
|
-
throw error;
|
|
664
|
-
}
|
|
665
|
-
}
|
|
666
633
|
async function setGatewayTranslation(enabled) {
|
|
667
634
|
const settings = await deps.settings.updateSettings({ translationEnabled: enabled });
|
|
668
635
|
return `Gateway translation ${settings.translationEnabled ? "on" : "off"}.`;
|
package/dist/backend/server.js
CHANGED
|
@@ -35,9 +35,11 @@ import { createRuntimeCoordinatorService } from "./services/runtime-coordinator-
|
|
|
35
35
|
import { createRuntimeRecoveryService } from "./services/runtime-recovery-service.js";
|
|
36
36
|
import { createStatusService } from "./services/status-service.js";
|
|
37
37
|
import { createTaskService } from "./services/task-service.js";
|
|
38
|
+
import { createTaskCloseService } from "./services/task-close-service.js";
|
|
38
39
|
import { createTaskLaunchService } from "./services/task-launch-service.js";
|
|
39
40
|
import { createTerminalInterruptService } from "./services/terminal-interrupt-service.js";
|
|
40
41
|
import { createTranslationService } from "./services/translation-service.js";
|
|
42
|
+
import { createTurnReconcilerService } from "./services/turn-reconciler-service.js";
|
|
41
43
|
import { createDiagnosticsService } from "./services/diagnostics-service.js";
|
|
42
44
|
import { registerAppSettingsRoutes } from "./api/app-settings-routes.js";
|
|
43
45
|
import { registerArtifactRoutes } from "./api/artifact-routes.js";
|
|
@@ -112,11 +114,10 @@ export async function createServer(deps, options = {}) {
|
|
|
112
114
|
registerTaskRoutes(app, {
|
|
113
115
|
projectService: deps.projectService,
|
|
114
116
|
taskService: deps.taskService,
|
|
115
|
-
|
|
117
|
+
taskCloseService: deps.taskCloseService,
|
|
116
118
|
statusService: deps.statusService,
|
|
117
119
|
messageService: deps.messageService,
|
|
118
120
|
taskLaunchService: deps.taskLaunchService,
|
|
119
|
-
translationService: deps.translationService,
|
|
120
121
|
roundService: deps.roundService
|
|
121
122
|
});
|
|
122
123
|
registerSessionRoutes(app, {
|
|
@@ -154,9 +155,11 @@ export async function createServer(deps, options = {}) {
|
|
|
154
155
|
});
|
|
155
156
|
app.addHook("onReady", async () => {
|
|
156
157
|
await cleanupRecentTranslationRuntime(deps);
|
|
158
|
+
deps.runtimeCoordinator.start();
|
|
157
159
|
await deps.gatewayService.start();
|
|
158
160
|
});
|
|
159
161
|
app.addHook("onClose", async () => {
|
|
162
|
+
deps.runtimeCoordinator.stop();
|
|
160
163
|
await deps.gatewayService.stop();
|
|
161
164
|
});
|
|
162
165
|
if (options.staticDir) {
|
|
@@ -306,6 +309,12 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
306
309
|
fs,
|
|
307
310
|
auditPath: gatewaySettings.getAuditPath()
|
|
308
311
|
});
|
|
312
|
+
const taskCloseService = createTaskCloseService({
|
|
313
|
+
taskService,
|
|
314
|
+
sessionService,
|
|
315
|
+
translationService,
|
|
316
|
+
roundService
|
|
317
|
+
});
|
|
309
318
|
const gatewayService = createGatewayService({
|
|
310
319
|
fs,
|
|
311
320
|
settings: gatewaySettings,
|
|
@@ -313,6 +322,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
313
322
|
channels: gatewayChannels,
|
|
314
323
|
projectService,
|
|
315
324
|
taskService,
|
|
325
|
+
taskCloseService,
|
|
316
326
|
sessionService,
|
|
317
327
|
taskLaunchService,
|
|
318
328
|
translationService,
|
|
@@ -320,20 +330,6 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
320
330
|
runtime,
|
|
321
331
|
appSettings
|
|
322
332
|
});
|
|
323
|
-
const runtimeCoordinator = createRuntimeCoordinatorService({
|
|
324
|
-
appSettings,
|
|
325
|
-
taskService,
|
|
326
|
-
sessionService,
|
|
327
|
-
translationService,
|
|
328
|
-
harnessService,
|
|
329
|
-
harnessFeedbackService,
|
|
330
|
-
autoMemoryService,
|
|
331
|
-
roundService,
|
|
332
|
-
gatewayService,
|
|
333
|
-
async getStateRoot(repoRoot) {
|
|
334
|
-
return (await projectService.loadConfig(repoRoot)).stateRoot;
|
|
335
|
-
}
|
|
336
|
-
});
|
|
337
333
|
const runtimeRecoveryService = createRuntimeRecoveryService({
|
|
338
334
|
fs,
|
|
339
335
|
runtime,
|
|
@@ -357,6 +353,28 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
357
353
|
jobGuard: createJobGuardService(),
|
|
358
354
|
translationWorkerService
|
|
359
355
|
});
|
|
356
|
+
const turnReconciler = createTurnReconcilerService({
|
|
357
|
+
sessionService,
|
|
358
|
+
roundService,
|
|
359
|
+
claudeHookService,
|
|
360
|
+
runtime
|
|
361
|
+
});
|
|
362
|
+
const runtimeCoordinator = createRuntimeCoordinatorService({
|
|
363
|
+
appSettings,
|
|
364
|
+
projectService,
|
|
365
|
+
taskService,
|
|
366
|
+
sessionService,
|
|
367
|
+
translationService,
|
|
368
|
+
harnessService,
|
|
369
|
+
harnessFeedbackService,
|
|
370
|
+
autoMemoryService,
|
|
371
|
+
roundService,
|
|
372
|
+
gatewayService,
|
|
373
|
+
turnReconciler,
|
|
374
|
+
async getStateRoot(repoRoot) {
|
|
375
|
+
return (await projectService.loadConfig(repoRoot)).stateRoot;
|
|
376
|
+
}
|
|
377
|
+
});
|
|
360
378
|
const terminalInterruptService = createTerminalInterruptService({
|
|
361
379
|
runtime,
|
|
362
380
|
projectService,
|
|
@@ -374,6 +392,7 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
374
392
|
appSettings,
|
|
375
393
|
projectService,
|
|
376
394
|
taskService,
|
|
395
|
+
taskCloseService,
|
|
377
396
|
sessionService,
|
|
378
397
|
artifactService,
|
|
379
398
|
harnessService,
|
|
@@ -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
|
+
}
|
|
@@ -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);
|
|
@@ -66,6 +88,19 @@ export function createRuntimeCoordinatorService(deps) {
|
|
|
66
88
|
});
|
|
67
89
|
}
|
|
68
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
|
+
}
|
|
69
104
|
async function resolveActiveTask(repoRoot, requestedTaskSlug) {
|
|
70
105
|
const tasks = await deps.taskService.listTasks(repoRoot);
|
|
71
106
|
const activeTasks = tasks.filter((task) => task.cleanupStatus !== "cleaned");
|
|
@@ -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
|
+
}
|