vibe-coding-master 0.6.1 → 0.6.3

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.
@@ -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",
@@ -21,6 +23,8 @@ const COMMANDS_ALLOWED_WHEN_DISABLED = new Set([
21
23
  "projects",
22
24
  "tasks"
23
25
  ]);
26
+ class HandledGatewayInboundError extends Error {
27
+ }
24
28
  export function createGatewayService(deps) {
25
29
  const now = deps.now ?? (() => new Date().toISOString());
26
30
  const larkRegistration = deps.larkRegistration ?? createLarkRegistrationClient();
@@ -219,6 +223,10 @@ export function createGatewayService(deps) {
219
223
  return;
220
224
  }
221
225
  const command = parseGatewayCommand(update.text);
226
+ if (command.kind === "plain" && settings.enabled) {
227
+ await handlePlainInbound(update, command.text);
228
+ return;
229
+ }
222
230
  try {
223
231
  const output = await executeCommand(command, settings);
224
232
  await reply(await deps.settings.loadSettings(), update.fromUserId, output);
@@ -247,6 +255,69 @@ export function createGatewayService(deps) {
247
255
  });
248
256
  }
249
257
  }
258
+ async function handlePlainInbound(update, text) {
259
+ try {
260
+ const target = await resolvePlainTextPmTarget(text);
261
+ if (typeof target === "string") {
262
+ await reply(await deps.settings.loadSettings(), update.fromUserId, target);
263
+ await recordInbound(update, "ok", "plain");
264
+ return;
265
+ }
266
+ if (target.settings.translationEnabled) {
267
+ await reply(await deps.settings.loadSettings(), update.fromUserId, "已收到,正在翻译...");
268
+ }
269
+ else {
270
+ await reply(await deps.settings.loadSettings(), update.fromUserId, "已收到,正在发送给 PM...");
271
+ }
272
+ const englishText = target.settings.translationEnabled
273
+ ? await translatePlainTextForPm(target, text, update)
274
+ : text;
275
+ await submitTerminalInput(deps.runtime, target.session.id, englishText);
276
+ await reply(await deps.settings.loadSettings(), update.fromUserId, target.settings.translationEnabled
277
+ ? formatGatewayInputTranslationSuccess(englishText)
278
+ : "已发送给 PM。");
279
+ await recordInbound(update, "ok", "plain");
280
+ }
281
+ catch (error) {
282
+ if (error instanceof HandledGatewayInboundError) {
283
+ return;
284
+ }
285
+ const message = errorMessage(error);
286
+ await reply(await deps.settings.loadSettings(), update.fromUserId, `Error: ${message}`);
287
+ await recordInbound(update, "error", "plain", message);
288
+ }
289
+ }
290
+ async function translatePlainTextForPm(target, text, update) {
291
+ try {
292
+ return (await deps.translationService.translateUserInput({
293
+ repoRoot: target.project.repoRoot,
294
+ taskRepoRoot: getTaskRuntimeRepoRoot(target.task),
295
+ taskSlug: target.task.taskSlug,
296
+ role: "project-manager",
297
+ text,
298
+ useContext: false,
299
+ send: false
300
+ })).englishPreview;
301
+ }
302
+ catch (error) {
303
+ const message = errorMessage(error);
304
+ await reply(await deps.settings.loadSettings(), update.fromUserId, formatGatewayInputTranslationFailure(message));
305
+ await recordInbound(update, "error", "plain", message);
306
+ throw new HandledGatewayInboundError();
307
+ }
308
+ }
309
+ async function recordInbound(update, result, command, error) {
310
+ await recordMessageStatus("inbound", result, update.text, error, command);
311
+ await deps.audit.record({
312
+ type: "gateway.command",
313
+ result,
314
+ messageId: update.messageId,
315
+ userId: update.fromUserId,
316
+ command,
317
+ preview: update.text,
318
+ error
319
+ });
320
+ }
250
321
  async function saveInboundMetadata(settings, update, options = {}) {
251
322
  const bindMode = options.bind ?? "never";
252
323
  return deps.settings.saveSettings({
@@ -594,12 +665,15 @@ export function createGatewayService(deps) {
594
665
  }
595
666
  async function enableGatewayTranslationRuntime() {
596
667
  const preferences = await deps.appSettings.getPreferences();
597
- if (preferences.translationEnabled && preferences.translationAutoSendEnabled) {
668
+ if (preferences.translationEnabled &&
669
+ preferences.translationAutoSendEnabled &&
670
+ preferences.translationOutputMode === "round-final") {
598
671
  return;
599
672
  }
600
673
  await deps.appSettings.updatePreferences({
601
674
  translationEnabled: true,
602
- translationAutoSendEnabled: true
675
+ translationAutoSendEnabled: true,
676
+ translationOutputMode: "round-final"
603
677
  });
604
678
  }
605
679
  async function startGateway() {
@@ -633,6 +707,25 @@ export function createGatewayService(deps) {
633
707
  return lines.join("\n");
634
708
  }
635
709
  async function sendPlainTextToPm(text) {
710
+ const target = await resolvePlainTextPmTarget(text);
711
+ if (typeof target === "string") {
712
+ return target;
713
+ }
714
+ const englishText = target.settings.translationEnabled
715
+ ? (await deps.translationService.translateUserInput({
716
+ repoRoot: target.project.repoRoot,
717
+ taskRepoRoot: getTaskRuntimeRepoRoot(target.task),
718
+ taskSlug: target.task.taskSlug,
719
+ role: "project-manager",
720
+ text,
721
+ useContext: false,
722
+ send: false
723
+ })).englishPreview
724
+ : text;
725
+ await submitTerminalInput(deps.runtime, target.session.id, englishText);
726
+ return "Sent to PM.";
727
+ }
728
+ async function resolvePlainTextPmTarget(text) {
636
729
  if (!text.trim()) {
637
730
  return "Empty message ignored.";
638
731
  }
@@ -657,21 +750,13 @@ export function createGatewayService(deps) {
657
750
  if (session.activityStatus === "running") {
658
751
  return "PM is still working on the current turn. Please wait and send again later.";
659
752
  }
660
- const englishText = settings.translationEnabled
661
- ? (await deps.translationService.translateUserInput({
662
- repoRoot: project.repoRoot,
663
- taskRepoRoot: getTaskRuntimeRepoRoot(task),
664
- taskSlug: task.taskSlug,
665
- role: "project-manager",
666
- text,
667
- useContext: false,
668
- send: false
669
- })).englishPreview
670
- : text;
671
- await submitTerminalInput(deps.runtime, session.id, englishText);
672
- return "Sent to PM.";
753
+ return { project, task, session, settings };
673
754
  }
674
755
  async function reply(settings, userId, text) {
756
+ await sendGatewayText(settings, userId, text);
757
+ await recordMessageStatus("outbound", "ok", text);
758
+ }
759
+ async function sendGatewayText(settings, userId, text) {
675
760
  const account = toAccount(settings);
676
761
  if (!account) {
677
762
  return;
@@ -685,7 +770,6 @@ export function createGatewayService(deps) {
685
770
  contextToken,
686
771
  text
687
772
  });
688
- await recordMessageStatus("outbound", "ok", text);
689
773
  }
690
774
  async function recordMessageStatus(direction, result, preview, error, command) {
691
775
  const settings = await deps.settings.loadSettings();
@@ -1032,19 +1116,12 @@ export function createGatewayService(deps) {
1032
1116
  return;
1033
1117
  }
1034
1118
  const events = await readTranscriptTextEvents(transcriptPath);
1035
- const latestReply = selectLatestTurnReply(events, input.session);
1036
- if (latestReply) {
1037
- await saveLatestPmReply(input, latestReply);
1038
- }
1039
- const settings = await deps.settings.loadSettings();
1040
- const account = toAccount(settings);
1041
- const boundUserId = settings.binding.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) {
1119
+ const round = await waitForGatewayRoundFinal(input.repoRoot, input.taskSlug);
1120
+ if (!round) {
1045
1121
  return;
1046
1122
  }
1047
1123
  const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
1124
+ const settings = await deps.settings.loadSettings();
1048
1125
  const cursor = settings.pushCursors[cursorKey];
1049
1126
  const nextEvents = selectEventsAfterCursor(events, cursor?.lastTranscriptEventId)
1050
1127
  .filter(isFinalTurnTextEvent);
@@ -1055,19 +1132,36 @@ export function createGatewayService(deps) {
1055
1132
  if (!text) {
1056
1133
  return;
1057
1134
  }
1058
- const output = await renderGatewayPmOutput({
1059
- settings,
1060
- repoRoot: input.repoRoot,
1061
- taskSlug: input.taskSlug,
1062
- sourceText: text
1063
- });
1064
- await resolveChannel(settings).sendText({
1065
- account,
1066
- toUserId: boundUserId,
1067
- chatId: settings.binding.chatIds[boundUserId] ?? settings.binding.homeChatId ?? undefined,
1068
- contextToken: settings.binding.contextTokens[boundUserId],
1069
- text: output.text
1070
- });
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);
1147
+ const originalMessage = formatGatewayPmOriginalReply(text, roundNotice);
1148
+ await sendGatewayText(settings, boundUserId, originalMessage);
1149
+ let output;
1150
+ if (settings.translationEnabled) {
1151
+ output = await renderGatewayPmOutput({
1152
+ settings,
1153
+ repoRoot: input.repoRoot,
1154
+ taskSlug: input.taskSlug,
1155
+ sourceText: text,
1156
+ sourceEntryIds: nextEvents.map((event) => event.id)
1157
+ });
1158
+ await sendGatewayText(settings, boundUserId, output.translationFailed
1159
+ ? formatGatewayPmTranslationFailure(output.translationError)
1160
+ : formatGatewayPmTranslatedReply(output.text));
1161
+ }
1162
+ else {
1163
+ clearFailedTranslation(input.repoRoot, input.taskSlug);
1164
+ }
1071
1165
  const lastEvent = nextEvents.at(-1);
1072
1166
  const current = await deps.settings.loadSettings();
1073
1167
  await deps.settings.saveSettings({
@@ -1082,19 +1176,53 @@ export function createGatewayService(deps) {
1082
1176
  lastMessageStatus: {
1083
1177
  checkedAt: now(),
1084
1178
  direction: "outbound",
1085
- result: output.translationFailed ? "error" : "ok",
1179
+ result: output?.translationFailed ? "error" : "ok",
1086
1180
  command: "pm-stop",
1087
- preview: output.text.slice(0, 160),
1088
- error: output.translationError
1181
+ preview: (output?.text ?? originalMessage).slice(0, 160),
1182
+ error: output?.translationError
1089
1183
  },
1090
1184
  updatedAt: now()
1091
1185
  });
1092
1186
  await deps.audit.record({
1093
1187
  type: "gateway.pm_push",
1094
- result: output.translationFailed ? "error" : "ok",
1188
+ result: output?.translationFailed ? "error" : "ok",
1095
1189
  command: "pm-stop",
1096
- preview: output.text,
1097
- error: output.translationError
1190
+ preview: output ? `${originalMessage}\n\n${output.text}` : originalMessage,
1191
+ error: output?.translationError
1192
+ });
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
1098
1226
  });
1099
1227
  },
1100
1228
  getDiagnostics() {
@@ -1109,12 +1237,122 @@ export function createGatewayService(deps) {
1109
1237
  }
1110
1238
  return settings.latestPmReplies[latestPmReplyKey(settings.currentProjectId, settings.currentTaskSlug)];
1111
1239
  }
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) {
1255
+ try {
1256
+ const [projectConfig, task] = await Promise.all([
1257
+ deps.projectService.loadConfig(repoRoot),
1258
+ deps.taskService.loadTask(repoRoot, taskSlug)
1259
+ ]);
1260
+ const round = await deps.roundService.getSessionRoundState({
1261
+ repoRoot,
1262
+ stateRepoRoot: getTaskRuntimeRepoRoot(task),
1263
+ stateRoot: projectConfig.stateRoot,
1264
+ taskSlug
1265
+ });
1266
+ return round;
1267
+ }
1268
+ catch {
1269
+ return undefined;
1270
+ }
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
+ }
1284
+ function formatGatewayRoundNotice(round) {
1285
+ const roundLabel = round.roundSequence ? `第 ${round.roundSequence} 轮` : "当前 round";
1286
+ if (round.status === "stopped") {
1287
+ return `${roundLabel}已结束。现在需要你给出下一步指令。`;
1288
+ }
1289
+ const activeRole = round.activeRole ? `,当前角色:${round.activeRole}` : "";
1290
+ return `${roundLabel}运行中${activeRole}。`;
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
+ }
1313
+ function formatGatewayPmOriginalReply(text, roundNotice) {
1314
+ return [
1315
+ "PM final reply 原文:",
1316
+ "",
1317
+ text.trim(),
1318
+ roundNotice ? "" : undefined,
1319
+ roundNotice ? `Round: ${roundNotice}` : undefined
1320
+ ].filter((line) => line !== undefined).join("\n");
1321
+ }
1322
+ function formatGatewayPmTranslatedReply(text) {
1323
+ return [
1324
+ "PM final reply 翻译:",
1325
+ "",
1326
+ text.trim()
1327
+ ].join("\n");
1328
+ }
1329
+ function formatGatewayPmTranslationFailure(error) {
1330
+ return [
1331
+ GATEWAY_TRANSLATION_FAILURE_TEXT,
1332
+ error ? `原因:${error}` : undefined
1333
+ ].filter((line) => line !== undefined).join("\n");
1334
+ }
1335
+ function formatGatewayInputTranslationSuccess(englishText) {
1336
+ return [
1337
+ "翻译完成,已发送给 PM:",
1338
+ "",
1339
+ englishText.trim()
1340
+ ].join("\n");
1341
+ }
1342
+ function formatGatewayInputTranslationFailure(error) {
1343
+ return [
1344
+ "翻译失败,消息未发送给 PM。",
1345
+ `原因:${error}`,
1346
+ "请重新输入后再试。"
1347
+ ].join("\n");
1348
+ }
1112
1349
  async function renderLatestPmReply(settings, reply) {
1113
1350
  const rendered = await renderGatewayPmOutput({
1114
1351
  settings,
1115
1352
  repoRoot: reply.repoRoot,
1116
1353
  taskSlug: reply.taskSlug,
1117
- sourceText: reply.text
1354
+ sourceText: reply.text,
1355
+ sourceEntryIds: reply.transcriptEventId ? [reply.transcriptEventId] : undefined
1118
1356
  });
1119
1357
  return {
1120
1358
  ...rendered,
@@ -1134,7 +1372,8 @@ export function createGatewayService(deps) {
1134
1372
  repoRoot: input.repoRoot,
1135
1373
  taskSlug: input.taskSlug,
1136
1374
  role: "project-manager",
1137
- text: input.sourceText
1375
+ text: input.sourceText,
1376
+ sourceEntryIds: input.sourceEntryIds
1138
1377
  });
1139
1378
  clearFailedTranslation(input.repoRoot, input.taskSlug);
1140
1379
  return {
@@ -1293,6 +1532,9 @@ function sleep(ms, signal) {
1293
1532
  }, { once: true });
1294
1533
  });
1295
1534
  }
1535
+ function delay(ms) {
1536
+ return new Promise((resolve) => setTimeout(resolve, ms));
1537
+ }
1296
1538
  function normalizeBaseUrl(input, fallback) {
1297
1539
  const trimmed = input.trim();
1298
1540
  if (!trimmed) {
@@ -277,6 +277,7 @@ export function createDefaultServerDeps(options = {}) {
277
277
  translationWorkerService,
278
278
  fs,
279
279
  projectService,
280
+ roundService,
280
281
  appSettings
281
282
  });
282
283
  const gatewayChannels = createGatewayChannelRegistry([
@@ -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 retryScheduled = await scheduleStopFailureRetry(input, context, failure);
252
- if (retryScheduled) {
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 false;
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 false;
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 false;
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 true;
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
  }