vibe-coding-master 0.5.6 → 0.6.1
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/gateway-routes.js +5 -0
- package/dist/backend/api/project-routes.js +1 -1
- package/dist/backend/gateway/gateway-service.js +34 -6
- package/dist/backend/gateway/gateway-settings-service.js +2 -1
- 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 +166 -16
- package/dist/backend/services/terminal-interrupt-service.js +29 -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/dist-frontend/assets/{index-BL1E27kN.js → index-D5LBogZG.js} +35 -35
- package/dist-frontend/index.html +1 -1
- package/package.json +1 -1
|
@@ -23,4 +23,9 @@ export function registerGatewayRoutes(app, deps) {
|
|
|
23
23
|
app.post("/api/gateway/binding/reset", async () => {
|
|
24
24
|
return deps.gatewayService.resetBinding();
|
|
25
25
|
});
|
|
26
|
+
// Arm/disarm the runtime channel-connection switch (process-local, not
|
|
27
|
+
// persisted). Returns the updated GatewayStatus.
|
|
28
|
+
app.put("/api/gateway/connection", async (request) => {
|
|
29
|
+
return deps.gatewayService.setConnectionEnabled(request.body.enabled);
|
|
30
|
+
});
|
|
26
31
|
}
|
|
@@ -5,7 +5,7 @@ export function registerProjectRoutes(app, deps) {
|
|
|
5
5
|
});
|
|
6
6
|
app.post("/api/projects/connect", async (request) => {
|
|
7
7
|
const project = await deps.projectService.connectProject(request.body);
|
|
8
|
-
await deps.
|
|
8
|
+
await deps.runtimeRecoveryService?.recoverProject(project.repoRoot);
|
|
9
9
|
return project;
|
|
10
10
|
});
|
|
11
11
|
app.get("/api/projects/current", async () => {
|
|
@@ -30,10 +30,20 @@ export function createGatewayService(deps) {
|
|
|
30
30
|
let qrLogin = null;
|
|
31
31
|
let larkRegistrationState = null;
|
|
32
32
|
let lastFailedTranslation = null;
|
|
33
|
+
// Runtime channel-connection arming switch. Not persisted: every process starts
|
|
34
|
+
// disarmed. `ensurePolling` is the single chokepoint that reads it, so no
|
|
35
|
+
// self-heal path can connect the channel while this is false.
|
|
36
|
+
let connectionEnabled = false;
|
|
33
37
|
function isRunning() {
|
|
34
38
|
return Boolean(pollAbort && !pollAbort.signal.aborted);
|
|
35
39
|
}
|
|
36
40
|
async function ensurePolling() {
|
|
41
|
+
// Single chokepoint for the runtime connection switch: while disarmed, no
|
|
42
|
+
// auto-connect path (boot, getStatus/reconcile self-heal, QR success,
|
|
43
|
+
// updateSettings) may start the poll loop.
|
|
44
|
+
if (!connectionEnabled) {
|
|
45
|
+
return;
|
|
46
|
+
}
|
|
37
47
|
if (isRunning()) {
|
|
38
48
|
return;
|
|
39
49
|
}
|
|
@@ -720,7 +730,7 @@ export function createGatewayService(deps) {
|
|
|
720
730
|
await enableGatewayTranslationRuntime();
|
|
721
731
|
}
|
|
722
732
|
await ensurePolling();
|
|
723
|
-
return deps.settings.expose(settings, isRunning());
|
|
733
|
+
return deps.settings.expose(settings, isRunning(), connectionEnabled);
|
|
724
734
|
},
|
|
725
735
|
async updateSettings(input) {
|
|
726
736
|
const gatewayStartInput = input.enabled === true
|
|
@@ -745,15 +755,31 @@ export function createGatewayService(deps) {
|
|
|
745
755
|
else {
|
|
746
756
|
await stopPolling();
|
|
747
757
|
}
|
|
748
|
-
return deps.settings.expose(settings, isRunning());
|
|
758
|
+
return deps.settings.expose(settings, isRunning(), connectionEnabled);
|
|
749
759
|
},
|
|
750
760
|
async resetBinding() {
|
|
751
761
|
await stopPolling();
|
|
762
|
+
// Disarm the connection switch on reset so no connection survives a
|
|
763
|
+
// binding reset.
|
|
764
|
+
connectionEnabled = false;
|
|
752
765
|
lastFailedTranslation = null;
|
|
753
766
|
qrLogin = null;
|
|
754
767
|
larkRegistrationState = null;
|
|
755
768
|
const settings = await deps.settings.resetBinding();
|
|
756
|
-
return deps.settings.expose(settings, isRunning());
|
|
769
|
+
return deps.settings.expose(settings, isRunning(), connectionEnabled);
|
|
770
|
+
},
|
|
771
|
+
async setConnectionEnabled(enabled) {
|
|
772
|
+
connectionEnabled = enabled;
|
|
773
|
+
// Armed → connect now if an account is configured; disarmed → abort the
|
|
774
|
+
// poll loop (the Lark WS closes via the abort path in waitForUpdates).
|
|
775
|
+
if (enabled) {
|
|
776
|
+
await ensurePolling();
|
|
777
|
+
}
|
|
778
|
+
else {
|
|
779
|
+
await stopPolling();
|
|
780
|
+
}
|
|
781
|
+
const settings = await deps.settings.loadSettings();
|
|
782
|
+
return deps.settings.expose(settings, isRunning(), connectionEnabled);
|
|
757
783
|
},
|
|
758
784
|
async startQrLogin() {
|
|
759
785
|
const settings = await deps.settings.loadSettings();
|
|
@@ -921,7 +947,7 @@ export function createGatewayService(deps) {
|
|
|
921
947
|
});
|
|
922
948
|
larkRegistrationState = null;
|
|
923
949
|
await ensurePolling();
|
|
924
|
-
const status = deps.settings.expose(settings, isRunning());
|
|
950
|
+
const status = deps.settings.expose(settings, isRunning(), connectionEnabled);
|
|
925
951
|
return {
|
|
926
952
|
status: "confirmed",
|
|
927
953
|
appIdConfigured: Boolean(result.appId),
|
|
@@ -985,7 +1011,7 @@ export function createGatewayService(deps) {
|
|
|
985
1011
|
});
|
|
986
1012
|
larkRegistrationState = null;
|
|
987
1013
|
await ensurePolling();
|
|
988
|
-
const status = deps.settings.expose(settings, isRunning());
|
|
1014
|
+
const status = deps.settings.expose(settings, isRunning(), connectionEnabled);
|
|
989
1015
|
return {
|
|
990
1016
|
status: "confirmed",
|
|
991
1017
|
appIdConfigured: true,
|
|
@@ -1013,7 +1039,9 @@ export function createGatewayService(deps) {
|
|
|
1013
1039
|
const settings = await deps.settings.loadSettings();
|
|
1014
1040
|
const account = toAccount(settings);
|
|
1015
1041
|
const boundUserId = settings.binding.boundUserId;
|
|
1016
|
-
|
|
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) {
|
|
1017
1045
|
return;
|
|
1018
1046
|
}
|
|
1019
1047
|
const cursorKey = `${input.taskSlug}:project-manager:${input.session.claudeSessionId}`;
|
|
@@ -79,11 +79,12 @@ export function createGatewaySettingsService(deps) {
|
|
|
79
79
|
updatedAt: now()
|
|
80
80
|
});
|
|
81
81
|
},
|
|
82
|
-
expose(settings, running = false) {
|
|
82
|
+
expose(settings, running = false, connectionEnabled = false) {
|
|
83
83
|
return {
|
|
84
84
|
version: 1,
|
|
85
85
|
enabled: settings.enabled,
|
|
86
86
|
running,
|
|
87
|
+
connectionEnabled,
|
|
87
88
|
channel: settings.channel,
|
|
88
89
|
translationEnabled: settings.translationEnabled,
|
|
89
90
|
currentProjectId: settings.currentProjectId,
|
package/dist/backend/server.js
CHANGED
|
@@ -31,9 +31,11 @@ import { createSessionService } from "./services/session-service.js";
|
|
|
31
31
|
import { createMessageService } from "./services/message-service.js";
|
|
32
32
|
import { createRoundService } from "./services/round-service.js";
|
|
33
33
|
import { createRuntimeCoordinatorService } from "./services/runtime-coordinator-service.js";
|
|
34
|
+
import { createRuntimeRecoveryService } from "./services/runtime-recovery-service.js";
|
|
34
35
|
import { createStatusService } from "./services/status-service.js";
|
|
35
36
|
import { createTaskService } from "./services/task-service.js";
|
|
36
37
|
import { createTaskLaunchService } from "./services/task-launch-service.js";
|
|
38
|
+
import { createTerminalInterruptService } from "./services/terminal-interrupt-service.js";
|
|
37
39
|
import { createTranslationService } from "./services/translation-service.js";
|
|
38
40
|
import { createDiagnosticsService } from "./services/diagnostics-service.js";
|
|
39
41
|
import { registerAppSettingsRoutes } from "./api/app-settings-routes.js";
|
|
@@ -86,7 +88,7 @@ export async function createServer(deps, options = {}) {
|
|
|
86
88
|
});
|
|
87
89
|
registerProjectRoutes(app, {
|
|
88
90
|
projectService: deps.projectService,
|
|
89
|
-
|
|
91
|
+
runtimeRecoveryService: deps.runtimeRecoveryService
|
|
90
92
|
});
|
|
91
93
|
registerHarnessRoutes(app, {
|
|
92
94
|
projectService: deps.projectService,
|
|
@@ -143,7 +145,10 @@ export async function createServer(deps, options = {}) {
|
|
|
143
145
|
translationService: deps.translationService
|
|
144
146
|
});
|
|
145
147
|
registerGatewayRoutes(app, { gatewayService: deps.gatewayService });
|
|
146
|
-
registerTerminalWs(app, {
|
|
148
|
+
registerTerminalWs(app, {
|
|
149
|
+
runtime: deps.runtime,
|
|
150
|
+
onManualInterrupt: (sessionId) => deps.terminalInterruptService.handleManualInterrupt(sessionId)
|
|
151
|
+
});
|
|
147
152
|
app.addHook("onReady", async () => {
|
|
148
153
|
await cleanupRecentTranslationRuntime(deps);
|
|
149
154
|
await deps.gatewayService.start();
|
|
@@ -314,6 +319,13 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
314
319
|
return (await projectService.loadConfig(repoRoot)).stateRoot;
|
|
315
320
|
}
|
|
316
321
|
});
|
|
322
|
+
const runtimeRecoveryService = createRuntimeRecoveryService({
|
|
323
|
+
fs,
|
|
324
|
+
runtime,
|
|
325
|
+
projectService,
|
|
326
|
+
taskService,
|
|
327
|
+
translationWorkerService
|
|
328
|
+
});
|
|
317
329
|
const claudeHookService = createClaudeHookService({
|
|
318
330
|
projectService,
|
|
319
331
|
taskService,
|
|
@@ -329,6 +341,13 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
329
341
|
jobGuard: createJobGuardService(),
|
|
330
342
|
translationWorkerService
|
|
331
343
|
});
|
|
344
|
+
const terminalInterruptService = createTerminalInterruptService({
|
|
345
|
+
runtime,
|
|
346
|
+
projectService,
|
|
347
|
+
taskService,
|
|
348
|
+
sessionService,
|
|
349
|
+
roundService
|
|
350
|
+
});
|
|
332
351
|
const diagnosticsService = createDiagnosticsService({
|
|
333
352
|
appRoot,
|
|
334
353
|
runtime,
|
|
@@ -354,6 +373,8 @@ export function createDefaultServerDeps(options = {}) {
|
|
|
354
373
|
translationService,
|
|
355
374
|
gatewayService,
|
|
356
375
|
runtimeCoordinator,
|
|
376
|
+
runtimeRecoveryService,
|
|
377
|
+
terminalInterruptService,
|
|
357
378
|
runtime,
|
|
358
379
|
diagnosticsService
|
|
359
380
|
};
|
|
@@ -374,6 +374,11 @@ export function createClaudeHookService(deps) {
|
|
|
374
374
|
}
|
|
375
375
|
const stateInput = createRoundStateInput(context);
|
|
376
376
|
const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
|
|
377
|
+
if (currentRoundState.stopReason === "manual-interrupt"
|
|
378
|
+
&& currentRoundState.status === "stopped"
|
|
379
|
+
&& currentRoundState.activeRole === input.role) {
|
|
380
|
+
return false;
|
|
381
|
+
}
|
|
377
382
|
const previousAttempt = currentRoundState.roleRecovery?.role === input.role &&
|
|
378
383
|
currentRoundState.roleRecovery.status !== "failed"
|
|
379
384
|
? currentRoundState.roleRecovery.attempt
|
|
@@ -246,6 +246,25 @@ export function createRoundService(deps) {
|
|
|
246
246
|
recordClaudeHookEvent(input) {
|
|
247
247
|
return recordRoleTurnEvent(input);
|
|
248
248
|
},
|
|
249
|
+
async recordManualInterrupt(input) {
|
|
250
|
+
return withTaskLock(input, async () => {
|
|
251
|
+
const timestamp = now();
|
|
252
|
+
const state = await load(input);
|
|
253
|
+
const next = applyManualInterrupt({
|
|
254
|
+
state,
|
|
255
|
+
taskSlug: input.taskSlug,
|
|
256
|
+
role: input.role,
|
|
257
|
+
timestamp
|
|
258
|
+
});
|
|
259
|
+
if (!manualInterruptWasApplied(state, next, input.role)) {
|
|
260
|
+
return toSessionRoundState(state, timestamp);
|
|
261
|
+
}
|
|
262
|
+
await save(input, next);
|
|
263
|
+
clearSettleTimer(input);
|
|
264
|
+
await updateSessionStatus(input, next.currentRound?.status === "running" ? "running" : "stopped");
|
|
265
|
+
return toSessionRoundState(next, timestamp);
|
|
266
|
+
});
|
|
267
|
+
},
|
|
249
268
|
async setRoleRecovery(input) {
|
|
250
269
|
return withTaskLock(input, async () => {
|
|
251
270
|
const timestamp = now();
|
|
@@ -327,6 +346,7 @@ function applyPromptSubmitted(input) {
|
|
|
327
346
|
lastTurnStartedAt: input.timestamp,
|
|
328
347
|
settleDeadlineAt: undefined,
|
|
329
348
|
stoppedAt: undefined,
|
|
349
|
+
stopReason: undefined,
|
|
330
350
|
activeTurnStartedAt: current.activeTurnStartedAt ?? input.timestamp,
|
|
331
351
|
turnCount: current.turnCount + 1,
|
|
332
352
|
roles: appendUniqueRole(current.roles, input.role)
|
|
@@ -360,6 +380,7 @@ function applyStop(input) {
|
|
|
360
380
|
activeRole: input.role,
|
|
361
381
|
lastTurnEndedAt: input.timestamp,
|
|
362
382
|
settleDeadlineAt: addMilliseconds(input.timestamp, input.settleMs),
|
|
383
|
+
stopReason: undefined,
|
|
363
384
|
activeTurnStartedAt: undefined,
|
|
364
385
|
ccActiveMs: current.ccActiveMs + activeDurationMs,
|
|
365
386
|
completedTurnCount: current.completedTurnCount + 1,
|
|
@@ -374,6 +395,47 @@ function applyStop(input) {
|
|
|
374
395
|
updatedAt: input.timestamp
|
|
375
396
|
};
|
|
376
397
|
}
|
|
398
|
+
function applyManualInterrupt(input) {
|
|
399
|
+
const current = input.state.currentRound;
|
|
400
|
+
if (!current || current.status === "stopped" || !current.activeTurnStartedAt || current.activeRole !== input.role) {
|
|
401
|
+
return {
|
|
402
|
+
...input.state,
|
|
403
|
+
taskSlug: input.taskSlug,
|
|
404
|
+
updatedAt: input.timestamp
|
|
405
|
+
};
|
|
406
|
+
}
|
|
407
|
+
const activeDurationMs = getDurationMs(current.activeTurnStartedAt, input.timestamp);
|
|
408
|
+
const stopped = {
|
|
409
|
+
...current,
|
|
410
|
+
status: "stopped",
|
|
411
|
+
activeRole: input.role,
|
|
412
|
+
lastTurnEndedAt: input.timestamp,
|
|
413
|
+
stoppedAt: input.timestamp,
|
|
414
|
+
stopReason: "manual-interrupt",
|
|
415
|
+
settleDeadlineAt: undefined,
|
|
416
|
+
activeTurnStartedAt: undefined,
|
|
417
|
+
ccActiveMs: current.ccActiveMs + activeDurationMs,
|
|
418
|
+
completedTurnCount: current.completedTurnCount + 1,
|
|
419
|
+
roles: appendUniqueRole(current.roles, input.role)
|
|
420
|
+
};
|
|
421
|
+
return {
|
|
422
|
+
...input.state,
|
|
423
|
+
taskSlug: input.taskSlug,
|
|
424
|
+
currentRound: stopped,
|
|
425
|
+
lastStoppedRound: stopped,
|
|
426
|
+
totalCompletedTurnCount: input.state.totalCompletedTurnCount + 1,
|
|
427
|
+
totalCcActiveMs: input.state.totalCcActiveMs + activeDurationMs,
|
|
428
|
+
pendingUserReply: undefined,
|
|
429
|
+
updatedAt: input.timestamp
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
function manualInterruptWasApplied(previous, next, role) {
|
|
433
|
+
return Boolean(previous.currentRound?.status === "running"
|
|
434
|
+
&& previous.currentRound.activeTurnStartedAt
|
|
435
|
+
&& previous.currentRound.activeRole === role
|
|
436
|
+
&& next.currentRound?.stopReason === "manual-interrupt"
|
|
437
|
+
&& next.currentRound.status === "stopped");
|
|
438
|
+
}
|
|
377
439
|
function toSessionRoundState(state, updatedAt) {
|
|
378
440
|
const current = state.currentRound;
|
|
379
441
|
if (!current) {
|
|
@@ -407,6 +469,7 @@ function toSessionRoundState(state, updatedAt) {
|
|
|
407
469
|
lastTurnEndedAt: current.lastTurnEndedAt,
|
|
408
470
|
settleDeadlineAt: current.settleDeadlineAt,
|
|
409
471
|
stoppedAt: current.stoppedAt,
|
|
472
|
+
stopReason: current.stopReason,
|
|
410
473
|
activeTurnStartedAt: current.activeTurnStartedAt,
|
|
411
474
|
roundSequence: current.sequence,
|
|
412
475
|
turnCount: current.turnCount,
|
|
@@ -439,8 +502,10 @@ function computeFlowPause(current, roleRecovery, awaitingUser) {
|
|
|
439
502
|
return undefined;
|
|
440
503
|
}
|
|
441
504
|
const roundStopped = Boolean(current) && current.status === "stopped" && Boolean(current.id);
|
|
505
|
+
const nonAlertingStop = roundStopped && (current.stopReason === "manual-interrupt" ||
|
|
506
|
+
current.stopReason === "runtime-recovery");
|
|
442
507
|
if (roleRecovery?.status === "failed") {
|
|
443
|
-
return roundStopped
|
|
508
|
+
return roundStopped && !nonAlertingStop
|
|
444
509
|
? {
|
|
445
510
|
paused: true,
|
|
446
511
|
reason: "role-recovery-failed",
|
|
@@ -459,7 +524,7 @@ function computeFlowPause(current, roleRecovery, awaitingUser) {
|
|
|
459
524
|
messageTruncated: awaitingUser.messageTruncated
|
|
460
525
|
};
|
|
461
526
|
}
|
|
462
|
-
if (roundStopped) {
|
|
527
|
+
if (roundStopped && !nonAlertingStop) {
|
|
463
528
|
return {
|
|
464
529
|
paused: true,
|
|
465
530
|
reason: "stopped-no-next-turn",
|
|
@@ -633,6 +698,9 @@ function normalizeRound(input) {
|
|
|
633
698
|
: typeof legacy.pausedAt === "string"
|
|
634
699
|
? legacy.pausedAt
|
|
635
700
|
: undefined,
|
|
701
|
+
stopReason: input.stopReason === "manual-interrupt" || input.stopReason === "runtime-recovery"
|
|
702
|
+
? input.stopReason
|
|
703
|
+
: undefined,
|
|
636
704
|
activeTurnStartedAt: typeof input.activeTurnStartedAt === "string"
|
|
637
705
|
? input.activeTurnStartedAt
|
|
638
706
|
: typeof legacy.runningSince === "string"
|
|
@@ -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
|
+
}
|