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.
@@ -1,4 +1,4 @@
1
- import { DISPATCHABLE_ROLES, VCM_ROLE_NAMES } from "../../shared/constants.js";
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
- const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
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 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || 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 --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 task = await deps.taskService.loadTask(project.repoRoot, taskSlug);
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
- result.removedWorktreePath ? `removed worktree: ${result.removedWorktreePath}` : "removed worktree: none",
633
- result.deletedBranch ? `deleted branch: ${result.deletedBranch}` : "deleted branch: none",
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"}.`;
@@ -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
- sessionService: deps.sessionService,
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 5 -X POST "\${VCM_API_URL}/api/hooks/claude-code/stop" -H "content-type: application/json" --data-binary @- || 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 --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
+ }