vibe-coding-master 0.5.6 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -23,4 +23,9 @@ export function registerGatewayRoutes(app, deps) {
23
23
  app.post("/api/gateway/binding/reset", async () => {
24
24
  return deps.gatewayService.resetBinding();
25
25
  });
26
+ // Arm/disarm the runtime channel-connection switch (process-local, not
27
+ // persisted). Returns the updated GatewayStatus.
28
+ app.put("/api/gateway/connection", async (request) => {
29
+ return deps.gatewayService.setConnectionEnabled(request.body.enabled);
30
+ });
26
31
  }
@@ -5,7 +5,7 @@ export function registerProjectRoutes(app, deps) {
5
5
  });
6
6
  app.post("/api/projects/connect", async (request) => {
7
7
  const project = await deps.projectService.connectProject(request.body);
8
- await deps.translationWorkerService?.cleanupStartupRuntime(project.repoRoot);
8
+ await deps.runtimeRecoveryService?.recoverProject(project.repoRoot);
9
9
  return project;
10
10
  });
11
11
  app.get("/api/projects/current", async () => {
@@ -30,10 +30,20 @@ export function createGatewayService(deps) {
30
30
  let qrLogin = null;
31
31
  let larkRegistrationState = null;
32
32
  let lastFailedTranslation = null;
33
+ // Runtime channel-connection arming switch. Not persisted: every process starts
34
+ // disarmed. `ensurePolling` is the single chokepoint that reads it, so no
35
+ // self-heal path can connect the channel while this is false.
36
+ let connectionEnabled = false;
33
37
  function isRunning() {
34
38
  return Boolean(pollAbort && !pollAbort.signal.aborted);
35
39
  }
36
40
  async function ensurePolling() {
41
+ // Single chokepoint for the runtime connection switch: while disarmed, no
42
+ // auto-connect path (boot, getStatus/reconcile self-heal, QR success,
43
+ // updateSettings) may start the poll loop.
44
+ if (!connectionEnabled) {
45
+ return;
46
+ }
37
47
  if (isRunning()) {
38
48
  return;
39
49
  }
@@ -720,7 +730,7 @@ export function createGatewayService(deps) {
720
730
  await enableGatewayTranslationRuntime();
721
731
  }
722
732
  await ensurePolling();
723
- return deps.settings.expose(settings, isRunning());
733
+ return deps.settings.expose(settings, isRunning(), connectionEnabled);
724
734
  },
725
735
  async updateSettings(input) {
726
736
  const gatewayStartInput = input.enabled === true
@@ -745,15 +755,31 @@ export function createGatewayService(deps) {
745
755
  else {
746
756
  await stopPolling();
747
757
  }
748
- return deps.settings.expose(settings, isRunning());
758
+ return deps.settings.expose(settings, isRunning(), connectionEnabled);
749
759
  },
750
760
  async resetBinding() {
751
761
  await stopPolling();
762
+ // Disarm the connection switch on reset so no connection survives a
763
+ // binding reset.
764
+ connectionEnabled = false;
752
765
  lastFailedTranslation = null;
753
766
  qrLogin = null;
754
767
  larkRegistrationState = null;
755
768
  const settings = await deps.settings.resetBinding();
756
- return deps.settings.expose(settings, isRunning());
769
+ return deps.settings.expose(settings, isRunning(), connectionEnabled);
770
+ },
771
+ async setConnectionEnabled(enabled) {
772
+ connectionEnabled = enabled;
773
+ // Armed → connect now if an account is configured; disarmed → abort the
774
+ // poll loop (the Lark WS closes via the abort path in waitForUpdates).
775
+ if (enabled) {
776
+ await ensurePolling();
777
+ }
778
+ else {
779
+ await stopPolling();
780
+ }
781
+ const settings = await deps.settings.loadSettings();
782
+ return deps.settings.expose(settings, isRunning(), connectionEnabled);
757
783
  },
758
784
  async startQrLogin() {
759
785
  const settings = await deps.settings.loadSettings();
@@ -921,7 +947,7 @@ export function createGatewayService(deps) {
921
947
  });
922
948
  larkRegistrationState = null;
923
949
  await ensurePolling();
924
- const status = deps.settings.expose(settings, isRunning());
950
+ const status = deps.settings.expose(settings, isRunning(), connectionEnabled);
925
951
  return {
926
952
  status: "confirmed",
927
953
  appIdConfigured: Boolean(result.appId),
@@ -985,7 +1011,7 @@ export function createGatewayService(deps) {
985
1011
  });
986
1012
  larkRegistrationState = null;
987
1013
  await ensurePolling();
988
- const status = deps.settings.expose(settings, isRunning());
1014
+ const status = deps.settings.expose(settings, isRunning(), connectionEnabled);
989
1015
  return {
990
1016
  status: "confirmed",
991
1017
  appIdConfigured: true,
@@ -1013,7 +1039,9 @@ export function createGatewayService(deps) {
1013
1039
  const settings = await deps.settings.loadSettings();
1014
1040
  const account = toAccount(settings);
1015
1041
  const boundUserId = settings.binding.boundUserId;
1016
- if (!settings.enabled || !account || !boundUserId) {
1042
+ // A disarmed gateway never touches the channel: skip the outbound push.
1043
+ // The latest reply was already cached above and replays on the next /start.
1044
+ if (!connectionEnabled || !settings.enabled || !account || !boundUserId) {
1017
1045
  return;
1018
1046
  }
1019
1047
  const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
@@ -79,11 +79,12 @@ export function createGatewaySettingsService(deps) {
79
79
  updatedAt: now()
80
80
  });
81
81
  },
82
- expose(settings, running = false) {
82
+ expose(settings, running = false, connectionEnabled = false) {
83
83
  return {
84
84
  version: 1,
85
85
  enabled: settings.enabled,
86
86
  running,
87
+ connectionEnabled,
87
88
  channel: settings.channel,
88
89
  translationEnabled: settings.translationEnabled,
89
90
  currentProjectId: settings.currentProjectId,
@@ -28,6 +28,7 @@ export function createNodePtyTerminalRuntime(deps) {
28
28
  });
29
29
  const session = {
30
30
  id: sessionId,
31
+ repoRoot: input.repoRoot,
31
32
  taskSlug: input.taskSlug,
32
33
  role: input.role,
33
34
  status: "running",
@@ -31,9 +31,11 @@ import { createSessionService } from "./services/session-service.js";
31
31
  import { createMessageService } from "./services/message-service.js";
32
32
  import { createRoundService } from "./services/round-service.js";
33
33
  import { createRuntimeCoordinatorService } from "./services/runtime-coordinator-service.js";
34
+ import { createRuntimeRecoveryService } from "./services/runtime-recovery-service.js";
34
35
  import { createStatusService } from "./services/status-service.js";
35
36
  import { createTaskService } from "./services/task-service.js";
36
37
  import { createTaskLaunchService } from "./services/task-launch-service.js";
38
+ import { createTerminalInterruptService } from "./services/terminal-interrupt-service.js";
37
39
  import { createTranslationService } from "./services/translation-service.js";
38
40
  import { createDiagnosticsService } from "./services/diagnostics-service.js";
39
41
  import { registerAppSettingsRoutes } from "./api/app-settings-routes.js";
@@ -86,7 +88,7 @@ export async function createServer(deps, options = {}) {
86
88
  });
87
89
  registerProjectRoutes(app, {
88
90
  projectService: deps.projectService,
89
- translationWorkerService: deps.translationWorkerService
91
+ runtimeRecoveryService: deps.runtimeRecoveryService
90
92
  });
91
93
  registerHarnessRoutes(app, {
92
94
  projectService: deps.projectService,
@@ -143,7 +145,10 @@ export async function createServer(deps, options = {}) {
143
145
  translationService: deps.translationService
144
146
  });
145
147
  registerGatewayRoutes(app, { gatewayService: deps.gatewayService });
146
- registerTerminalWs(app, { runtime: deps.runtime });
148
+ registerTerminalWs(app, {
149
+ runtime: deps.runtime,
150
+ onManualInterrupt: (sessionId) => deps.terminalInterruptService.handleManualInterrupt(sessionId)
151
+ });
147
152
  app.addHook("onReady", async () => {
148
153
  await cleanupRecentTranslationRuntime(deps);
149
154
  await deps.gatewayService.start();
@@ -314,6 +319,13 @@ export function createDefaultServerDeps(options = {}) {
314
319
  return (await projectService.loadConfig(repoRoot)).stateRoot;
315
320
  }
316
321
  });
322
+ const runtimeRecoveryService = createRuntimeRecoveryService({
323
+ fs,
324
+ runtime,
325
+ projectService,
326
+ taskService,
327
+ translationWorkerService
328
+ });
317
329
  const claudeHookService = createClaudeHookService({
318
330
  projectService,
319
331
  taskService,
@@ -329,6 +341,13 @@ export function createDefaultServerDeps(options = {}) {
329
341
  jobGuard: createJobGuardService(),
330
342
  translationWorkerService
331
343
  });
344
+ const terminalInterruptService = createTerminalInterruptService({
345
+ runtime,
346
+ projectService,
347
+ taskService,
348
+ sessionService,
349
+ roundService
350
+ });
332
351
  const diagnosticsService = createDiagnosticsService({
333
352
  appRoot,
334
353
  runtime,
@@ -354,6 +373,8 @@ export function createDefaultServerDeps(options = {}) {
354
373
  translationService,
355
374
  gatewayService,
356
375
  runtimeCoordinator,
376
+ runtimeRecoveryService,
377
+ terminalInterruptService,
357
378
  runtime,
358
379
  diagnosticsService
359
380
  };
@@ -374,6 +374,11 @@ export function createClaudeHookService(deps) {
374
374
  }
375
375
  const stateInput = createRoundStateInput(context);
376
376
  const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
377
+ if (currentRoundState.stopReason === "manual-interrupt"
378
+ && currentRoundState.status === "stopped"
379
+ && currentRoundState.activeRole === input.role) {
380
+ return false;
381
+ }
377
382
  const previousAttempt = currentRoundState.roleRecovery?.role === input.role &&
378
383
  currentRoundState.roleRecovery.status !== "failed"
379
384
  ? currentRoundState.roleRecovery.attempt
@@ -246,6 +246,25 @@ export function createRoundService(deps) {
246
246
  recordClaudeHookEvent(input) {
247
247
  return recordRoleTurnEvent(input);
248
248
  },
249
+ async recordManualInterrupt(input) {
250
+ return withTaskLock(input, async () => {
251
+ const timestamp = now();
252
+ const state = await load(input);
253
+ const next = applyManualInterrupt({
254
+ state,
255
+ taskSlug: input.taskSlug,
256
+ role: input.role,
257
+ timestamp
258
+ });
259
+ if (!manualInterruptWasApplied(state, next, input.role)) {
260
+ return toSessionRoundState(state, timestamp);
261
+ }
262
+ await save(input, next);
263
+ clearSettleTimer(input);
264
+ await updateSessionStatus(input, next.currentRound?.status === "running" ? "running" : "stopped");
265
+ return toSessionRoundState(next, timestamp);
266
+ });
267
+ },
249
268
  async setRoleRecovery(input) {
250
269
  return withTaskLock(input, async () => {
251
270
  const timestamp = now();
@@ -327,6 +346,7 @@ function applyPromptSubmitted(input) {
327
346
  lastTurnStartedAt: input.timestamp,
328
347
  settleDeadlineAt: undefined,
329
348
  stoppedAt: undefined,
349
+ stopReason: undefined,
330
350
  activeTurnStartedAt: current.activeTurnStartedAt ?? input.timestamp,
331
351
  turnCount: current.turnCount + 1,
332
352
  roles: appendUniqueRole(current.roles, input.role)
@@ -360,6 +380,7 @@ function applyStop(input) {
360
380
  activeRole: input.role,
361
381
  lastTurnEndedAt: input.timestamp,
362
382
  settleDeadlineAt: addMilliseconds(input.timestamp, input.settleMs),
383
+ stopReason: undefined,
363
384
  activeTurnStartedAt: undefined,
364
385
  ccActiveMs: current.ccActiveMs + activeDurationMs,
365
386
  completedTurnCount: current.completedTurnCount + 1,
@@ -374,6 +395,47 @@ function applyStop(input) {
374
395
  updatedAt: input.timestamp
375
396
  };
376
397
  }
398
+ function applyManualInterrupt(input) {
399
+ const current = input.state.currentRound;
400
+ if (!current || current.status === "stopped" || !current.activeTurnStartedAt || current.activeRole !== input.role) {
401
+ return {
402
+ ...input.state,
403
+ taskSlug: input.taskSlug,
404
+ updatedAt: input.timestamp
405
+ };
406
+ }
407
+ const activeDurationMs = getDurationMs(current.activeTurnStartedAt, input.timestamp);
408
+ const stopped = {
409
+ ...current,
410
+ status: "stopped",
411
+ activeRole: input.role,
412
+ lastTurnEndedAt: input.timestamp,
413
+ stoppedAt: input.timestamp,
414
+ stopReason: "manual-interrupt",
415
+ settleDeadlineAt: undefined,
416
+ activeTurnStartedAt: undefined,
417
+ ccActiveMs: current.ccActiveMs + activeDurationMs,
418
+ completedTurnCount: current.completedTurnCount + 1,
419
+ roles: appendUniqueRole(current.roles, input.role)
420
+ };
421
+ return {
422
+ ...input.state,
423
+ taskSlug: input.taskSlug,
424
+ currentRound: stopped,
425
+ lastStoppedRound: stopped,
426
+ totalCompletedTurnCount: input.state.totalCompletedTurnCount + 1,
427
+ totalCcActiveMs: input.state.totalCcActiveMs + activeDurationMs,
428
+ pendingUserReply: undefined,
429
+ updatedAt: input.timestamp
430
+ };
431
+ }
432
+ function manualInterruptWasApplied(previous, next, role) {
433
+ return Boolean(previous.currentRound?.status === "running"
434
+ && previous.currentRound.activeTurnStartedAt
435
+ && previous.currentRound.activeRole === role
436
+ && next.currentRound?.stopReason === "manual-interrupt"
437
+ && next.currentRound.status === "stopped");
438
+ }
377
439
  function toSessionRoundState(state, updatedAt) {
378
440
  const current = state.currentRound;
379
441
  if (!current) {
@@ -407,6 +469,7 @@ function toSessionRoundState(state, updatedAt) {
407
469
  lastTurnEndedAt: current.lastTurnEndedAt,
408
470
  settleDeadlineAt: current.settleDeadlineAt,
409
471
  stoppedAt: current.stoppedAt,
472
+ stopReason: current.stopReason,
410
473
  activeTurnStartedAt: current.activeTurnStartedAt,
411
474
  roundSequence: current.sequence,
412
475
  turnCount: current.turnCount,
@@ -439,8 +502,10 @@ function computeFlowPause(current, roleRecovery, awaitingUser) {
439
502
  return undefined;
440
503
  }
441
504
  const roundStopped = Boolean(current) && current.status === "stopped" && Boolean(current.id);
505
+ const nonAlertingStop = roundStopped && (current.stopReason === "manual-interrupt" ||
506
+ current.stopReason === "runtime-recovery");
442
507
  if (roleRecovery?.status === "failed") {
443
- return roundStopped
508
+ return roundStopped && !nonAlertingStop
444
509
  ? {
445
510
  paused: true,
446
511
  reason: "role-recovery-failed",
@@ -459,7 +524,7 @@ function computeFlowPause(current, roleRecovery, awaitingUser) {
459
524
  messageTruncated: awaitingUser.messageTruncated
460
525
  };
461
526
  }
462
- if (roundStopped) {
527
+ if (roundStopped && !nonAlertingStop) {
463
528
  return {
464
529
  paused: true,
465
530
  reason: "stopped-no-next-turn",
@@ -633,6 +698,9 @@ function normalizeRound(input) {
633
698
  : typeof legacy.pausedAt === "string"
634
699
  ? legacy.pausedAt
635
700
  : undefined,
701
+ stopReason: input.stopReason === "manual-interrupt" || input.stopReason === "runtime-recovery"
702
+ ? input.stopReason
703
+ : undefined,
636
704
  activeTurnStartedAt: typeof input.activeTurnStartedAt === "string"
637
705
  ? input.activeTurnStartedAt
638
706
  : typeof legacy.runningSince === "string"
@@ -0,0 +1,322 @@
1
+ import path from "node:path";
2
+ import { GATE_REVIEW_GATES } from "../../shared/types/gate-review.js";
3
+ import { getTaskRuntimeRepoRoot } from "./task-service.js";
4
+ const TRANSLATOR_SESSION_PATH = ".ai/vcm/translations/session.json";
5
+ const HARNESS_ENGINEER_SESSION_PATH = ".ai/vcm/harness-engineer/session.json";
6
+ const BOOTSTRAP_SESSION_PATH = ".ai/vcm/bootstrap/session.json";
7
+ const HARNESS_FEEDBACK_STATE_PATH = ".ai/vcm/harness-feedback/state.json";
8
+ const RECOVERABLE_FEEDBACK_STATES = new Set(["analyzing", "applying"]);
9
+ export function createRuntimeRecoveryService(deps) {
10
+ const now = deps.now ?? (() => new Date().toISOString());
11
+ return {
12
+ async recoverProject(repoRoot) {
13
+ const recoveredAt = now();
14
+ const context = {
15
+ changedPaths: new Set(),
16
+ warnings: []
17
+ };
18
+ await runStep(context, "cleanup translation runtime", () => deps.translationWorkerService?.cleanupStartupRuntime(repoRoot) ?? Promise.resolve());
19
+ const config = await deps.projectService.loadConfig(repoRoot);
20
+ await runStep(context, "recover project tool sessions", () => recoverProjectToolSessions(repoRoot, recoveredAt, context));
21
+ await runStep(context, "recover harness bootstrap", () => recoverHarnessBootstrap(repoRoot, recoveredAt, context));
22
+ await runStep(context, "recover harness feedback", () => recoverHarnessFeedback(repoRoot, recoveredAt, context));
23
+ const tasks = await deps.taskService.listTasks(repoRoot);
24
+ for (const task of tasks.filter((candidate) => candidate.cleanupStatus !== "cleaned")) {
25
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
26
+ await runStep(context, `recover task ${task.taskSlug}`, async () => {
27
+ await recoverTaskSessions(taskRepoRoot, config.stateRoot, task.taskSlug, recoveredAt, context);
28
+ const roundRecovered = await recoverRound(taskRepoRoot, config.stateRoot, task.taskSlug, recoveredAt, context);
29
+ await recoverMessages(taskRepoRoot, config.stateRoot, task.taskSlug, recoveredAt, context);
30
+ await recoverGateReview(taskRepoRoot, recoveredAt, context);
31
+ if ((roundRecovered || task.status === "running") && !hasLiveTaskSession(task.taskSlug)) {
32
+ await deps.taskService.updateTaskStatus(repoRoot, task.taskSlug, "stopped");
33
+ }
34
+ });
35
+ }
36
+ return {
37
+ repoRoot,
38
+ recoveredAt,
39
+ changedPaths: [...context.changedPaths].sort(),
40
+ warnings: context.warnings
41
+ };
42
+ }
43
+ };
44
+ async function recoverProjectToolSessions(repoRoot, timestamp, context) {
45
+ await recoverProjectRoleSessionFile(repoRoot, TRANSLATOR_SESSION_PATH, timestamp, context);
46
+ await recoverProjectRoleSessionFile(repoRoot, HARNESS_ENGINEER_SESSION_PATH, timestamp, context);
47
+ }
48
+ async function recoverProjectRoleSessionFile(repoRoot, relativePath, timestamp, context) {
49
+ const absolutePath = path.join(repoRoot, relativePath);
50
+ const state = await readJsonIfExists(absolutePath);
51
+ if (!state?.record) {
52
+ return;
53
+ }
54
+ const next = recoverRoleSessionRecord(state.record, timestamp);
55
+ if (!next.changed) {
56
+ return;
57
+ }
58
+ await deps.fs.writeJsonAtomic(absolutePath, {
59
+ ...state,
60
+ updatedAt: timestamp,
61
+ record: next.record
62
+ });
63
+ context.changedPaths.add(relativePath);
64
+ }
65
+ async function recoverTaskSessions(taskRepoRoot, stateRoot, taskSlug, timestamp, context) {
66
+ const relativePath = path.join(stateRoot, "sessions", `${taskSlug}.json`);
67
+ const absolutePath = path.join(taskRepoRoot, relativePath);
68
+ const state = await readJsonIfExists(absolutePath);
69
+ if (!state?.roles) {
70
+ return;
71
+ }
72
+ let changed = false;
73
+ const roles = {};
74
+ for (const [role, pointer] of Object.entries(state.roles)) {
75
+ if (!pointer) {
76
+ continue;
77
+ }
78
+ const record = pointer.record;
79
+ if (!record) {
80
+ roles[role] = pointer;
81
+ continue;
82
+ }
83
+ const next = recoverRoleSessionRecord(record, timestamp);
84
+ changed ||= next.changed;
85
+ roles[role] = {
86
+ ...pointer,
87
+ status: next.record.status,
88
+ record: next.record
89
+ };
90
+ }
91
+ if (!changed) {
92
+ return;
93
+ }
94
+ await deps.fs.writeJsonAtomic(absolutePath, {
95
+ ...state,
96
+ updatedAt: timestamp,
97
+ roles
98
+ });
99
+ context.changedPaths.add(relativePath);
100
+ }
101
+ function recoverRoleSessionRecord(record, timestamp) {
102
+ const live = record.id ? deps.runtime.getSession(record.id) : undefined;
103
+ if (live?.status === "running") {
104
+ return { changed: false, record };
105
+ }
106
+ const recoveredStatus = getRecoveredSessionStatus(record);
107
+ const shouldIdle = record.activityStatus !== "idle";
108
+ if (record.status === recoveredStatus && !shouldIdle) {
109
+ return { changed: false, record };
110
+ }
111
+ return {
112
+ changed: true,
113
+ record: {
114
+ ...record,
115
+ status: recoveredStatus,
116
+ activityStatus: "idle",
117
+ lastTurnEndedAt: record.lastTurnEndedAt ?? timestamp,
118
+ updatedAt: timestamp
119
+ }
120
+ };
121
+ }
122
+ async function recoverRound(taskRepoRoot, stateRoot, taskSlug, timestamp, context) {
123
+ const relativePath = path.join(stateRoot, "rounds", `${taskSlug}.json`);
124
+ const absolutePath = path.join(taskRepoRoot, relativePath);
125
+ const state = await readJsonIfExists(absolutePath);
126
+ if (!state) {
127
+ return false;
128
+ }
129
+ let changed = false;
130
+ let current = state.currentRound;
131
+ if (current?.status === "running" && !hasLiveRoundRole(taskSlug, current.activeRole)) {
132
+ const activeDurationMs = current.activeTurnStartedAt
133
+ ? getDurationMs(current.activeTurnStartedAt, timestamp)
134
+ : 0;
135
+ const completedIncrement = current.activeTurnStartedAt ? 1 : 0;
136
+ current = {
137
+ ...current,
138
+ status: "stopped",
139
+ lastTurnEndedAt: current.lastTurnEndedAt ?? timestamp,
140
+ stoppedAt: timestamp,
141
+ stopReason: "runtime-recovery",
142
+ settleDeadlineAt: undefined,
143
+ activeTurnStartedAt: undefined,
144
+ ccActiveMs: (current.ccActiveMs ?? 0) + activeDurationMs,
145
+ completedTurnCount: (current.completedTurnCount ?? 0) + completedIncrement
146
+ };
147
+ changed = true;
148
+ state.currentRound = current;
149
+ state.lastStoppedRound = current;
150
+ state.totalCompletedTurnCount = (state.totalCompletedTurnCount ?? 0) + completedIncrement;
151
+ state.totalCcActiveMs = (state.totalCcActiveMs ?? 0) + activeDurationMs;
152
+ state.pendingUserReply = undefined;
153
+ }
154
+ if (state.roleRecovery?.status === "waiting" || state.roleRecovery?.status === "retrying") {
155
+ state.roleRecovery = undefined;
156
+ changed = true;
157
+ }
158
+ if (!changed) {
159
+ return false;
160
+ }
161
+ state.updatedAt = timestamp;
162
+ await deps.fs.writeJsonAtomic(absolutePath, state);
163
+ context.changedPaths.add(relativePath);
164
+ return true;
165
+ }
166
+ async function recoverMessages(taskRepoRoot, stateRoot, taskSlug, timestamp, context) {
167
+ const relativePath = path.join(stateRoot, "messages", `${taskSlug}.jsonl`);
168
+ const absolutePath = path.join(taskRepoRoot, relativePath);
169
+ if (!(await deps.fs.pathExists(absolutePath))) {
170
+ return;
171
+ }
172
+ const lines = (await deps.fs.readText(absolutePath)).split(/\r?\n/).filter((line) => line.trim());
173
+ let changed = false;
174
+ const messages = lines.map((line) => {
175
+ const message = JSON.parse(line);
176
+ if (message.dispatchingAt && !message.acceptedAt) {
177
+ const next = {
178
+ ...message,
179
+ failureReason: "VCM restarted before this dispatch was confirmed. Resend the message if it is still needed."
180
+ };
181
+ delete next.dispatchingAt;
182
+ delete next.deliveredAt;
183
+ changed = true;
184
+ return next;
185
+ }
186
+ return message;
187
+ });
188
+ if (!changed) {
189
+ return;
190
+ }
191
+ const content = messages.length ? `${messages.map((message) => JSON.stringify(message)).join("\n")}\n` : "";
192
+ await deps.fs.writeText(absolutePath, content);
193
+ context.changedPaths.add(relativePath);
194
+ void timestamp;
195
+ }
196
+ async function recoverGateReview(taskRepoRoot, timestamp, context) {
197
+ const relativePath = path.join(".ai/vcm/gate-reviews", "index.json");
198
+ const absolutePath = path.join(taskRepoRoot, relativePath);
199
+ const index = await readJsonIfExists(absolutePath);
200
+ if (!index?.gates) {
201
+ return;
202
+ }
203
+ let changed = false;
204
+ const gates = { ...index.gates };
205
+ for (const gate of GATE_REVIEW_GATES) {
206
+ const record = gates[gate];
207
+ if (record?.status !== "running") {
208
+ continue;
209
+ }
210
+ gates[gate] = {
211
+ ...record,
212
+ status: "pending",
213
+ error: "VCM restarted before this Gate Review completed. Request the gate again if it is still required.",
214
+ updatedAt: timestamp
215
+ };
216
+ changed = true;
217
+ }
218
+ const activeGate = isGateReviewGate(index.activeGate) && gates[index.activeGate].status === "running"
219
+ ? index.activeGate
220
+ : null;
221
+ changed ||= index.activeGate !== activeGate;
222
+ if (!changed) {
223
+ return;
224
+ }
225
+ await deps.fs.writeJsonAtomic(absolutePath, {
226
+ ...index,
227
+ activeGate,
228
+ gates,
229
+ updatedAt: timestamp
230
+ });
231
+ context.changedPaths.add(relativePath);
232
+ }
233
+ async function recoverHarnessBootstrap(repoRoot, _timestamp, context) {
234
+ const absolutePath = path.join(repoRoot, BOOTSTRAP_SESSION_PATH);
235
+ const state = await readJsonIfExists(absolutePath);
236
+ if (state?.status !== "running") {
237
+ return;
238
+ }
239
+ await deps.fs.removePath?.(absolutePath, { force: true });
240
+ context.changedPaths.add(BOOTSTRAP_SESSION_PATH);
241
+ }
242
+ async function recoverHarnessFeedback(repoRoot, timestamp, context) {
243
+ const absolutePath = path.join(repoRoot, HARNESS_FEEDBACK_STATE_PATH);
244
+ const state = await readJsonIfExists(absolutePath);
245
+ if (!state?.status || !RECOVERABLE_FEEDBACK_STATES.has(state.status)) {
246
+ return;
247
+ }
248
+ const previousStatus = state.status;
249
+ const next = {
250
+ ...state,
251
+ status: "awaiting_user_approval",
252
+ updatedAt: timestamp,
253
+ active: state.active
254
+ ? {
255
+ ...state.active,
256
+ updatedAt: timestamp
257
+ }
258
+ : state.active
259
+ };
260
+ await deps.fs.writeJsonAtomic(absolutePath, next);
261
+ context.changedPaths.add(HARNESS_FEEDBACK_STATE_PATH);
262
+ const analysisPath = state.active?.analysisPath;
263
+ if (analysisPath) {
264
+ const notePath = path.join(repoRoot, analysisPath);
265
+ if (!(await deps.fs.pathExists(notePath))) {
266
+ await deps.fs.writeText(notePath, [
267
+ "# Harness Feedback Recovery",
268
+ "",
269
+ `VCM restarted while this harness feedback item was ${previousStatus}.`,
270
+ "Review the current repository diff and either comment, reject, cancel, or approve explicitly."
271
+ ].join("\n"));
272
+ context.changedPaths.add(analysisPath);
273
+ }
274
+ }
275
+ }
276
+ function hasLiveTaskSession(taskSlug) {
277
+ return deps.runtime.listSessions(taskSlug).some((session) => session.status === "running");
278
+ }
279
+ function hasLiveRoundRole(taskSlug, role) {
280
+ if (!role) {
281
+ return false;
282
+ }
283
+ return deps.runtime.getSessionByRole(taskSlug, role)?.status === "running";
284
+ }
285
+ async function readJsonIfExists(absolutePath) {
286
+ if (!(await deps.fs.pathExists(absolutePath))) {
287
+ return undefined;
288
+ }
289
+ return deps.fs.readJson(absolutePath);
290
+ }
291
+ }
292
+ function getRecoveredSessionStatus(record) {
293
+ if (record.status === "done") {
294
+ return "done";
295
+ }
296
+ if (record.claudeSessionId?.trim()) {
297
+ return "resumable";
298
+ }
299
+ if (record.status === "not_started") {
300
+ return "not_started";
301
+ }
302
+ return "missing";
303
+ }
304
+ function getDurationMs(start, end) {
305
+ const startMs = Date.parse(start);
306
+ const endMs = Date.parse(end);
307
+ if (!Number.isFinite(startMs) || !Number.isFinite(endMs)) {
308
+ return 0;
309
+ }
310
+ return Math.max(0, endMs - startMs);
311
+ }
312
+ function isGateReviewGate(value) {
313
+ return typeof value === "string" && GATE_REVIEW_GATES.includes(value);
314
+ }
315
+ async function runStep(context, label, run) {
316
+ try {
317
+ await run();
318
+ }
319
+ catch (error) {
320
+ context.warnings.push(`${label}: ${error instanceof Error ? error.message : String(error)}`);
321
+ }
322
+ }