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.
- package/dist/backend/api/project-routes.js +1 -1
- package/dist/backend/gateway/gateway-service.js +184 -35
- package/dist/backend/runtime/node-pty-runtime.js +1 -0
- package/dist/backend/server.js +23 -2
- package/dist/backend/services/claude-hook-service.js +5 -0
- package/dist/backend/services/round-service.js +70 -2
- package/dist/backend/services/runtime-recovery-service.js +322 -0
- package/dist/backend/services/session-service.js +88 -15
- package/dist/backend/services/terminal-interrupt-service.js +29 -0
- package/dist/backend/services/translation-service.js +148 -0
- package/dist/backend/services/translation-worker-service.js +54 -142
- package/dist/backend/templates/harness/claude-root.js +5 -5
- package/dist/backend/templates/harness/gate-review.js +2 -2
- package/dist/backend/templates/harness/project-manager-agent.js +2 -1
- package/dist/backend/ws/terminal-ws.js +11 -5
- package/package.json +1 -1
|
@@ -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
|
-
|
|
964
|
-
|
|
965
|
-
|
|
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
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
971
|
-
|
|
972
|
-
|
|
973
|
-
}
|
|
974
|
-
|
|
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") {
|