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.
Files changed (33) hide show
  1. package/README.md +37 -3
  2. package/dist/backend/adapters/filesystem.js +8 -0
  3. package/dist/backend/api/harness-routes.js +54 -0
  4. package/dist/backend/api/runtime-state-routes.js +6 -2
  5. package/dist/backend/api/task-routes.js +2 -32
  6. package/dist/backend/cli/install-vcm-harness.js +1 -1
  7. package/dist/backend/gateway/gateway-service.js +4 -37
  8. package/dist/backend/server.js +50 -15
  9. package/dist/backend/services/app-settings-service.js +1 -0
  10. package/dist/backend/services/auto-memory-service.js +760 -0
  11. package/dist/backend/services/claude-hook-service.js +108 -2
  12. package/dist/backend/services/claude-transcript-reply.js +81 -1
  13. package/dist/backend/services/harness-service.js +1 -1
  14. package/dist/backend/services/runtime-coordinator-service.js +69 -1
  15. package/dist/backend/services/runtime-recovery-service.js +6 -0
  16. package/dist/backend/services/session-service.js +3 -0
  17. package/dist/backend/services/task-close-service.js +88 -0
  18. package/dist/backend/services/task-service.js +152 -35
  19. package/dist/backend/services/turn-reconciler-service.js +122 -0
  20. package/dist/backend/templates/harness/architect-agent.js +3 -0
  21. package/dist/backend/templates/harness/claude-root.js +3 -1
  22. package/dist/backend/templates/harness/coder-agent.js +3 -0
  23. package/dist/backend/templates/harness/gate-review.js +3 -0
  24. package/dist/backend/templates/harness/harness-engineer-agent.js +32 -8
  25. package/dist/backend/templates/harness/project-manager-agent.js +3 -0
  26. package/dist/backend/templates/harness/role-memory.js +9 -0
  27. package/dist/backend/templates/harness/tester-agent.js +3 -0
  28. package/dist/shared/types/memory.js +8 -0
  29. package/dist-frontend/assets/{index-DmSHDyiQ.css → index-C2QzumXk.css} +1 -1
  30. package/dist-frontend/assets/index-e8Tqa8Qh.js +97 -0
  31. package/dist-frontend/index.html +2 -2
  32. package/package.json +1 -1
  33. 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 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);
@@ -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 maybeStartTaskHarnessRetrospective(repoRoot, activeTask);
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
+ }