vibe-coding-master 0.6.0 → 0.6.2

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.
@@ -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
+ }
@@ -74,6 +74,7 @@ export function createSessionService(deps) {
74
74
  cwd: taskRepoRoot
75
75
  };
76
76
  const runtimeSession = await deps.runtime.createSession({
77
+ repoRoot,
77
78
  taskSlug,
78
79
  role,
79
80
  command: startCommand.command,
@@ -166,6 +167,7 @@ export function createSessionService(deps) {
166
167
  cwd: launchCwd
167
168
  };
168
169
  const runtimeSession = await deps.runtime.createSession({
170
+ repoRoot,
169
171
  taskSlug: PROJECT_TRANSLATOR_SCOPE,
170
172
  role: TRANSLATOR_ROLE,
171
173
  command: startCommand.command,
@@ -264,6 +266,7 @@ export function createSessionService(deps) {
264
266
  cwd: launchCwd
265
267
  };
266
268
  const runtimeSession = await deps.runtime.createSession({
269
+ repoRoot,
267
270
  taskSlug: PROJECT_HARNESS_ENGINEER_SCOPE,
268
271
  role: HARNESS_ENGINEER_ROLE,
269
272
  command: startCommand.command,
@@ -425,6 +428,7 @@ export function createSessionService(deps) {
425
428
  cwd: launchCwd
426
429
  };
427
430
  const runtimeSession = await deps.runtime.createSession({
431
+ repoRoot,
428
432
  taskSlug: normalizeProjectScopedRecordForPersistence(session).taskSlug,
429
433
  role: session.role,
430
434
  command: startCommand.command,
@@ -515,6 +519,54 @@ export function createSessionService(deps) {
515
519
  const task = await deps.taskService.loadTask(repoRoot, session.taskSlug);
516
520
  await persistRoleSessionRecord(deps.fs, repoRoot, getTaskRuntimeRepoRoot(task), config.stateRoot, session);
517
521
  }
522
+ async function getProjectToolSessionView(repoRoot, role) {
523
+ const record = role === TRANSLATOR_ROLE
524
+ ? getRegisteredProjectTranslatorSession(deps.registry, deps.runtime)
525
+ ?? await loadPersistedTranslatorSession(deps.fs, repoRoot)
526
+ : getRegisteredProjectHarnessEngineerSession(deps.registry, deps.runtime)
527
+ ?? await loadPersistedHarnessEngineerSession(deps.fs, repoRoot);
528
+ const view = toRoleSessionRecordView(record, deps.runtime);
529
+ return view ? withHarnessRevisionView(repoRoot, view) : undefined;
530
+ }
531
+ async function markProjectToolActivityIdle(repoRoot, current, persist) {
532
+ const timestamp = now();
533
+ const updated = {
534
+ ...current,
535
+ activityStatus: "idle",
536
+ lastTurnEndedAt: timestamp,
537
+ updatedAt: timestamp
538
+ };
539
+ deps.registry.upsert(updated);
540
+ await persist(deps.fs, repoRoot, updated);
541
+ return updated;
542
+ }
543
+ async function getTaskRoleSessionView(repoRoot, taskSlug, role) {
544
+ const config = await deps.projectService.loadConfig(repoRoot);
545
+ const task = await deps.taskService.loadTask(repoRoot, taskSlug);
546
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
547
+ const record = getRegisteredRoleSession(deps.registry, deps.runtime, taskSlug, role)
548
+ ?? await loadPersistedRoleRecordForRole(deps.fs, repoRoot, taskRepoRoot, config.stateRoot, taskSlug, role);
549
+ const view = toRoleSessionRecordView(record, deps.runtime);
550
+ return view ? withHarnessRevisionView(repoRoot, view) : undefined;
551
+ }
552
+ async function markTaskRoleActivityIdle(repoRoot, taskSlug, role) {
553
+ const current = await getTaskRoleSessionView(repoRoot, taskSlug, role);
554
+ if (!current) {
555
+ return undefined;
556
+ }
557
+ const timestamp = now();
558
+ const updated = {
559
+ ...current,
560
+ activityStatus: "idle",
561
+ lastTurnEndedAt: timestamp,
562
+ updatedAt: timestamp
563
+ };
564
+ deps.registry.upsert(updated);
565
+ const config = await deps.projectService.loadConfig(repoRoot);
566
+ const task = await deps.taskService.loadTask(repoRoot, taskSlug);
567
+ await persistRoleSessionRecord(deps.fs, repoRoot, getTaskRuntimeRepoRoot(task), config.stateRoot, updated);
568
+ return updated;
569
+ }
518
570
  return {
519
571
  startProjectTranslatorSession(repoRoot, input = {}) {
520
572
  return launchProjectTranslatorSession(repoRoot, input, "fresh");
@@ -940,6 +992,28 @@ export function createSessionService(deps) {
940
992
  cwd: input.cwd
941
993
  });
942
994
  },
995
+ async markTerminalSessionActivityIdle(repoRoot, sessionId) {
996
+ const runtimeSession = deps.runtime.getSession(sessionId);
997
+ const registered = deps.registry.get(sessionId);
998
+ const role = registered?.role ?? runtimeSession?.role;
999
+ const taskSlug = registered?.taskSlug ?? runtimeSession?.taskSlug;
1000
+ if (!role || !taskSlug) {
1001
+ return undefined;
1002
+ }
1003
+ if (role === TRANSLATOR_ROLE) {
1004
+ const current = await getProjectToolSessionView(repoRoot, TRANSLATOR_ROLE);
1005
+ return current?.id === sessionId
1006
+ ? markProjectToolActivityIdle(repoRoot, current, persistTranslatorSession)
1007
+ : undefined;
1008
+ }
1009
+ if (role === HARNESS_ENGINEER_ROLE) {
1010
+ const current = await getProjectToolSessionView(repoRoot, HARNESS_ENGINEER_ROLE);
1011
+ return current?.id === sessionId
1012
+ ? markProjectToolActivityIdle(repoRoot, current, persistHarnessEngineerSession)
1013
+ : undefined;
1014
+ }
1015
+ return markTaskRoleActivityIdle(repoRoot, taskSlug, role);
1016
+ },
943
1017
  async markRoleActivityRunning(repoRoot, taskSlug, role) {
944
1018
  const current = await this.getRoleSession(repoRoot, taskSlug, role);
945
1019
  if (!current) {
@@ -960,22 +1034,21 @@ export function createSessionService(deps) {
960
1034
  return updated;
961
1035
  },
962
1036
  async markRoleActivityIdle(repoRoot, taskSlug, role) {
963
- const current = await this.getRoleSession(repoRoot, taskSlug, role);
964
- if (!current) {
965
- return undefined;
1037
+ if (role === TRANSLATOR_ROLE) {
1038
+ void taskSlug;
1039
+ const current = await getProjectToolSessionView(repoRoot, TRANSLATOR_ROLE);
1040
+ return current
1041
+ ? markProjectToolActivityIdle(repoRoot, current, persistTranslatorSession)
1042
+ : undefined;
966
1043
  }
967
- const timestamp = now();
968
- const updated = {
969
- ...current,
970
- activityStatus: "idle",
971
- lastTurnEndedAt: timestamp,
972
- updatedAt: timestamp
973
- };
974
- deps.registry.upsert(updated);
975
- const config = await deps.projectService.loadConfig(repoRoot);
976
- const task = await deps.taskService.loadTask(repoRoot, taskSlug);
977
- await persistRoleSessionRecord(deps.fs, repoRoot, getTaskRuntimeRepoRoot(task), config.stateRoot, updated);
978
- return updated;
1044
+ if (role === HARNESS_ENGINEER_ROLE) {
1045
+ void taskSlug;
1046
+ const current = await getProjectToolSessionView(repoRoot, HARNESS_ENGINEER_ROLE);
1047
+ return current
1048
+ ? markProjectToolActivityIdle(repoRoot, current, persistHarnessEngineerSession)
1049
+ : undefined;
1050
+ }
1051
+ return markTaskRoleActivityIdle(repoRoot, taskSlug, role);
979
1052
  }
980
1053
  };
981
1054
  }
@@ -0,0 +1,29 @@
1
+ import { isVcmRoleName } from "../../shared/constants.js";
2
+ import { getTaskRuntimeRepoRoot } from "./task-service.js";
3
+ export function createTerminalInterruptService(deps) {
4
+ return {
5
+ async handleManualInterrupt(sessionId) {
6
+ const terminalSession = deps.runtime.getSession(sessionId);
7
+ if (!terminalSession) {
8
+ return;
9
+ }
10
+ const repoRoot = terminalSession.repoRoot ?? (await deps.projectService.getCurrentProject())?.repoRoot;
11
+ if (!repoRoot) {
12
+ return;
13
+ }
14
+ await deps.sessionService.markTerminalSessionActivityIdle(repoRoot, sessionId);
15
+ if (!isVcmRoleName(terminalSession.role)) {
16
+ return;
17
+ }
18
+ const config = await deps.projectService.loadConfig(repoRoot);
19
+ const task = await deps.taskService.loadTask(repoRoot, terminalSession.taskSlug);
20
+ await deps.roundService.recordManualInterrupt({
21
+ repoRoot,
22
+ stateRepoRoot: getTaskRuntimeRepoRoot(task),
23
+ stateRoot: config.stateRoot,
24
+ taskSlug: terminalSession.taskSlug,
25
+ role: terminalSession.role
26
+ });
27
+ }
28
+ };
29
+ }
@@ -13,6 +13,8 @@ const TRANSLATION_MODEL = "translator";
13
13
  const OUTPUT_TRANSLATION_BATCH_DELAY_MS = 10000;
14
14
  const TRANSCRIPT_REPLAY_GRACE_MS = 5000;
15
15
  const TRANSLATION_TASK_FEED_RETENTION_LIMIT = 2000;
16
+ const GATEWAY_TRANSLATION_REUSE_GRACE_MS = 1500;
17
+ const GATEWAY_TRANSLATION_REUSE_POLL_MS = 50;
16
18
  export function createTranslationService(deps) {
17
19
  const now = deps.now ?? (() => new Date().toISOString());
18
20
  const id = deps.id ?? (() => `tr_${Date.now()}_${Math.random().toString(16).slice(2)}`);
@@ -1004,6 +1006,10 @@ export function createTranslationService(deps) {
1004
1006
  },
1005
1007
  async translateGatewayOutput(input) {
1006
1008
  const config = await loadConfig();
1009
+ const reusable = await findReusableGatewayOutputTranslation(input, config);
1010
+ if (reusable) {
1011
+ return reusable.trim();
1012
+ }
1007
1013
  const translation = await translateText({
1008
1014
  repoRoot: input.repoRoot,
1009
1015
  taskSlug: input.taskSlug,
@@ -1033,6 +1039,148 @@ export function createTranslationService(deps) {
1033
1039
  };
1034
1040
  }
1035
1041
  };
1042
+ async function findReusableGatewayOutputTranslation(input, config) {
1043
+ const graceDeadline = Date.now() + (input.sourceEntryIds?.length ? GATEWAY_TRANSLATION_REUSE_GRACE_MS : 0);
1044
+ while (true) {
1045
+ const lookup = await lookupGatewayOutputTranslation(input);
1046
+ if (lookup.kind === "translated") {
1047
+ return lookup.text;
1048
+ }
1049
+ if (lookup.kind === "active") {
1050
+ return waitForReusableGatewayOutputTranslation(input, config);
1051
+ }
1052
+ if (!input.sourceEntryIds?.length || Date.now() >= graceDeadline) {
1053
+ return undefined;
1054
+ }
1055
+ await delay(GATEWAY_TRANSLATION_REUSE_POLL_MS);
1056
+ }
1057
+ }
1058
+ async function waitForReusableGatewayOutputTranslation(input, config) {
1059
+ const deadline = Date.now() + config.requestTimeoutMs;
1060
+ while (Date.now() <= deadline) {
1061
+ const lookup = await lookupGatewayOutputTranslation(input);
1062
+ if (lookup.kind === "translated") {
1063
+ return lookup.text;
1064
+ }
1065
+ if (lookup.kind !== "active") {
1066
+ return undefined;
1067
+ }
1068
+ await delay(GATEWAY_TRANSLATION_REUSE_POLL_MS);
1069
+ }
1070
+ throw new VcmError({
1071
+ code: "TRANSLATION_TIMEOUT",
1072
+ message: "Gateway output translation timed out while waiting for the existing PM reply translation.",
1073
+ statusCode: 504
1074
+ });
1075
+ }
1076
+ async function lookupGatewayOutputTranslation(input) {
1077
+ const states = await getGatewayOutputCandidateStates(input);
1078
+ return selectGatewayOutputTranslation(states, input);
1079
+ }
1080
+ async function getGatewayOutputCandidateStates(input) {
1081
+ const states = [];
1082
+ const seen = new Set();
1083
+ const add = (state) => {
1084
+ if (seen.has(state) || !isGatewayOutputCandidateState(state, input)) {
1085
+ return;
1086
+ }
1087
+ seen.add(state);
1088
+ states.push(state);
1089
+ };
1090
+ const roleSession = await getGatewayRoleSession(input);
1091
+ if (roleSession) {
1092
+ const state = await prepareCache({
1093
+ repoRoot: roleSession.cwd,
1094
+ baseRepoRoot: input.repoRoot,
1095
+ taskSlug: input.taskSlug,
1096
+ role: input.role,
1097
+ sessionId: roleSession.id
1098
+ });
1099
+ if (roleSession.status === "running") {
1100
+ startTranscriptTail(roleSession);
1101
+ }
1102
+ add(state);
1103
+ }
1104
+ for (const state of sessionStates.values()) {
1105
+ add(state);
1106
+ }
1107
+ return states;
1108
+ }
1109
+ async function getGatewayRoleSession(input) {
1110
+ try {
1111
+ return await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.role);
1112
+ }
1113
+ catch {
1114
+ return undefined;
1115
+ }
1116
+ }
1117
+ function isGatewayOutputCandidateState(state, input) {
1118
+ if (state.taskSlug !== input.taskSlug || state.role !== input.role) {
1119
+ return false;
1120
+ }
1121
+ return state.baseRepoRoot === input.repoRoot
1122
+ || state.repoRoot === input.repoRoot
1123
+ || Boolean(state.repoRoot?.startsWith(`${input.repoRoot}${path.sep}`));
1124
+ }
1125
+ function selectGatewayOutputTranslation(states, input) {
1126
+ const sourceEntryIds = normalizeGatewaySourceEntryIds(input.sourceEntryIds);
1127
+ if (sourceEntryIds.length > 0) {
1128
+ const entries = sourceEntryIds
1129
+ .map((entryId) => findGatewayOutputEntryById(states, input, entryId));
1130
+ const foundEntries = entries.filter((entry) => Boolean(entry));
1131
+ if (foundEntries.length === sourceEntryIds.length) {
1132
+ if (foundEntries.some(isActiveTranslationEntry)) {
1133
+ return { kind: "active" };
1134
+ }
1135
+ if (foundEntries.every(isReusableGatewayOutputEntry)) {
1136
+ return {
1137
+ kind: "translated",
1138
+ text: foundEntries.map((entry) => entry.translatedText).join("\n\n")
1139
+ };
1140
+ }
1141
+ }
1142
+ else if (foundEntries.some(isActiveTranslationEntry)) {
1143
+ return { kind: "active" };
1144
+ }
1145
+ }
1146
+ const normalizedSourceText = normalizeGatewaySourceText(input.text);
1147
+ const sourceMatches = states
1148
+ .flatMap((state) => state.entries)
1149
+ .filter((entry) => isGatewayOutputEntry(entry, input)
1150
+ && normalizeGatewaySourceText(entry.sourceText) === normalizedSourceText);
1151
+ const translated = [...sourceMatches].reverse().find(isReusableGatewayOutputEntry);
1152
+ if (translated) {
1153
+ return { kind: "translated", text: translated.translatedText };
1154
+ }
1155
+ if (sourceMatches.some(isActiveTranslationEntry)) {
1156
+ return { kind: "active" };
1157
+ }
1158
+ return { kind: "missing" };
1159
+ }
1160
+ function findGatewayOutputEntryById(states, input, entryId) {
1161
+ for (const state of states) {
1162
+ const entry = state.entries.find((candidate) => candidate.id === entryId && isGatewayOutputEntry(candidate, input));
1163
+ if (entry) {
1164
+ return entry;
1165
+ }
1166
+ }
1167
+ return undefined;
1168
+ }
1169
+ function isGatewayOutputEntry(entry, input) {
1170
+ return entry.taskSlug === input.taskSlug
1171
+ && entry.role === input.role
1172
+ && entry.direction === "cc-output-to-user"
1173
+ && entry.sourceKind === "prose";
1174
+ }
1175
+ function isReusableGatewayOutputEntry(entry) {
1176
+ return entry.status === "translated" && Boolean(entry.translatedText.trim());
1177
+ }
1178
+ function normalizeGatewaySourceEntryIds(sourceEntryIds) {
1179
+ return Array.from(new Set((sourceEntryIds ?? []).map((entryId) => entryId.trim()).filter(Boolean)));
1180
+ }
1181
+ function normalizeGatewaySourceText(text) {
1182
+ return text.trim();
1183
+ }
1036
1184
  async function writeToCurrentRole(repoRoot, taskSlug, role, text) {
1037
1185
  const record = await deps.sessionService.getRoleSession(repoRoot, taskSlug, role);
1038
1186
  if (!record || record.status !== "running") {