vibe-coding-master 0.6.2 → 0.6.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/backend/api/translation-routes.js +11 -0
- package/dist/backend/gateway/gateway-service.js +108 -15
- package/dist/backend/server.js +1 -0
- package/dist/backend/services/claude-hook-service.js +52 -9
- package/dist/backend/services/translation-service.js +159 -9
- package/dist/shared/types/app-settings.js +1 -0
- package/dist-frontend/assets/{index-D5LBogZG.js → index-8pJ3dNxs.js} +22 -22
- package/dist-frontend/assets/{index-BM6nSKae.css → index-D6vwKigt.css} +1 -1
- package/dist-frontend/index.html +2 -2
- package/package.json +1 -1
|
@@ -52,6 +52,17 @@ export function registerTranslationRoutes(app, deps) {
|
|
|
52
52
|
text: request.body?.text ?? ""
|
|
53
53
|
});
|
|
54
54
|
});
|
|
55
|
+
app.post("/api/tasks/:taskSlug/sessions/:role/translation/latest-reply", async (request) => {
|
|
56
|
+
const project = await requireCurrentProject(deps.projectService);
|
|
57
|
+
const role = parseRole(request.params.role);
|
|
58
|
+
const task = await deps.taskService.loadTask(project.repoRoot, request.params.taskSlug);
|
|
59
|
+
return deps.translationService.translateLatestReply({
|
|
60
|
+
repoRoot: project.repoRoot,
|
|
61
|
+
taskRepoRoot: getTaskRuntimeRepoRoot(task),
|
|
62
|
+
taskSlug: request.params.taskSlug,
|
|
63
|
+
role
|
|
64
|
+
});
|
|
65
|
+
});
|
|
55
66
|
app.post("/api/tasks/:taskSlug/sessions/:role/translation/send", async (request) => {
|
|
56
67
|
const project = await requireCurrentProject(deps.projectService);
|
|
57
68
|
const role = parseRole(request.params.role);
|
|
@@ -13,6 +13,8 @@ const POLL_LONG_BACKOFF_MS = 30_000;
|
|
|
13
13
|
const MAX_FAILURES_BEFORE_LONG_BACKOFF = 3;
|
|
14
14
|
const DEFAULT_POLL_TIMEOUT_MS = 35_000;
|
|
15
15
|
const LARK_REGISTRATION_CONFIRM_TIMEOUT_MS = 15_000;
|
|
16
|
+
const GATEWAY_ROUND_FINAL_WAIT_MS = 12_000;
|
|
17
|
+
const GATEWAY_ROUND_FINAL_POLL_MS = 250;
|
|
16
18
|
const GATEWAY_TRANSLATION_FAILURE_TEXT = "PM 回复已收到,但翻译失败。\n发送 /retry 重新翻译。";
|
|
17
19
|
const COMMANDS_ALLOWED_WHEN_DISABLED = new Set([
|
|
18
20
|
"help",
|
|
@@ -663,12 +665,15 @@ export function createGatewayService(deps) {
|
|
|
663
665
|
}
|
|
664
666
|
async function enableGatewayTranslationRuntime() {
|
|
665
667
|
const preferences = await deps.appSettings.getPreferences();
|
|
666
|
-
if (preferences.translationEnabled &&
|
|
668
|
+
if (preferences.translationEnabled &&
|
|
669
|
+
preferences.translationAutoSendEnabled &&
|
|
670
|
+
preferences.translationOutputMode === "round-final") {
|
|
667
671
|
return;
|
|
668
672
|
}
|
|
669
673
|
await deps.appSettings.updatePreferences({
|
|
670
674
|
translationEnabled: true,
|
|
671
|
-
translationAutoSendEnabled: true
|
|
675
|
+
translationAutoSendEnabled: true,
|
|
676
|
+
translationOutputMode: "round-final"
|
|
672
677
|
});
|
|
673
678
|
}
|
|
674
679
|
async function startGateway() {
|
|
@@ -1111,19 +1116,12 @@ export function createGatewayService(deps) {
|
|
|
1111
1116
|
return;
|
|
1112
1117
|
}
|
|
1113
1118
|
const events = await readTranscriptTextEvents(transcriptPath);
|
|
1114
|
-
const
|
|
1115
|
-
if (
|
|
1116
|
-
await saveLatestPmReply(input, latestReply);
|
|
1117
|
-
}
|
|
1118
|
-
const settings = await deps.settings.loadSettings();
|
|
1119
|
-
const account = toAccount(settings);
|
|
1120
|
-
const boundUserId = settings.binding.boundUserId;
|
|
1121
|
-
// A disarmed gateway never touches the channel: skip the outbound push.
|
|
1122
|
-
// The latest reply was already cached above and replays on the next /start.
|
|
1123
|
-
if (!connectionEnabled || !settings.enabled || !account || !boundUserId) {
|
|
1119
|
+
const round = await waitForGatewayRoundFinal(input.repoRoot, input.taskSlug);
|
|
1120
|
+
if (!round) {
|
|
1124
1121
|
return;
|
|
1125
1122
|
}
|
|
1126
1123
|
const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
|
|
1124
|
+
const settings = await deps.settings.loadSettings();
|
|
1127
1125
|
const cursor = settings.pushCursors[cursorKey];
|
|
1128
1126
|
const nextEvents = selectEventsAfterCursor(events, cursor?.lastTranscriptEventId)
|
|
1129
1127
|
.filter(isFinalTurnTextEvent);
|
|
@@ -1134,7 +1132,18 @@ export function createGatewayService(deps) {
|
|
|
1134
1132
|
if (!text) {
|
|
1135
1133
|
return;
|
|
1136
1134
|
}
|
|
1137
|
-
const
|
|
1135
|
+
const latestReply = selectLatestTurnReply(nextEvents, input.session);
|
|
1136
|
+
if (latestReply) {
|
|
1137
|
+
await saveLatestPmReply(input, latestReply);
|
|
1138
|
+
}
|
|
1139
|
+
const account = toAccount(settings);
|
|
1140
|
+
const boundUserId = settings.binding.boundUserId;
|
|
1141
|
+
// A disarmed gateway never touches the channel: skip the outbound push.
|
|
1142
|
+
// The round-final PM reply was already cached above and replays on the next /start.
|
|
1143
|
+
if (!connectionEnabled || !settings.enabled || !account || !boundUserId) {
|
|
1144
|
+
return;
|
|
1145
|
+
}
|
|
1146
|
+
const roundNotice = formatGatewayRoundNotice(round);
|
|
1138
1147
|
const originalMessage = formatGatewayPmOriginalReply(text, roundNotice);
|
|
1139
1148
|
await sendGatewayText(settings, boundUserId, originalMessage);
|
|
1140
1149
|
let output;
|
|
@@ -1182,6 +1191,40 @@ export function createGatewayService(deps) {
|
|
|
1182
1191
|
error: output?.translationError
|
|
1183
1192
|
});
|
|
1184
1193
|
},
|
|
1194
|
+
async handleRoleStopFailure(input) {
|
|
1195
|
+
const round = await getGatewayRoundState(input.repoRoot, input.taskSlug);
|
|
1196
|
+
if (round?.stopReason === "manual-interrupt") {
|
|
1197
|
+
return;
|
|
1198
|
+
}
|
|
1199
|
+
const settings = await deps.settings.loadSettings();
|
|
1200
|
+
const account = toAccount(settings);
|
|
1201
|
+
const boundUserId = settings.binding.boundUserId;
|
|
1202
|
+
if (!connectionEnabled || !settings.enabled || !account || !boundUserId) {
|
|
1203
|
+
return;
|
|
1204
|
+
}
|
|
1205
|
+
const message = formatGatewayRoleStopFailure(input, round);
|
|
1206
|
+
await sendGatewayText(settings, boundUserId, message);
|
|
1207
|
+
const current = await deps.settings.loadSettings();
|
|
1208
|
+
await deps.settings.saveSettings({
|
|
1209
|
+
...current,
|
|
1210
|
+
lastMessageStatus: {
|
|
1211
|
+
checkedAt: now(),
|
|
1212
|
+
direction: "outbound",
|
|
1213
|
+
result: "error",
|
|
1214
|
+
command: "role-stop-failure",
|
|
1215
|
+
preview: message.slice(0, 160),
|
|
1216
|
+
error: input.error
|
|
1217
|
+
},
|
|
1218
|
+
updatedAt: now()
|
|
1219
|
+
});
|
|
1220
|
+
await deps.audit.record({
|
|
1221
|
+
type: "gateway.role_stop_failure",
|
|
1222
|
+
result: "error",
|
|
1223
|
+
command: "role-stop-failure",
|
|
1224
|
+
preview: message,
|
|
1225
|
+
error: input.error
|
|
1226
|
+
});
|
|
1227
|
+
},
|
|
1185
1228
|
getDiagnostics() {
|
|
1186
1229
|
return {
|
|
1187
1230
|
polling: isRunning()
|
|
@@ -1194,7 +1237,21 @@ export function createGatewayService(deps) {
|
|
|
1194
1237
|
}
|
|
1195
1238
|
return settings.latestPmReplies[latestPmReplyKey(settings.currentProjectId, settings.currentTaskSlug)];
|
|
1196
1239
|
}
|
|
1197
|
-
async function
|
|
1240
|
+
async function waitForGatewayRoundFinal(repoRoot, taskSlug) {
|
|
1241
|
+
const startedAt = Date.now();
|
|
1242
|
+
while (Date.now() - startedAt <= GATEWAY_ROUND_FINAL_WAIT_MS) {
|
|
1243
|
+
const round = await getGatewayRoundState(repoRoot, taskSlug);
|
|
1244
|
+
if (round && isGatewayRoundFinal(round)) {
|
|
1245
|
+
return round;
|
|
1246
|
+
}
|
|
1247
|
+
if (round && isGatewayNonFinalTerminalRound(round)) {
|
|
1248
|
+
return undefined;
|
|
1249
|
+
}
|
|
1250
|
+
await delay(GATEWAY_ROUND_FINAL_POLL_MS);
|
|
1251
|
+
}
|
|
1252
|
+
return undefined;
|
|
1253
|
+
}
|
|
1254
|
+
async function getGatewayRoundState(repoRoot, taskSlug) {
|
|
1198
1255
|
try {
|
|
1199
1256
|
const [projectConfig, task] = await Promise.all([
|
|
1200
1257
|
deps.projectService.loadConfig(repoRoot),
|
|
@@ -1206,12 +1263,24 @@ export function createGatewayService(deps) {
|
|
|
1206
1263
|
stateRoot: projectConfig.stateRoot,
|
|
1207
1264
|
taskSlug
|
|
1208
1265
|
});
|
|
1209
|
-
return
|
|
1266
|
+
return round;
|
|
1210
1267
|
}
|
|
1211
1268
|
catch {
|
|
1212
1269
|
return undefined;
|
|
1213
1270
|
}
|
|
1214
1271
|
}
|
|
1272
|
+
function isGatewayRoundFinal(round) {
|
|
1273
|
+
return round.status === "stopped"
|
|
1274
|
+
&& Boolean(round.roundId)
|
|
1275
|
+
&& !round.stopReason
|
|
1276
|
+
&& round.roleRecovery?.status !== "failed"
|
|
1277
|
+
&& round.flowPause?.reason !== "role-recovery-failed";
|
|
1278
|
+
}
|
|
1279
|
+
function isGatewayNonFinalTerminalRound(round) {
|
|
1280
|
+
return round.status === "stopped" && (Boolean(round.stopReason) ||
|
|
1281
|
+
round.roleRecovery?.status === "failed" ||
|
|
1282
|
+
round.flowPause?.reason === "role-recovery-failed");
|
|
1283
|
+
}
|
|
1215
1284
|
function formatGatewayRoundNotice(round) {
|
|
1216
1285
|
const roundLabel = round.roundSequence ? `第 ${round.roundSequence} 轮` : "当前 round";
|
|
1217
1286
|
if (round.status === "stopped") {
|
|
@@ -1220,6 +1289,27 @@ export function createGatewayService(deps) {
|
|
|
1220
1289
|
const activeRole = round.activeRole ? `,当前角色:${round.activeRole}` : "";
|
|
1221
1290
|
return `${roundLabel}运行中${activeRole}。`;
|
|
1222
1291
|
}
|
|
1292
|
+
function formatGatewayRoleStopFailure(input, round) {
|
|
1293
|
+
const roundLabel = round?.roundSequence ? `第 ${round.roundSequence} 轮` : "当前 round";
|
|
1294
|
+
const retryLine = input.maxAttempts !== undefined
|
|
1295
|
+
? `Retry: ${input.attempt ?? 0}/${input.maxAttempts}`
|
|
1296
|
+
: undefined;
|
|
1297
|
+
return [
|
|
1298
|
+
"VCM 角色异常中断,流程已暂停。",
|
|
1299
|
+
`Task: ${input.taskSlug}`,
|
|
1300
|
+
`Role: ${input.role}`,
|
|
1301
|
+
`Round: ${roundLabel}`,
|
|
1302
|
+
input.error ? `Reason: ${input.error}` : undefined,
|
|
1303
|
+
retryLine,
|
|
1304
|
+
input.errorDetails ? `Details: ${truncateGatewayFailureDetails(input.errorDetails)}` : undefined,
|
|
1305
|
+
"",
|
|
1306
|
+
"请在 VCM 中检查状态,或修复问题后继续给出下一步指令。"
|
|
1307
|
+
].filter((line) => line !== undefined).join("\n");
|
|
1308
|
+
}
|
|
1309
|
+
function truncateGatewayFailureDetails(value) {
|
|
1310
|
+
const trimmed = value.trim();
|
|
1311
|
+
return trimmed.length > 600 ? `${trimmed.slice(0, 600)}...` : trimmed;
|
|
1312
|
+
}
|
|
1223
1313
|
function formatGatewayPmOriginalReply(text, roundNotice) {
|
|
1224
1314
|
return [
|
|
1225
1315
|
"PM final reply 原文:",
|
|
@@ -1442,6 +1532,9 @@ function sleep(ms, signal) {
|
|
|
1442
1532
|
}, { once: true });
|
|
1443
1533
|
});
|
|
1444
1534
|
}
|
|
1535
|
+
function delay(ms) {
|
|
1536
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
1537
|
+
}
|
|
1445
1538
|
function normalizeBaseUrl(input, fallback) {
|
|
1446
1539
|
const trimmed = input.trim();
|
|
1447
1540
|
if (!trimmed) {
|
package/dist/backend/server.js
CHANGED
|
@@ -241,15 +241,27 @@ export function createClaudeHookService(deps) {
|
|
|
241
241
|
}
|
|
242
242
|
const failure = parseStopFailureDiagnostic(input.event);
|
|
243
243
|
if (!failure.retryable) {
|
|
244
|
+
if (await isManualInterruptedStopFailure(context, input.role)) {
|
|
245
|
+
return recordTurnEnd(input, context, eventName, {
|
|
246
|
+
dispatchRouteFiles: false,
|
|
247
|
+
notifyGateway: false,
|
|
248
|
+
settleGuard: false
|
|
249
|
+
});
|
|
250
|
+
}
|
|
244
251
|
await markStopFailureRecoveryFailed(input, context, failure, 0);
|
|
245
252
|
return recordTurnEnd(input, context, eventName, {
|
|
246
253
|
dispatchRouteFiles: false,
|
|
247
254
|
notifyGateway: false,
|
|
248
|
-
settleGuard: false
|
|
255
|
+
settleGuard: false,
|
|
256
|
+
gatewayStopFailure: {
|
|
257
|
+
...failure,
|
|
258
|
+
attempt: 0,
|
|
259
|
+
maxAttempts: MAX_ROLE_RETRY_ATTEMPTS
|
|
260
|
+
}
|
|
249
261
|
});
|
|
250
262
|
}
|
|
251
|
-
const
|
|
252
|
-
if (
|
|
263
|
+
const retryResult = await scheduleStopFailureRetry(input, context, failure);
|
|
264
|
+
if (retryResult === "scheduled") {
|
|
253
265
|
return {
|
|
254
266
|
ok: true,
|
|
255
267
|
eventName,
|
|
@@ -262,7 +274,8 @@ export function createClaudeHookService(deps) {
|
|
|
262
274
|
return recordTurnEnd(input, context, eventName, {
|
|
263
275
|
dispatchRouteFiles: false,
|
|
264
276
|
notifyGateway: false,
|
|
265
|
-
settleGuard: false
|
|
277
|
+
settleGuard: false,
|
|
278
|
+
...(retryResult === "manual-interrupt" ? {} : { gatewayStopFailure: failure })
|
|
266
279
|
});
|
|
267
280
|
}
|
|
268
281
|
async function processPostCompactHook(input) {
|
|
@@ -344,6 +357,17 @@ export function createClaudeHookService(deps) {
|
|
|
344
357
|
session
|
|
345
358
|
}).catch(() => undefined);
|
|
346
359
|
}
|
|
360
|
+
if (boundToTask && options.gatewayStopFailure) {
|
|
361
|
+
void deps.gatewayService?.handleRoleStopFailure({
|
|
362
|
+
repoRoot: context.project.repoRoot,
|
|
363
|
+
taskSlug: context.taskSlug,
|
|
364
|
+
role: input.role,
|
|
365
|
+
error: options.gatewayStopFailure.error,
|
|
366
|
+
errorDetails: options.gatewayStopFailure.errorDetails,
|
|
367
|
+
attempt: options.gatewayStopFailure.attempt,
|
|
368
|
+
maxAttempts: options.gatewayStopFailure.maxAttempts
|
|
369
|
+
}).catch(() => undefined);
|
|
370
|
+
}
|
|
347
371
|
const dispatched = options.dispatchRouteFiles
|
|
348
372
|
? await deps.messageService.scanAndDispatchPendingRouteFiles(scopedRouteDispatchInput)
|
|
349
373
|
: [];
|
|
@@ -370,14 +394,14 @@ export function createClaudeHookService(deps) {
|
|
|
370
394
|
async function scheduleStopFailureRetry(input, context, failure) {
|
|
371
395
|
const preferences = await deps.appSettings.getPreferences();
|
|
372
396
|
if (!preferences.roleRetryEnabled || !deps.runtime) {
|
|
373
|
-
return
|
|
397
|
+
return "not-scheduled";
|
|
374
398
|
}
|
|
375
399
|
const stateInput = createRoundStateInput(context);
|
|
376
400
|
const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
|
|
377
401
|
if (currentRoundState.stopReason === "manual-interrupt"
|
|
378
402
|
&& currentRoundState.status === "stopped"
|
|
379
403
|
&& currentRoundState.activeRole === input.role) {
|
|
380
|
-
return
|
|
404
|
+
return "manual-interrupt";
|
|
381
405
|
}
|
|
382
406
|
const previousAttempt = currentRoundState.roleRecovery?.role === input.role &&
|
|
383
407
|
currentRoundState.roleRecovery.status !== "failed"
|
|
@@ -388,7 +412,7 @@ export function createClaudeHookService(deps) {
|
|
|
388
412
|
if (attempt > MAX_ROLE_RETRY_ATTEMPTS) {
|
|
389
413
|
clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
|
|
390
414
|
await markStopFailureRecoveryFailed(input, context, failure, MAX_ROLE_RETRY_ATTEMPTS, timestamp);
|
|
391
|
-
return
|
|
415
|
+
return "not-scheduled";
|
|
392
416
|
}
|
|
393
417
|
const nextRetryAt = new Date(Date.parse(timestamp) + attempt * ROLE_RETRY_BASE_DELAY_MS).toISOString();
|
|
394
418
|
await deps.roundService.setRoleRecovery({
|
|
@@ -408,7 +432,18 @@ export function createClaudeHookService(deps) {
|
|
|
408
432
|
});
|
|
409
433
|
await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
|
|
410
434
|
scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt);
|
|
411
|
-
return
|
|
435
|
+
return "scheduled";
|
|
436
|
+
}
|
|
437
|
+
async function isManualInterruptedStopFailure(context, role) {
|
|
438
|
+
try {
|
|
439
|
+
const state = await deps.roundService.getSessionRoundState(createRoundStateInput(context));
|
|
440
|
+
return state.status === "stopped" &&
|
|
441
|
+
state.stopReason === "manual-interrupt" &&
|
|
442
|
+
state.activeRole === role;
|
|
443
|
+
}
|
|
444
|
+
catch {
|
|
445
|
+
return false;
|
|
446
|
+
}
|
|
412
447
|
}
|
|
413
448
|
function scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt) {
|
|
414
449
|
const key = stopFailureRecoveryKey(context.project.repoRoot, context.taskSlug, input.role);
|
|
@@ -441,7 +476,15 @@ export function createClaudeHookService(deps) {
|
|
|
441
476
|
await recordTurnEnd(input, context, "StopFailure", {
|
|
442
477
|
dispatchRouteFiles: false,
|
|
443
478
|
notifyGateway: false,
|
|
444
|
-
settleGuard: false
|
|
479
|
+
settleGuard: false,
|
|
480
|
+
gatewayStopFailure: {
|
|
481
|
+
error: recovery.error ?? "stop_failure",
|
|
482
|
+
errorDetails: recovery.errorDetails,
|
|
483
|
+
lastAssistantMessage: recovery.lastAssistantMessage,
|
|
484
|
+
retryable: recovery.retryable ?? true,
|
|
485
|
+
attempt: recovery.attempt,
|
|
486
|
+
maxAttempts: recovery.maxAttempts
|
|
487
|
+
}
|
|
445
488
|
});
|
|
446
489
|
return;
|
|
447
490
|
}
|
|
@@ -3,6 +3,7 @@ import { isVcmRoleName } from "../../shared/constants.js";
|
|
|
3
3
|
import { TRANSLATION_ENTRY_RETENTION_LIMIT } from "../../shared/types/translation.js";
|
|
4
4
|
import { VcmError } from "../errors.js";
|
|
5
5
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
6
|
+
import { readLatestRoleTurnReply } from "./claude-transcript-reply.js";
|
|
6
7
|
import { createTranslationQueueRegistry } from "./translation-queue.js";
|
|
7
8
|
const TRANSLATION_SOURCE_LANGUAGE = "auto";
|
|
8
9
|
const TRANSLATION_INPUT_MODE = "review-before-send";
|
|
@@ -243,11 +244,13 @@ export function createTranslationService(deps) {
|
|
|
243
244
|
let displayed = false;
|
|
244
245
|
if (event.kind === "text") {
|
|
245
246
|
const shouldTranslate = shouldTranslateTextTranscriptEvent(state, event, config);
|
|
247
|
+
const metadata = getTextTranscriptEntryMetadata(event);
|
|
246
248
|
displayed = shouldTranslate
|
|
247
249
|
? processClaudeOutputText(sessionId, event.text, config, event.id, {
|
|
248
|
-
flushImmediately: event.stopReason === "end_turn"
|
|
250
|
+
flushImmediately: event.stopReason === "end_turn",
|
|
251
|
+
metadata
|
|
249
252
|
})
|
|
250
|
-
: pushPreservedProseEntry(sessionId, event.id, event.text, config);
|
|
253
|
+
: pushPreservedProseEntry(sessionId, event.id, event.text, config, metadata);
|
|
251
254
|
if (displayed && shouldTranslate) {
|
|
252
255
|
state.lastAssistantText = event.text;
|
|
253
256
|
}
|
|
@@ -269,6 +272,9 @@ export function createTranslationService(deps) {
|
|
|
269
272
|
if (config.outputMode === "all") {
|
|
270
273
|
return true;
|
|
271
274
|
}
|
|
275
|
+
if (config.outputMode === "round-final") {
|
|
276
|
+
return false;
|
|
277
|
+
}
|
|
272
278
|
if (event.stopReason !== "end_turn") {
|
|
273
279
|
return false;
|
|
274
280
|
}
|
|
@@ -281,9 +287,16 @@ export function createTranslationService(deps) {
|
|
|
281
287
|
return startClaudeOutputTranslation(sessionId, rawText, config, {
|
|
282
288
|
entryId,
|
|
283
289
|
replaceExisting: false,
|
|
284
|
-
flushImmediately: options.flushImmediately === true
|
|
290
|
+
flushImmediately: options.flushImmediately === true,
|
|
291
|
+
metadata: options.metadata
|
|
285
292
|
}) !== undefined;
|
|
286
293
|
}
|
|
294
|
+
function getTextTranscriptEntryMetadata(event) {
|
|
295
|
+
return {
|
|
296
|
+
...(event.stopReason !== undefined ? { transcriptStopReason: event.stopReason } : {}),
|
|
297
|
+
transcriptTimestamp: event.timestamp
|
|
298
|
+
};
|
|
299
|
+
}
|
|
287
300
|
function startClaudeOutputTranslation(sessionId, rawText, config, options) {
|
|
288
301
|
const session = deps.runtime.getSession(sessionId);
|
|
289
302
|
const roleSession = deps.sessionRegistry.get(sessionId);
|
|
@@ -304,7 +317,8 @@ export function createTranslationService(deps) {
|
|
|
304
317
|
config,
|
|
305
318
|
status: "queued",
|
|
306
319
|
contextUsed: false,
|
|
307
|
-
id: options.entryId
|
|
320
|
+
id: options.entryId,
|
|
321
|
+
...options.metadata
|
|
308
322
|
})
|
|
309
323
|
};
|
|
310
324
|
if (options.replaceExisting) {
|
|
@@ -439,10 +453,10 @@ export function createTranslationService(deps) {
|
|
|
439
453
|
function pushPreservedTranscriptEntry(sessionId, entryId, sourceText, config) {
|
|
440
454
|
return pushPreservedOutputEntry(sessionId, entryId, sourceText, "tool-output", config);
|
|
441
455
|
}
|
|
442
|
-
function pushPreservedProseEntry(sessionId, entryId, sourceText, config) {
|
|
443
|
-
return pushPreservedOutputEntry(sessionId, entryId, sourceText, "prose", config);
|
|
456
|
+
function pushPreservedProseEntry(sessionId, entryId, sourceText, config, metadata = {}) {
|
|
457
|
+
return pushPreservedOutputEntry(sessionId, entryId, sourceText, "prose", config, metadata);
|
|
444
458
|
}
|
|
445
|
-
function pushPreservedOutputEntry(sessionId, entryId, sourceText, sourceKind, config) {
|
|
459
|
+
function pushPreservedOutputEntry(sessionId, entryId, sourceText, sourceKind, config, metadata = {}) {
|
|
446
460
|
if (!sourceText.trim()) {
|
|
447
461
|
return false;
|
|
448
462
|
}
|
|
@@ -462,7 +476,8 @@ export function createTranslationService(deps) {
|
|
|
462
476
|
contextUsed: false,
|
|
463
477
|
id: entryId,
|
|
464
478
|
translatedText: sourceText,
|
|
465
|
-
completedAt: now()
|
|
479
|
+
completedAt: now(),
|
|
480
|
+
...metadata
|
|
466
481
|
});
|
|
467
482
|
pushEntry(sessionId, entry);
|
|
468
483
|
return true;
|
|
@@ -552,7 +567,11 @@ export function createTranslationService(deps) {
|
|
|
552
567
|
markFailureRetrying(sessionId, existingFailure);
|
|
553
568
|
const retrying = startClaudeOutputTranslation(sessionId, original.sourceText, config, {
|
|
554
569
|
entryId: original.id,
|
|
555
|
-
replaceExisting: true
|
|
570
|
+
replaceExisting: true,
|
|
571
|
+
metadata: {
|
|
572
|
+
transcriptStopReason: original.transcriptStopReason,
|
|
573
|
+
transcriptTimestamp: original.transcriptTimestamp
|
|
574
|
+
}
|
|
556
575
|
});
|
|
557
576
|
if (!retrying) {
|
|
558
577
|
throw new VcmError({
|
|
@@ -576,6 +595,8 @@ export function createTranslationService(deps) {
|
|
|
576
595
|
translatedText: input.translatedText ?? "",
|
|
577
596
|
status: input.status,
|
|
578
597
|
contextUsed: input.contextUsed,
|
|
598
|
+
transcriptStopReason: input.transcriptStopReason,
|
|
599
|
+
transcriptTimestamp: input.transcriptTimestamp,
|
|
579
600
|
boundaryKind: input.boundaryKind,
|
|
580
601
|
conversationTurn: input.conversationTurn,
|
|
581
602
|
occurredAt: input.occurredAt,
|
|
@@ -676,6 +697,7 @@ export function createTranslationService(deps) {
|
|
|
676
697
|
};
|
|
677
698
|
},
|
|
678
699
|
async pollTaskFeed(input) {
|
|
700
|
+
const config = await loadConfig();
|
|
679
701
|
const cursor = Number.isFinite(input.after) ? Math.max(1, Math.floor(input.after)) : 1;
|
|
680
702
|
const maxEvents = Math.min(Math.max(1, Math.floor(input.limit ?? 500)), 1000);
|
|
681
703
|
const roleSessions = await deps.sessionService.listRoleSessions(input.repoRoot, input.taskSlug);
|
|
@@ -700,6 +722,9 @@ export function createTranslationService(deps) {
|
|
|
700
722
|
status: state.status
|
|
701
723
|
});
|
|
702
724
|
}
|
|
725
|
+
if (config.outputMode === "round-final") {
|
|
726
|
+
await translateRoundFinalReplyIfReady(input, config);
|
|
727
|
+
}
|
|
703
728
|
const feed = getTaskFeed(input.taskRepoRoot, input.taskSlug);
|
|
704
729
|
const events = feed.events
|
|
705
730
|
.filter((event) => event.seq >= cursor)
|
|
@@ -882,6 +907,51 @@ export function createTranslationService(deps) {
|
|
|
882
907
|
}
|
|
883
908
|
return entry;
|
|
884
909
|
},
|
|
910
|
+
async translateLatestReply(input) {
|
|
911
|
+
const config = await loadConfig();
|
|
912
|
+
const roleSession = await deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.role);
|
|
913
|
+
if (!roleSession || roleSession.status !== "running") {
|
|
914
|
+
throw new VcmError({
|
|
915
|
+
code: "SESSION_NOT_RUNNING",
|
|
916
|
+
message: `${input.role} session is not running.`,
|
|
917
|
+
statusCode: 409
|
|
918
|
+
});
|
|
919
|
+
}
|
|
920
|
+
const reply = await readLatestRoleTurnReply(roleSession);
|
|
921
|
+
if (!reply?.text.trim()) {
|
|
922
|
+
throw new VcmError({
|
|
923
|
+
code: "TRANSLATION_REPLY_NOT_FOUND",
|
|
924
|
+
message: `No completed final reply was found for ${input.role}.`,
|
|
925
|
+
statusCode: 404,
|
|
926
|
+
hint: "Wait until the role finishes a Claude Code turn, then try again."
|
|
927
|
+
});
|
|
928
|
+
}
|
|
929
|
+
await prepareCache({
|
|
930
|
+
repoRoot: input.taskRepoRoot ?? input.repoRoot,
|
|
931
|
+
baseRepoRoot: input.repoRoot,
|
|
932
|
+
taskSlug: input.taskSlug,
|
|
933
|
+
role: input.role,
|
|
934
|
+
sessionId: roleSession.id
|
|
935
|
+
});
|
|
936
|
+
startTranscriptTail(roleSession);
|
|
937
|
+
const entry = startClaudeOutputTranslation(roleSession.id, reply.text, config, {
|
|
938
|
+
replaceExisting: false,
|
|
939
|
+
flushImmediately: true,
|
|
940
|
+
metadata: {
|
|
941
|
+
transcriptStopReason: "end_turn",
|
|
942
|
+
...(reply.transcriptTimestamp ? { transcriptTimestamp: reply.transcriptTimestamp } : {})
|
|
943
|
+
}
|
|
944
|
+
});
|
|
945
|
+
if (!entry) {
|
|
946
|
+
throw new VcmError({
|
|
947
|
+
code: "TRANSLATION_NOT_STARTED",
|
|
948
|
+
message: "Latest reply translation could not be queued.",
|
|
949
|
+
statusCode: 409,
|
|
950
|
+
hint: "Check that the role session is still running and try again."
|
|
951
|
+
});
|
|
952
|
+
}
|
|
953
|
+
return entry;
|
|
954
|
+
},
|
|
885
955
|
async sendTranslatedInput(input) {
|
|
886
956
|
await writeToCurrentRole(input.repoRoot, input.taskSlug, input.role, input.englishText);
|
|
887
957
|
},
|
|
@@ -1039,6 +1109,86 @@ export function createTranslationService(deps) {
|
|
|
1039
1109
|
};
|
|
1040
1110
|
}
|
|
1041
1111
|
};
|
|
1112
|
+
async function translateRoundFinalReplyIfReady(input, config) {
|
|
1113
|
+
if (!deps.roundService || !deps.projectService) {
|
|
1114
|
+
return;
|
|
1115
|
+
}
|
|
1116
|
+
const projectConfig = await deps.projectService.loadConfig(input.repoRoot);
|
|
1117
|
+
const round = await deps.roundService.getSessionRoundState({
|
|
1118
|
+
repoRoot: input.repoRoot,
|
|
1119
|
+
stateRepoRoot: input.taskRepoRoot,
|
|
1120
|
+
stateRoot: projectConfig.stateRoot,
|
|
1121
|
+
taskSlug: input.taskSlug
|
|
1122
|
+
});
|
|
1123
|
+
if (!isNormalStoppedRound(round)) {
|
|
1124
|
+
return;
|
|
1125
|
+
}
|
|
1126
|
+
const candidate = findRoundFinalReplyCandidate(input, round);
|
|
1127
|
+
if (!candidate || candidate.entry.status !== "preserved") {
|
|
1128
|
+
return;
|
|
1129
|
+
}
|
|
1130
|
+
startClaudeOutputTranslation(candidate.sessionId, candidate.entry.sourceText, config, {
|
|
1131
|
+
entryId: candidate.entry.id,
|
|
1132
|
+
replaceExisting: true,
|
|
1133
|
+
flushImmediately: true,
|
|
1134
|
+
metadata: {
|
|
1135
|
+
transcriptStopReason: candidate.entry.transcriptStopReason,
|
|
1136
|
+
transcriptTimestamp: candidate.entry.transcriptTimestamp
|
|
1137
|
+
}
|
|
1138
|
+
});
|
|
1139
|
+
}
|
|
1140
|
+
function isNormalStoppedRound(round) {
|
|
1141
|
+
return round.status === "stopped"
|
|
1142
|
+
&& Boolean(round.roundId)
|
|
1143
|
+
&& !round.stopReason
|
|
1144
|
+
&& round.roleRecovery?.status !== "failed"
|
|
1145
|
+
&& round.flowPause?.reason !== "role-recovery-failed";
|
|
1146
|
+
}
|
|
1147
|
+
function findRoundFinalReplyCandidate(input, round) {
|
|
1148
|
+
const candidates = [];
|
|
1149
|
+
for (const [sessionId, state] of sessionStates) {
|
|
1150
|
+
if (!isRoundFinalCandidateState(state, input, round.activeRole)) {
|
|
1151
|
+
continue;
|
|
1152
|
+
}
|
|
1153
|
+
for (const entry of state.entries) {
|
|
1154
|
+
if (isRoundFinalCandidateEntry(entry, input, round.stoppedAt)) {
|
|
1155
|
+
candidates.push({ sessionId, entry });
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
}
|
|
1159
|
+
return candidates
|
|
1160
|
+
.sort((left, right) => compareRoundFinalCandidates(left.entry, right.entry))
|
|
1161
|
+
.at(-1);
|
|
1162
|
+
}
|
|
1163
|
+
function isRoundFinalCandidateState(state, input, activeRole) {
|
|
1164
|
+
if (state.repoRoot !== input.taskRepoRoot || state.taskSlug !== input.taskSlug || !state.role) {
|
|
1165
|
+
return false;
|
|
1166
|
+
}
|
|
1167
|
+
return !activeRole || state.role === activeRole;
|
|
1168
|
+
}
|
|
1169
|
+
function isRoundFinalCandidateEntry(entry, input, stoppedAt) {
|
|
1170
|
+
if (entry.taskSlug !== input.taskSlug ||
|
|
1171
|
+
entry.direction !== "cc-output-to-user" ||
|
|
1172
|
+
entry.sourceKind !== "prose" ||
|
|
1173
|
+
entry.transcriptStopReason !== "end_turn") {
|
|
1174
|
+
return false;
|
|
1175
|
+
}
|
|
1176
|
+
if (entry.status !== "preserved" && entry.status !== "queued" && entry.status !== "translating" && entry.status !== "translated") {
|
|
1177
|
+
return false;
|
|
1178
|
+
}
|
|
1179
|
+
if (!stoppedAt || !entry.transcriptTimestamp) {
|
|
1180
|
+
return true;
|
|
1181
|
+
}
|
|
1182
|
+
return entry.transcriptTimestamp <= stoppedAt;
|
|
1183
|
+
}
|
|
1184
|
+
function compareRoundFinalCandidates(left, right) {
|
|
1185
|
+
const leftTime = left.transcriptTimestamp ?? left.createdAt;
|
|
1186
|
+
const rightTime = right.transcriptTimestamp ?? right.createdAt;
|
|
1187
|
+
if (leftTime === rightTime) {
|
|
1188
|
+
return left.id.localeCompare(right.id);
|
|
1189
|
+
}
|
|
1190
|
+
return leftTime.localeCompare(rightTime);
|
|
1191
|
+
}
|
|
1042
1192
|
async function findReusableGatewayOutputTranslation(input, config) {
|
|
1043
1193
|
const graceDeadline = Date.now() + (input.sourceEntryIds?.length ? GATEWAY_TRANSLATION_REUSE_GRACE_MS : 0);
|
|
1044
1194
|
while (true) {
|
|
@@ -12,6 +12,7 @@ export const TRANSLATION_TARGET_LANGUAGE_OPTIONS = [
|
|
|
12
12
|
{ value: "es", label: "Spanish" }
|
|
13
13
|
];
|
|
14
14
|
export const TRANSLATION_OUTPUT_MODE_OPTIONS = [
|
|
15
|
+
{ value: "round-final", label: "Round final reply" },
|
|
15
16
|
{ value: "pm-final-only", label: "PM final reply" },
|
|
16
17
|
{ value: "final-only", label: "Each role final reply" },
|
|
17
18
|
{ value: "all", label: "All replies" }
|