vibe-coding-master 0.5.2 → 0.5.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/gateway/gateway-service.js +2 -73
- package/dist/backend/services/claude-hook-service.js +69 -34
- package/dist/backend/services/claude-transcript-reply.js +107 -0
- package/dist/backend/services/round-service.js +139 -26
- package/dist/backend/services/session-service.js +54 -27
- package/dist/shared/constants.js +10 -0
- package/dist-frontend/assets/{index-BaDS9Ohj.js → index-C0CdYJxv.js} +3 -3
- package/dist-frontend/index.html +1 -1
- package/package.json +1 -1
|
@@ -1,9 +1,9 @@
|
|
|
1
|
-
import { readFile } from "node:fs/promises";
|
|
2
1
|
import { VCM_ROLE_NAMES } from "../../shared/constants.js";
|
|
3
2
|
import { VcmError } from "../errors.js";
|
|
4
3
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
5
4
|
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
6
|
-
import {
|
|
5
|
+
import { resolveExistingClaudeTranscriptPath } from "../services/claude-transcript-service.js";
|
|
6
|
+
import { isFinalTurnTextEvent, readTranscriptTextEvents, selectLatestTurnReply } from "../services/claude-transcript-reply.js";
|
|
7
7
|
import { parseGatewayCommand } from "./gateway-command-parser.js";
|
|
8
8
|
import { createLarkRegistrationClient } from "./channels/lark-registration.js";
|
|
9
9
|
const QR_LOGIN_TTL_MS = 8 * 60 * 1000;
|
|
@@ -13,7 +13,6 @@ 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 MAX_LATEST_PM_REPLY_CHARS = 8_000;
|
|
17
16
|
const GATEWAY_TRANSLATION_FAILURE_TEXT = "PM 回复已收到,但翻译失败。\n发送 /retry 重新翻译。";
|
|
18
17
|
const COMMANDS_ALLOWED_WHEN_DISABLED = new Set([
|
|
19
18
|
"help",
|
|
@@ -1186,29 +1185,6 @@ export function createGatewayService(deps) {
|
|
|
1186
1185
|
});
|
|
1187
1186
|
}
|
|
1188
1187
|
}
|
|
1189
|
-
async function readTranscriptTextEvents(transcriptPath) {
|
|
1190
|
-
const raw = await readFile(transcriptPath, "utf8");
|
|
1191
|
-
const events = [];
|
|
1192
|
-
for (const line of raw.split("\n")) {
|
|
1193
|
-
if (!line.trim()) {
|
|
1194
|
-
continue;
|
|
1195
|
-
}
|
|
1196
|
-
for (const event of parseAssistantContent(line)) {
|
|
1197
|
-
if (event.kind === "text") {
|
|
1198
|
-
events.push({
|
|
1199
|
-
id: event.id,
|
|
1200
|
-
timestamp: event.timestamp,
|
|
1201
|
-
text: event.text,
|
|
1202
|
-
stopReason: event.stopReason
|
|
1203
|
-
});
|
|
1204
|
-
}
|
|
1205
|
-
}
|
|
1206
|
-
}
|
|
1207
|
-
return events;
|
|
1208
|
-
}
|
|
1209
|
-
function isFinalTurnTextEvent(event) {
|
|
1210
|
-
return event.stopReason === "end_turn";
|
|
1211
|
-
}
|
|
1212
1188
|
function selectEventsAfterCursor(events, cursorId) {
|
|
1213
1189
|
if (events.length === 0) {
|
|
1214
1190
|
return [];
|
|
@@ -1222,56 +1198,9 @@ function selectEventsAfterCursor(events, cursorId) {
|
|
|
1222
1198
|
}
|
|
1223
1199
|
return events.slice(index + 1);
|
|
1224
1200
|
}
|
|
1225
|
-
function selectLatestTurnReply(events, session) {
|
|
1226
|
-
if (events.length === 0) {
|
|
1227
|
-
return undefined;
|
|
1228
|
-
}
|
|
1229
|
-
const startMs = timestampMs(session.lastTurnStartedAt);
|
|
1230
|
-
const endMs = timestampMs(session.lastTurnEndedAt);
|
|
1231
|
-
const finalEvents = events.filter(isFinalTurnTextEvent);
|
|
1232
|
-
const selected = startMs === undefined
|
|
1233
|
-
? finalEvents.slice(-1)
|
|
1234
|
-
: finalEvents.filter((event) => {
|
|
1235
|
-
const eventMs = timestampMs(event.timestamp);
|
|
1236
|
-
return eventMs !== undefined
|
|
1237
|
-
&& eventMs >= startMs - 1_000
|
|
1238
|
-
&& (endMs === undefined || eventMs <= endMs + 1_000);
|
|
1239
|
-
});
|
|
1240
|
-
if (selected.length === 0) {
|
|
1241
|
-
return undefined;
|
|
1242
|
-
}
|
|
1243
|
-
const text = selected.map((event) => event.text).join("\n\n").trim();
|
|
1244
|
-
if (!text) {
|
|
1245
|
-
return undefined;
|
|
1246
|
-
}
|
|
1247
|
-
const limited = limitLatestPmReply(text);
|
|
1248
|
-
const lastEvent = selected.at(-1);
|
|
1249
|
-
return {
|
|
1250
|
-
transcriptEventId: lastEvent?.id ?? null,
|
|
1251
|
-
transcriptTimestamp: lastEvent?.timestamp ?? null,
|
|
1252
|
-
text: limited.text,
|
|
1253
|
-
truncated: limited.truncated
|
|
1254
|
-
};
|
|
1255
|
-
}
|
|
1256
1201
|
function latestPmReplyKey(repoRoot, taskSlug) {
|
|
1257
1202
|
return JSON.stringify([repoRoot, taskSlug]);
|
|
1258
1203
|
}
|
|
1259
|
-
function limitLatestPmReply(text) {
|
|
1260
|
-
if (text.length <= MAX_LATEST_PM_REPLY_CHARS) {
|
|
1261
|
-
return { text, truncated: false };
|
|
1262
|
-
}
|
|
1263
|
-
return {
|
|
1264
|
-
text: text.slice(0, MAX_LATEST_PM_REPLY_CHARS).trimEnd(),
|
|
1265
|
-
truncated: true
|
|
1266
|
-
};
|
|
1267
|
-
}
|
|
1268
|
-
function timestampMs(value) {
|
|
1269
|
-
if (!value) {
|
|
1270
|
-
return undefined;
|
|
1271
|
-
}
|
|
1272
|
-
const parsed = Date.parse(value);
|
|
1273
|
-
return Number.isFinite(parsed) ? parsed : undefined;
|
|
1274
|
-
}
|
|
1275
1204
|
function projectsText(projects) {
|
|
1276
1205
|
if (projects.length === 0) {
|
|
1277
1206
|
return "No recent projects. Connect a repository from desktop VCM first.";
|
|
@@ -1,5 +1,6 @@
|
|
|
1
|
-
import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorToolRoleName, isVcmRoleName } from "../../shared/constants.js";
|
|
1
|
+
import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorToolRoleName, isUserFacingRole, isVcmRoleName } from "../../shared/constants.js";
|
|
2
2
|
import { VcmError } from "../errors.js";
|
|
3
|
+
import { readLatestRoleTurnReply } from "./claude-transcript-reply.js";
|
|
3
4
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
4
5
|
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
5
6
|
const MAX_ROLE_RETRY_ATTEMPTS = 20;
|
|
@@ -115,17 +116,29 @@ export function createClaudeHookService(deps) {
|
|
|
115
116
|
void repoRoot;
|
|
116
117
|
return input.taskSlug;
|
|
117
118
|
}
|
|
119
|
+
// Defensive task-binding guard: only mutate a task's round/status when the
|
|
120
|
+
// posting role's authoritative session record is bound to that task. A session
|
|
121
|
+
// whose record reports a different slug (e.g. a project-scoped sentinel session)
|
|
122
|
+
// must not resume or clobber an unrelated task's flow. Absence of a record is
|
|
123
|
+
// not proof of misattribution, so the normal flow proceeds.
|
|
124
|
+
async function isHookSessionBoundToTask(context, role) {
|
|
125
|
+
const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, role);
|
|
126
|
+
return !session || session.taskSlug === context.taskSlug;
|
|
127
|
+
}
|
|
118
128
|
async function handleUserPromptSubmitHook(input) {
|
|
119
129
|
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
120
130
|
if (eventName !== "UserPromptSubmit") {
|
|
121
131
|
throwUnsupportedEvent(eventName);
|
|
122
132
|
}
|
|
123
133
|
const context = await getHookContext(input);
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
134
|
+
const boundToTask = await isHookSessionBoundToTask(context, input.role);
|
|
135
|
+
if (boundToTask) {
|
|
136
|
+
deps.jobGuard?.notePromptSubmitted({
|
|
137
|
+
repoRoot: context.project.repoRoot,
|
|
138
|
+
taskSlug: context.taskSlug,
|
|
139
|
+
role: input.role
|
|
140
|
+
});
|
|
141
|
+
}
|
|
129
142
|
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
130
143
|
taskSlug: context.taskSlug,
|
|
131
144
|
role: input.role,
|
|
@@ -134,14 +147,16 @@ export function createClaudeHookService(deps) {
|
|
|
134
147
|
transcriptPath: stringOrUndefined(input.event.transcript_path),
|
|
135
148
|
cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
|
|
136
149
|
});
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
150
|
+
if (boundToTask) {
|
|
151
|
+
await deps.roundService.recordClaudeHookEvent({
|
|
152
|
+
repoRoot: context.project.repoRoot,
|
|
153
|
+
stateRepoRoot: context.taskRepoRoot,
|
|
154
|
+
stateRoot: context.config.stateRoot,
|
|
155
|
+
taskSlug: context.taskSlug,
|
|
156
|
+
role: input.role,
|
|
157
|
+
eventName
|
|
158
|
+
});
|
|
159
|
+
}
|
|
145
160
|
if (session) {
|
|
146
161
|
await deps.translationService.recordConversationBoundary({
|
|
147
162
|
repoRoot: context.project.repoRoot,
|
|
@@ -276,6 +291,7 @@ export function createClaudeHookService(deps) {
|
|
|
276
291
|
async function recordTurnEnd(input, context, eventName, options) {
|
|
277
292
|
const scopedRouteDispatchInput = createRouteDispatchInput(input, context, input.role);
|
|
278
293
|
const settleRouteDispatchInput = createRouteDispatchInput(input, context);
|
|
294
|
+
const boundToTask = await isHookSessionBoundToTask(context, input.role);
|
|
279
295
|
const session = await deps.sessionService.recordClaudeHookEvent(context.project.repoRoot, {
|
|
280
296
|
taskSlug: context.taskSlug,
|
|
281
297
|
role: input.role,
|
|
@@ -284,28 +300,32 @@ export function createClaudeHookService(deps) {
|
|
|
284
300
|
transcriptPath: stringOrUndefined(input.event.transcript_path),
|
|
285
301
|
cwd: stringOrUndefined(input.event.cwd) ?? stringOrUndefined(input.event.new_cwd)
|
|
286
302
|
});
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
303
|
+
if (boundToTask) {
|
|
304
|
+
const userFacingReply = await captureUserFacingReply(eventName, input.role, session);
|
|
305
|
+
await deps.roundService.recordClaudeHookEvent({
|
|
306
|
+
repoRoot: context.project.repoRoot,
|
|
307
|
+
stateRepoRoot: context.taskRepoRoot,
|
|
308
|
+
stateRoot: context.config.stateRoot,
|
|
309
|
+
taskSlug: context.taskSlug,
|
|
310
|
+
role: input.role,
|
|
311
|
+
eventName,
|
|
312
|
+
...(userFacingReply ? { userFacingReply } : {}),
|
|
313
|
+
...(options.settleGuard
|
|
314
|
+
? {
|
|
315
|
+
settleGuard: async () => {
|
|
316
|
+
const pending = await deps.messageService.listPendingRouteFiles(settleRouteDispatchInput);
|
|
317
|
+
if (pending.length === 0) {
|
|
318
|
+
return { action: "stop" };
|
|
319
|
+
}
|
|
320
|
+
const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
|
|
321
|
+
return retried.some((result) => result.delivered)
|
|
322
|
+
? { action: "continue", reason: "pending route message dispatched" }
|
|
323
|
+
: { action: "stop" };
|
|
300
324
|
}
|
|
301
|
-
const retried = await deps.messageService.scanAndDispatchPendingRouteFiles(settleRouteDispatchInput);
|
|
302
|
-
return retried.some((result) => result.delivered)
|
|
303
|
-
? { action: "continue", reason: "pending route message dispatched" }
|
|
304
|
-
: { action: "stop" };
|
|
305
325
|
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
}
|
|
326
|
+
: {})
|
|
327
|
+
});
|
|
328
|
+
}
|
|
309
329
|
if (session) {
|
|
310
330
|
await deps.translationService.recordConversationBoundary({
|
|
311
331
|
repoRoot: context.project.repoRoot,
|
|
@@ -582,6 +602,21 @@ function parseHookEvent(value) {
|
|
|
582
602
|
hint: "VCM accepts UserPromptSubmit, Stop, StopFailure, and PostCompact hooks only."
|
|
583
603
|
});
|
|
584
604
|
}
|
|
605
|
+
// On a user-facing role's Stop, best-effort capture its last user-facing turn
|
|
606
|
+
// text to seed the await-user pause message. Capture failure is non-fatal and
|
|
607
|
+
// must never block turn-end, so a missing/failed read just omits the field.
|
|
608
|
+
async function captureUserFacingReply(eventName, role, session) {
|
|
609
|
+
if (eventName !== "Stop" || !isUserFacingRole(role) || !session) {
|
|
610
|
+
return undefined;
|
|
611
|
+
}
|
|
612
|
+
try {
|
|
613
|
+
const reply = await readLatestRoleTurnReply(session);
|
|
614
|
+
return reply ? { text: reply.text, truncated: reply.truncated } : undefined;
|
|
615
|
+
}
|
|
616
|
+
catch {
|
|
617
|
+
return undefined;
|
|
618
|
+
}
|
|
619
|
+
}
|
|
585
620
|
function throwUnsupportedEvent(eventName) {
|
|
586
621
|
throw new VcmError({
|
|
587
622
|
code: "HOOK_EVENT_UNSUPPORTED",
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { parseAssistantContent, resolveExistingClaudeTranscriptPath } from "./claude-transcript-service.js";
|
|
3
|
+
/** Default maximum captured-reply length (characters). */
|
|
4
|
+
export const MAX_TURN_REPLY_CHARS = 8_000;
|
|
5
|
+
/** Tolerance applied when matching transcript events to the role's last-turn window. */
|
|
6
|
+
const TURN_WINDOW_TOLERANCE_MS = 1_000;
|
|
7
|
+
/**
|
|
8
|
+
* Best-effort read of a role's latest user-facing turn reply.
|
|
9
|
+
*
|
|
10
|
+
* Resolves the session's transcript, selects the `end_turn` text emitted within
|
|
11
|
+
* the session's last-turn window (`lastTurnStartedAt`..`lastTurnEndedAt`), and
|
|
12
|
+
* truncates to `options.maxLength` (default `MAX_TURN_REPLY_CHARS`). Returns
|
|
13
|
+
* `undefined` when no transcript or no usable text is available — callers must
|
|
14
|
+
* treat a missing reply as non-fatal.
|
|
15
|
+
*/
|
|
16
|
+
export async function readLatestRoleTurnReply(session, options) {
|
|
17
|
+
const transcriptPath = resolveExistingClaudeTranscriptPath(session);
|
|
18
|
+
if (!transcriptPath) {
|
|
19
|
+
return undefined;
|
|
20
|
+
}
|
|
21
|
+
let events;
|
|
22
|
+
try {
|
|
23
|
+
events = await readTranscriptTextEvents(transcriptPath);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
return selectLatestTurnReply(events, session, options?.maxLength ?? MAX_TURN_REPLY_CHARS);
|
|
29
|
+
}
|
|
30
|
+
/** Read all assistant text events from a transcript file (throws on read failure). */
|
|
31
|
+
export async function readTranscriptTextEvents(transcriptPath) {
|
|
32
|
+
const raw = await readFile(transcriptPath, "utf8");
|
|
33
|
+
const events = [];
|
|
34
|
+
for (const line of raw.split("\n")) {
|
|
35
|
+
if (!line.trim()) {
|
|
36
|
+
continue;
|
|
37
|
+
}
|
|
38
|
+
for (const event of parseAssistantContent(line)) {
|
|
39
|
+
if (event.kind === "text") {
|
|
40
|
+
events.push({
|
|
41
|
+
id: event.id,
|
|
42
|
+
timestamp: event.timestamp,
|
|
43
|
+
text: event.text,
|
|
44
|
+
stopReason: event.stopReason
|
|
45
|
+
});
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return events;
|
|
50
|
+
}
|
|
51
|
+
/** True for a text event that completed a turn (assistant stopped of its own accord). */
|
|
52
|
+
export function isFinalTurnTextEvent(event) {
|
|
53
|
+
return event.stopReason === "end_turn";
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* Select the role's last completed user-facing reply from its transcript events,
|
|
57
|
+
* scoped to the session's last-turn window, joined and truncated. Returns
|
|
58
|
+
* `undefined` when no end_turn text falls in the window or the text is empty.
|
|
59
|
+
*/
|
|
60
|
+
export function selectLatestTurnReply(events, session, maxLength = MAX_TURN_REPLY_CHARS) {
|
|
61
|
+
if (events.length === 0) {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
const startMs = timestampMs(session.lastTurnStartedAt);
|
|
65
|
+
const endMs = timestampMs(session.lastTurnEndedAt);
|
|
66
|
+
const finalEvents = events.filter(isFinalTurnTextEvent);
|
|
67
|
+
const selected = startMs === undefined
|
|
68
|
+
? finalEvents.slice(-1)
|
|
69
|
+
: finalEvents.filter((event) => {
|
|
70
|
+
const eventMs = timestampMs(event.timestamp);
|
|
71
|
+
return eventMs !== undefined
|
|
72
|
+
&& eventMs >= startMs - TURN_WINDOW_TOLERANCE_MS
|
|
73
|
+
&& (endMs === undefined || eventMs <= endMs + TURN_WINDOW_TOLERANCE_MS);
|
|
74
|
+
});
|
|
75
|
+
if (selected.length === 0) {
|
|
76
|
+
return undefined;
|
|
77
|
+
}
|
|
78
|
+
const text = selected.map((event) => event.text).join("\n\n").trim();
|
|
79
|
+
if (!text) {
|
|
80
|
+
return undefined;
|
|
81
|
+
}
|
|
82
|
+
const limited = limitTranscriptReply(text, maxLength);
|
|
83
|
+
const lastEvent = selected.at(-1);
|
|
84
|
+
return {
|
|
85
|
+
transcriptEventId: lastEvent?.id ?? null,
|
|
86
|
+
transcriptTimestamp: lastEvent?.timestamp ?? null,
|
|
87
|
+
text: limited.text,
|
|
88
|
+
truncated: limited.truncated
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
/** Truncate reply text to `maxLength`, reporting whether truncation occurred. */
|
|
92
|
+
export function limitTranscriptReply(text, maxLength = MAX_TURN_REPLY_CHARS) {
|
|
93
|
+
if (text.length <= maxLength) {
|
|
94
|
+
return { text, truncated: false };
|
|
95
|
+
}
|
|
96
|
+
return {
|
|
97
|
+
text: text.slice(0, maxLength).trimEnd(),
|
|
98
|
+
truncated: true
|
|
99
|
+
};
|
|
100
|
+
}
|
|
101
|
+
function timestampMs(value) {
|
|
102
|
+
if (!value) {
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
const parsed = Date.parse(value);
|
|
106
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
107
|
+
}
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import path from "node:path";
|
|
2
2
|
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { isUserFacingRole } from "../../shared/constants.js";
|
|
3
4
|
const DEFAULT_SETTLE_MS = 10_000;
|
|
4
5
|
const setGlobalTimeout = globalThis.setTimeout.bind(globalThis);
|
|
5
6
|
const clearGlobalTimeout = globalThis.clearTimeout.bind(globalThis);
|
|
@@ -108,6 +109,8 @@ export function createRoundService(deps) {
|
|
|
108
109
|
...state,
|
|
109
110
|
currentRound: stopped,
|
|
110
111
|
lastStoppedRound: stopped,
|
|
112
|
+
awaitingUser: resolveAwaitingUserOnStop(state.awaitingUser, current.activeRole, stopped.stoppedAt ?? timestamp, state.pendingUserReply),
|
|
113
|
+
pendingUserReply: undefined,
|
|
111
114
|
updatedAt: timestamp
|
|
112
115
|
};
|
|
113
116
|
await save(input, next);
|
|
@@ -193,7 +196,7 @@ export function createRoundService(deps) {
|
|
|
193
196
|
const settled = await settleIfNeeded(input, await load(input), timestamp);
|
|
194
197
|
const current = settled.currentRound;
|
|
195
198
|
const shouldStartNewRound = input.eventName === "UserPromptSubmit" && (!current || current.status === "stopped");
|
|
196
|
-
const
|
|
199
|
+
const recorded = applyRoundHookEvent({
|
|
197
200
|
state: settled,
|
|
198
201
|
taskSlug: input.taskSlug,
|
|
199
202
|
role: input.role,
|
|
@@ -202,6 +205,8 @@ export function createRoundService(deps) {
|
|
|
202
205
|
roundId: shouldStartNewRound ? id() : current?.id ?? "",
|
|
203
206
|
settleMs
|
|
204
207
|
});
|
|
208
|
+
const answered = clearAwaitingUserIfAnswered(recorded, input.eventName, input.role);
|
|
209
|
+
const next = applyPendingUserReplyStash(answered, input, timestamp);
|
|
205
210
|
await save(input, next);
|
|
206
211
|
if (input.eventName === "UserPromptSubmit") {
|
|
207
212
|
clearSettleTimer(input);
|
|
@@ -383,7 +388,7 @@ function toSessionRoundState(state, updatedAt) {
|
|
|
383
388
|
totalCcActiveMs: state.totalCcActiveMs,
|
|
384
389
|
currentRoundCcActiveMs: 0,
|
|
385
390
|
roleRecovery: state.roleRecovery,
|
|
386
|
-
flowPause: computeFlowPause(undefined, state.roleRecovery),
|
|
391
|
+
flowPause: computeFlowPause(undefined, state.roleRecovery, state.awaitingUser),
|
|
387
392
|
roles: [],
|
|
388
393
|
updatedAt
|
|
389
394
|
};
|
|
@@ -412,41 +417,147 @@ function toSessionRoundState(state, updatedAt) {
|
|
|
412
417
|
totalCcActiveMs: state.totalCcActiveMs + activeDurationMs,
|
|
413
418
|
currentRoundCcActiveMs,
|
|
414
419
|
roleRecovery: state.roleRecovery,
|
|
415
|
-
flowPause: computeFlowPause(current, state.roleRecovery),
|
|
420
|
+
flowPause: computeFlowPause(current, state.roleRecovery, state.awaitingUser),
|
|
416
421
|
roles: current.roles,
|
|
417
422
|
updatedAt
|
|
418
423
|
};
|
|
419
424
|
}
|
|
420
425
|
/**
|
|
421
|
-
* Authoritative flow-pause predicate (single source of truth).
|
|
422
|
-
*
|
|
423
|
-
* advanced and we are not mid active-recovery — mirroring the decision the GUI
|
|
424
|
-
* previously derived. Reason is `role-recovery-failed` when recovery has failed,
|
|
425
|
-
* otherwise `stopped-no-next-turn`. Returns a non-paused state (or undefined) when
|
|
426
|
-
* the flow is not paused. The frontend consumes this instead of re-deriving it.
|
|
426
|
+
* Authoritative flow-pause predicate (single source of truth). The frontend
|
|
427
|
+
* consumes this instead of re-deriving the pause decision.
|
|
427
428
|
*
|
|
428
|
-
*
|
|
429
|
-
*
|
|
430
|
-
*
|
|
431
|
-
*
|
|
432
|
-
*
|
|
433
|
-
*
|
|
434
|
-
* reason: roleRecovery?.status === "failed" ? "role-recovery-failed" : "stopped-no-next-turn",
|
|
435
|
-
* role: current.activeRole,
|
|
436
|
-
* since: current.stoppedAt ?? current.lastTurnEndedAt
|
|
437
|
-
* };
|
|
429
|
+
* Reason precedence: `role-recovery-failed` > `awaiting-user` > `stopped-no-next-turn`.
|
|
430
|
+
* - `awaiting-user` is emitted whenever `awaitingUser` is set and recovery has not
|
|
431
|
+
* failed. It is STICKY: it stays paused regardless of round status (the round may
|
|
432
|
+
* auto-continue under another role while the user's decision is still pending).
|
|
433
|
+
* - `stopped-no-next-turn` is emitted only when a real round has ended and the auto
|
|
434
|
+
* flow has not advanced and we are not mid active-recovery.
|
|
438
435
|
*/
|
|
439
|
-
function computeFlowPause(current, roleRecovery) {
|
|
436
|
+
function computeFlowPause(current, roleRecovery, awaitingUser) {
|
|
440
437
|
const recovering = roleRecovery?.status === "waiting" || roleRecovery?.status === "retrying";
|
|
441
|
-
|
|
442
|
-
|
|
438
|
+
if (recovering) {
|
|
439
|
+
return undefined;
|
|
440
|
+
}
|
|
441
|
+
const roundStopped = Boolean(current) && current.status === "stopped" && Boolean(current.id);
|
|
442
|
+
if (roleRecovery?.status === "failed") {
|
|
443
|
+
return roundStopped
|
|
444
|
+
? {
|
|
445
|
+
paused: true,
|
|
446
|
+
reason: "role-recovery-failed",
|
|
447
|
+
role: current.activeRole,
|
|
448
|
+
since: current.stoppedAt ?? current.lastTurnEndedAt
|
|
449
|
+
}
|
|
450
|
+
: undefined;
|
|
451
|
+
}
|
|
452
|
+
if (awaitingUser) {
|
|
453
|
+
return {
|
|
454
|
+
paused: true,
|
|
455
|
+
reason: "awaiting-user",
|
|
456
|
+
role: awaitingUser.role,
|
|
457
|
+
since: awaitingUser.since,
|
|
458
|
+
message: awaitingUser.message,
|
|
459
|
+
messageTruncated: awaitingUser.messageTruncated
|
|
460
|
+
};
|
|
461
|
+
}
|
|
462
|
+
if (roundStopped) {
|
|
463
|
+
return {
|
|
464
|
+
paused: true,
|
|
465
|
+
reason: "stopped-no-next-turn",
|
|
466
|
+
role: current.activeRole,
|
|
467
|
+
since: current.stoppedAt ?? current.lastTurnEndedAt
|
|
468
|
+
};
|
|
469
|
+
}
|
|
470
|
+
return undefined;
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Sticky await-user resolution on settle->stopped: keep any existing anchor
|
|
474
|
+
* untouched, and only set a new one when the settling role addresses the human
|
|
475
|
+
* operator. Non-user-facing roles never raise an await-user anchor.
|
|
476
|
+
*/
|
|
477
|
+
function resolveAwaitingUserOnStop(existing, role, since, pendingReply) {
|
|
478
|
+
if (existing) {
|
|
479
|
+
return existing;
|
|
480
|
+
}
|
|
481
|
+
if (!isUserFacingRole(role)) {
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
if (pendingReply && pendingReply.role === role) {
|
|
485
|
+
return {
|
|
486
|
+
role,
|
|
487
|
+
since,
|
|
488
|
+
capturedAt: pendingReply.capturedAt,
|
|
489
|
+
message: pendingReply.text,
|
|
490
|
+
messageTruncated: pendingReply.truncated
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
return { role, since };
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* Maintain the transient `pendingUserReply` stash. A user-facing role's Stop with
|
|
497
|
+
* a captured reply records it until settle promotes it to `awaitingUser.message`.
|
|
498
|
+
* The same role's next UserPromptSubmit obsoletes the stash (turn answered/resumed),
|
|
499
|
+
* so stale text can never attach to a later anchor.
|
|
500
|
+
*/
|
|
501
|
+
function applyPendingUserReplyStash(state, input, timestamp) {
|
|
502
|
+
if (input.eventName === "Stop" && isUserFacingRole(input.role) && input.userFacingReply) {
|
|
503
|
+
return {
|
|
504
|
+
...state,
|
|
505
|
+
pendingUserReply: {
|
|
506
|
+
role: input.role,
|
|
507
|
+
text: input.userFacingReply.text,
|
|
508
|
+
truncated: input.userFacingReply.truncated,
|
|
509
|
+
capturedAt: timestamp
|
|
510
|
+
}
|
|
511
|
+
};
|
|
512
|
+
}
|
|
513
|
+
if (input.eventName === "UserPromptSubmit"
|
|
514
|
+
&& state.pendingUserReply?.role === input.role) {
|
|
515
|
+
return { ...state, pendingUserReply: undefined };
|
|
516
|
+
}
|
|
517
|
+
return state;
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* Clear the await-user anchor only when the awaiting role itself receives its
|
|
521
|
+
* next UserPromptSubmit (the user answered / that role resumed). Other roles,
|
|
522
|
+
* other events, and round auto-continuation never clear it.
|
|
523
|
+
*/
|
|
524
|
+
function clearAwaitingUserIfAnswered(state, eventName, role) {
|
|
525
|
+
if (eventName !== "UserPromptSubmit" || state.awaitingUser?.role !== role) {
|
|
526
|
+
return state;
|
|
527
|
+
}
|
|
528
|
+
return { ...state, awaitingUser: undefined };
|
|
529
|
+
}
|
|
530
|
+
function normalizeAwaitingUser(input) {
|
|
531
|
+
if (!input || typeof input !== "object") {
|
|
532
|
+
return undefined;
|
|
533
|
+
}
|
|
534
|
+
const record = input;
|
|
535
|
+
if (typeof record.role !== "string" || typeof record.since !== "string") {
|
|
536
|
+
return undefined;
|
|
537
|
+
}
|
|
538
|
+
return {
|
|
539
|
+
role: record.role,
|
|
540
|
+
since: record.since,
|
|
541
|
+
capturedAt: typeof record.capturedAt === "string" ? record.capturedAt : undefined,
|
|
542
|
+
message: typeof record.message === "string" ? record.message : undefined,
|
|
543
|
+
messageTruncated: record.messageTruncated === true ? true : undefined
|
|
544
|
+
};
|
|
545
|
+
}
|
|
546
|
+
function normalizePendingUserReply(input) {
|
|
547
|
+
if (!input || typeof input !== "object") {
|
|
548
|
+
return undefined;
|
|
549
|
+
}
|
|
550
|
+
const record = input;
|
|
551
|
+
if (typeof record.role !== "string"
|
|
552
|
+
|| typeof record.text !== "string"
|
|
553
|
+
|| typeof record.capturedAt !== "string") {
|
|
443
554
|
return undefined;
|
|
444
555
|
}
|
|
445
556
|
return {
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
557
|
+
role: record.role,
|
|
558
|
+
text: record.text,
|
|
559
|
+
truncated: record.truncated === true,
|
|
560
|
+
capturedAt: record.capturedAt
|
|
450
561
|
};
|
|
451
562
|
}
|
|
452
563
|
function normalizeRoundFile(input, taskSlug, updatedAt) {
|
|
@@ -461,6 +572,8 @@ function normalizeRoundFile(input, taskSlug, updatedAt) {
|
|
|
461
572
|
totalCompletedTurnCount: normalizeNumber(input.totalCompletedTurnCount ?? legacy.totalStopCount),
|
|
462
573
|
totalCcActiveMs: normalizeNumber(input.totalCcActiveMs),
|
|
463
574
|
roleRecovery: normalizeRoleRecovery(input.roleRecovery),
|
|
575
|
+
awaitingUser: normalizeAwaitingUser(input.awaitingUser),
|
|
576
|
+
pendingUserReply: normalizePendingUserReply(input.pendingUserReply),
|
|
464
577
|
updatedAt: typeof input.updatedAt === "string" ? input.updatedAt : updatedAt
|
|
465
578
|
};
|
|
466
579
|
}
|