vibe-coding-master 0.4.19 → 0.4.20
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/adapters/git-adapter.js +15 -0
- package/dist/backend/api/harness-routes.js +17 -0
- package/dist/backend/api/task-routes.js +2 -2
- package/dist/backend/gateway/gateway-service.js +2 -5
- package/dist/backend/services/app-settings-service.js +2 -0
- package/dist/backend/services/claude-hook-service.js +188 -36
- package/dist/backend/services/harness-feedback-service.js +149 -2
- package/dist/backend/services/harness-service.js +88 -0
- package/dist/backend/services/round-service.js +58 -0
- package/dist/backend/services/session-service.js +6 -301
- package/dist/backend/templates/harness/architect-agent.js +37 -3
- package/dist/backend/templates/harness/coder-agent.js +6 -1
- package/dist/backend/templates/harness/harness-engineer-agent.js +31 -0
- package/dist/backend/templates/harness/project-manager-agent.js +7 -0
- package/dist/backend/templates/harness/reviewer-agent.js +10 -0
- package/dist-frontend/assets/index-B3sODrcw.css +32 -0
- package/dist-frontend/assets/index-_aOTGZCq.js +96 -0
- package/dist-frontend/index.html +2 -2
- package/docs/full-harness-baseline.md +1 -2
- package/docs/gate-review-gates.md +8 -17
- package/docs/product-design.md +5 -6
- package/docs/v0.4-harness-optimization-plan.md +3 -2
- package/package.json +1 -1
- package/dist-frontend/assets/index-BrY-xd6U.js +0 -95
- package/dist-frontend/assets/index-D1LTJ-sY.css +0 -32
|
@@ -201,6 +201,21 @@ export function createGitAdapter(runner) {
|
|
|
201
201
|
hint: result.stderr
|
|
202
202
|
});
|
|
203
203
|
},
|
|
204
|
+
async mergeBranchFastForward(repoRoot, branch) {
|
|
205
|
+
const result = await runGit(runner, repoRoot, ["merge", "--ff-only", branch]);
|
|
206
|
+
if (result.exitCode !== 0) {
|
|
207
|
+
throw new VcmError({
|
|
208
|
+
code: "GIT_MERGE_FAILED",
|
|
209
|
+
message: `Unable to fast-forward merge branch: ${branch}`,
|
|
210
|
+
statusCode: 409,
|
|
211
|
+
hint: result.stderr || result.stdout || "Rebase the task branch onto the connected repository branch, then try again."
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
return {
|
|
215
|
+
stdout: result.stdout,
|
|
216
|
+
stderr: result.stderr
|
|
217
|
+
};
|
|
218
|
+
},
|
|
204
219
|
async addPaths(repoRoot, paths) {
|
|
205
220
|
if (paths.length === 0) {
|
|
206
221
|
return;
|
|
@@ -38,6 +38,13 @@ export function registerHarnessRoutes(app, deps) {
|
|
|
38
38
|
commitSha: request.query.commit
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
|
+
app.post("/api/projects/harness/repository-diff/merge-to-current-branch", async (request) => {
|
|
42
|
+
const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
|
|
43
|
+
return deps.harnessService.mergeRepositoryDiffToCurrentBranch(project.repoRoot, {
|
|
44
|
+
taskRepoRoot: task.worktreePath,
|
|
45
|
+
taskBranch: task.branch
|
|
46
|
+
});
|
|
47
|
+
});
|
|
41
48
|
app.get("/api/projects/harness/bootstrap", async (request) => {
|
|
42
49
|
const { project, task } = await requireHarnessTaskContext(deps, request.query.taskSlug);
|
|
43
50
|
try {
|
|
@@ -119,6 +126,16 @@ export function registerHarnessRoutes(app, deps) {
|
|
|
119
126
|
comment: typeof request.body?.comment === "string" ? request.body.comment : undefined
|
|
120
127
|
});
|
|
121
128
|
});
|
|
129
|
+
app.post("/api/projects/harness/task-retrospective", async (request) => {
|
|
130
|
+
const { project, task } = await requireHarnessTaskContext(deps, request.body?.taskSlug);
|
|
131
|
+
const trigger = request.body?.trigger === "auto" ? "auto" : "manual";
|
|
132
|
+
return deps.harnessFeedbackService.startTaskRetrospective(project.repoRoot, {
|
|
133
|
+
taskSlug: task.taskSlug,
|
|
134
|
+
taskRepoRoot: task.worktreePath,
|
|
135
|
+
handoffDir: task.handoffDir,
|
|
136
|
+
trigger
|
|
137
|
+
});
|
|
138
|
+
});
|
|
122
139
|
}
|
|
123
140
|
function degradedHarnessStatus(error) {
|
|
124
141
|
return {
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { DISPATCHABLE_ROLES, VCM_ROLE_NAMES } from "../../shared/constants.js";
|
|
2
2
|
import { isOpenFileLimitError, VcmError } from "../errors.js";
|
|
3
3
|
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
4
4
|
export function registerTaskRoutes(app, deps) {
|
|
@@ -41,7 +41,7 @@ export function registerTaskRoutes(app, deps) {
|
|
|
41
41
|
async function stopRunningRoleSessions(deps, repoRoot, taskSlug) {
|
|
42
42
|
const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|
|
43
43
|
for (const session of sessions) {
|
|
44
|
-
if (session.status === "running" &&
|
|
44
|
+
if (session.status === "running" && VCM_ROLE_NAMES.some((role) => role === session.role)) {
|
|
45
45
|
await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
|
|
46
46
|
}
|
|
47
47
|
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
-
import { CORE_VCM_ROLE_DEFINITIONS,
|
|
2
|
+
import { CORE_VCM_ROLE_DEFINITIONS, GATE_REVIEWER_ROLE_DEFINITION, VCM_ROLE_NAMES } from "../../shared/constants.js";
|
|
3
3
|
import { VcmError } from "../errors.js";
|
|
4
4
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
5
5
|
import { getTaskRuntimeRepoRoot } from "../services/task-service.js";
|
|
@@ -425,9 +425,6 @@ export function createGatewayService(deps) {
|
|
|
425
425
|
};
|
|
426
426
|
const existing = await deps.sessionService.getRoleSession(project.repoRoot, task.taskSlug, definition.name);
|
|
427
427
|
if (existing?.status === "running") {
|
|
428
|
-
if (definition.name === "gate-reviewer") {
|
|
429
|
-
await deps.sessionService.resumeRoleSession(project.repoRoot, task.taskSlug, definition.name, sessionInput);
|
|
430
|
-
}
|
|
431
428
|
startedRoles.push(definition.name);
|
|
432
429
|
continue;
|
|
433
430
|
}
|
|
@@ -556,7 +553,7 @@ export function createGatewayService(deps) {
|
|
|
556
553
|
async function stopRunningRoleSessions(repoRoot, taskSlug) {
|
|
557
554
|
const sessions = await deps.sessionService.listRoleSessions(repoRoot, taskSlug);
|
|
558
555
|
for (const session of sessions) {
|
|
559
|
-
if (session.status === "running" &&
|
|
556
|
+
if (session.status === "running" && VCM_ROLE_NAMES.some((role) => role === session.role)) {
|
|
560
557
|
await deps.sessionService.stopRoleSession(repoRoot, taskSlug, session.role);
|
|
561
558
|
}
|
|
562
559
|
}
|
|
@@ -230,7 +230,9 @@ function normalizePreferences(input) {
|
|
|
230
230
|
return {
|
|
231
231
|
themeMode: normalizeThemeMode(candidate.themeMode),
|
|
232
232
|
flowPauseAlerts: rawFlowPauseAlerts !== false,
|
|
233
|
+
roleRetryEnabled: candidate.roleRetryEnabled !== false,
|
|
233
234
|
permissionRequestMode: normalizePermissionRequestMode(candidate.permissionRequestMode),
|
|
235
|
+
autoTaskHarnessReviewEnabled: candidate.autoTaskHarnessReviewEnabled === true,
|
|
234
236
|
translationEnabled: candidate.translationEnabled === true,
|
|
235
237
|
translationAutoSendEnabled: candidate.translationAutoSendEnabled === true,
|
|
236
238
|
translationTargetLanguage: normalizeTranslationTargetLanguage(candidate.translationTargetLanguage),
|
|
@@ -2,9 +2,22 @@ import { isGateReviewerRoleName, isHarnessEngineerToolRoleName, isTranslatorTool
|
|
|
2
2
|
import { VcmError } from "../errors.js";
|
|
3
3
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
4
4
|
import { getTaskRuntimeRepoRoot } from "./task-service.js";
|
|
5
|
-
const
|
|
5
|
+
const MAX_ROLE_RETRY_ATTEMPTS = 20;
|
|
6
|
+
const ROLE_RETRY_BASE_DELAY_MS = 60_000;
|
|
7
|
+
const NON_RETRYABLE_STOP_FAILURE_ERRORS = new Set([
|
|
8
|
+
"authentication_failed",
|
|
9
|
+
"oauth_org_not_allowed",
|
|
10
|
+
"billing_error",
|
|
11
|
+
"invalid_request",
|
|
12
|
+
"model_not_found",
|
|
13
|
+
"max_output_tokens"
|
|
14
|
+
]);
|
|
15
|
+
const DIAGNOSTIC_SNIPPET_MAX_LENGTH = 2000;
|
|
6
16
|
export function createClaudeHookService(deps) {
|
|
7
|
-
const
|
|
17
|
+
const stopFailureRetryTimers = new Map();
|
|
18
|
+
const now = deps.now ?? (() => new Date().toISOString());
|
|
19
|
+
const retrySetTimeout = deps.retrySetTimeout ?? ((callback, delayMs) => globalThis.setTimeout(callback, delayMs));
|
|
20
|
+
const retryClearTimeout = deps.retryClearTimeout ?? ((timer) => globalThis.clearTimeout(timer));
|
|
8
21
|
async function getHookContext(input) {
|
|
9
22
|
if (!isVcmRoleName(input.role)) {
|
|
10
23
|
throw new VcmError({
|
|
@@ -99,19 +112,8 @@ export function createClaudeHookService(deps) {
|
|
|
99
112
|
};
|
|
100
113
|
}
|
|
101
114
|
async function resolveHookTaskSlug(repoRoot, input) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
}
|
|
105
|
-
const session = await deps.sessionService.getProjectGateReviewerSession(repoRoot);
|
|
106
|
-
if (session?.activeTaskSlug) {
|
|
107
|
-
return session.activeTaskSlug;
|
|
108
|
-
}
|
|
109
|
-
throw new VcmError({
|
|
110
|
-
code: "GATE_REVIEWER_TASK_UNBOUND",
|
|
111
|
-
message: "Gate Reviewer hook arrived without an active task binding.",
|
|
112
|
-
statusCode: 409,
|
|
113
|
-
hint: "Start or resume Gate Reviewer from the current task before submitting work."
|
|
114
|
-
});
|
|
115
|
+
void repoRoot;
|
|
116
|
+
return input.taskSlug;
|
|
115
117
|
}
|
|
116
118
|
async function handleUserPromptSubmitHook(input) {
|
|
117
119
|
const eventName = parseHookEvent(input.event.hook_event_name);
|
|
@@ -177,7 +179,7 @@ export function createClaudeHookService(deps) {
|
|
|
177
179
|
throwUnsupportedEvent(eventName);
|
|
178
180
|
}
|
|
179
181
|
const context = await getHookContext(input);
|
|
180
|
-
|
|
182
|
+
await clearStopFailureRecoveryState(context, input.role);
|
|
181
183
|
if (options.allowBlock && deps.jobGuard) {
|
|
182
184
|
const verdict = await deps.jobGuard.evaluateStop({
|
|
183
185
|
repoRoot: context.project.repoRoot,
|
|
@@ -215,15 +217,24 @@ export function createClaudeHookService(deps) {
|
|
|
215
217
|
const pending = await deps.messageService.listPendingRouteFiles(routeDispatchInput);
|
|
216
218
|
const hasCompletionEvidence = pending.some((routeFile) => routeFile.fromRole === input.role);
|
|
217
219
|
if (hasCompletionEvidence) {
|
|
218
|
-
|
|
220
|
+
await clearStopFailureRecoveryState(context, input.role);
|
|
219
221
|
return recordTurnEnd(input, context, eventName, {
|
|
220
222
|
dispatchRouteFiles: !isGateReviewerRoleName(input.role),
|
|
221
223
|
notifyGateway: false,
|
|
222
224
|
settleGuard: true
|
|
223
225
|
});
|
|
224
226
|
}
|
|
225
|
-
const
|
|
226
|
-
if (
|
|
227
|
+
const failure = parseStopFailureDiagnostic(input.event);
|
|
228
|
+
if (!failure.retryable) {
|
|
229
|
+
await markStopFailureRecoveryFailed(input, context, failure, 0);
|
|
230
|
+
return recordTurnEnd(input, context, eventName, {
|
|
231
|
+
dispatchRouteFiles: false,
|
|
232
|
+
notifyGateway: false,
|
|
233
|
+
settleGuard: false
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
const retryScheduled = await scheduleStopFailureRetry(input, context, failure);
|
|
237
|
+
if (retryScheduled) {
|
|
227
238
|
return {
|
|
228
239
|
ok: true,
|
|
229
240
|
eventName,
|
|
@@ -336,38 +347,147 @@ export function createClaudeHookService(deps) {
|
|
|
336
347
|
...(stoppedRole ? { stoppedRole } : {})
|
|
337
348
|
};
|
|
338
349
|
}
|
|
339
|
-
async function
|
|
340
|
-
|
|
350
|
+
async function scheduleStopFailureRetry(input, context, failure) {
|
|
351
|
+
const preferences = await deps.appSettings.getPreferences();
|
|
352
|
+
if (!preferences.roleRetryEnabled || !deps.runtime) {
|
|
341
353
|
return false;
|
|
342
354
|
}
|
|
343
|
-
const
|
|
344
|
-
const
|
|
345
|
-
|
|
355
|
+
const stateInput = createRoundStateInput(context);
|
|
356
|
+
const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
|
|
357
|
+
const previousAttempt = currentRoundState.roleRecovery?.role === input.role &&
|
|
358
|
+
currentRoundState.roleRecovery.status !== "failed"
|
|
359
|
+
? currentRoundState.roleRecovery.attempt
|
|
360
|
+
: 0;
|
|
361
|
+
const attempt = previousAttempt + 1;
|
|
362
|
+
const timestamp = now();
|
|
363
|
+
if (attempt > MAX_ROLE_RETRY_ATTEMPTS) {
|
|
364
|
+
clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
|
|
365
|
+
await markStopFailureRecoveryFailed(input, context, failure, MAX_ROLE_RETRY_ATTEMPTS, timestamp);
|
|
346
366
|
return false;
|
|
347
367
|
}
|
|
368
|
+
const nextRetryAt = new Date(Date.parse(timestamp) + attempt * ROLE_RETRY_BASE_DELAY_MS).toISOString();
|
|
369
|
+
await deps.roundService.setRoleRecovery({
|
|
370
|
+
...stateInput,
|
|
371
|
+
recovery: {
|
|
372
|
+
role: input.role,
|
|
373
|
+
status: "waiting",
|
|
374
|
+
attempt,
|
|
375
|
+
maxAttempts: MAX_ROLE_RETRY_ATTEMPTS,
|
|
376
|
+
lastFailureAt: timestamp,
|
|
377
|
+
error: failure.error,
|
|
378
|
+
errorDetails: failure.errorDetails,
|
|
379
|
+
lastAssistantMessage: failure.lastAssistantMessage,
|
|
380
|
+
retryable: failure.retryable,
|
|
381
|
+
nextRetryAt
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
|
|
385
|
+
scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt);
|
|
386
|
+
return true;
|
|
387
|
+
}
|
|
388
|
+
function scheduleStopFailureRetryTimer(input, context, attempt, nextRetryAt) {
|
|
389
|
+
const key = stopFailureRecoveryKey(context.project.repoRoot, context.taskSlug, input.role);
|
|
390
|
+
clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
|
|
391
|
+
const delayMs = Math.max(0, Date.parse(nextRetryAt) - Date.parse(now()));
|
|
392
|
+
const timer = retrySetTimeout(() => {
|
|
393
|
+
stopFailureRetryTimers.delete(key);
|
|
394
|
+
void runScheduledStopFailureRetry(input, context, attempt).catch(() => undefined);
|
|
395
|
+
}, delayMs);
|
|
396
|
+
stopFailureRetryTimers.set(key, timer);
|
|
397
|
+
}
|
|
398
|
+
async function runScheduledStopFailureRetry(input, context, attempt) {
|
|
399
|
+
const stateInput = createRoundStateInput(context);
|
|
400
|
+
const currentRoundState = await deps.roundService.getSessionRoundState(stateInput);
|
|
401
|
+
const recovery = currentRoundState.roleRecovery;
|
|
402
|
+
if (!recovery || recovery.role !== input.role || recovery.attempt !== attempt || recovery.status !== "waiting") {
|
|
403
|
+
return;
|
|
404
|
+
}
|
|
405
|
+
const timestamp = now();
|
|
348
406
|
const session = await deps.sessionService.getRoleSession(context.project.repoRoot, context.taskSlug, input.role);
|
|
349
|
-
if (!session || session.status !== "running") {
|
|
350
|
-
|
|
407
|
+
if (!session || session.status !== "running" || !deps.runtime) {
|
|
408
|
+
await deps.roundService.setRoleRecovery({
|
|
409
|
+
...stateInput,
|
|
410
|
+
recovery: {
|
|
411
|
+
...recovery,
|
|
412
|
+
status: "failed",
|
|
413
|
+
failedAt: timestamp
|
|
414
|
+
}
|
|
415
|
+
});
|
|
416
|
+
await recordTurnEnd(input, context, "StopFailure", {
|
|
417
|
+
dispatchRouteFiles: false,
|
|
418
|
+
notifyGateway: false,
|
|
419
|
+
settleGuard: false
|
|
420
|
+
});
|
|
421
|
+
return;
|
|
351
422
|
}
|
|
352
|
-
|
|
353
|
-
|
|
423
|
+
await deps.roundService.setRoleRecovery({
|
|
424
|
+
...stateInput,
|
|
425
|
+
recovery: {
|
|
426
|
+
...recovery,
|
|
427
|
+
status: "retrying",
|
|
428
|
+
nextRetryAt: undefined,
|
|
429
|
+
lastRetryAt: timestamp
|
|
430
|
+
}
|
|
431
|
+
});
|
|
432
|
+
await submitTerminalInput(deps.runtime, session.id, renderStopFailureRecoveryPrompt());
|
|
354
433
|
await deps.sessionService.markRoleActivityRunning(context.project.repoRoot, context.taskSlug, input.role);
|
|
355
|
-
|
|
434
|
+
}
|
|
435
|
+
async function markStopFailureRecoveryFailed(input, context, failure, attempt, timestamp = now()) {
|
|
436
|
+
clearStopFailureRetryTimer(context.project.repoRoot, context.taskSlug, input.role);
|
|
437
|
+
await deps.roundService.setRoleRecovery({
|
|
438
|
+
...createRoundStateInput(context),
|
|
439
|
+
recovery: {
|
|
440
|
+
role: input.role,
|
|
441
|
+
status: "failed",
|
|
442
|
+
attempt,
|
|
443
|
+
maxAttempts: MAX_ROLE_RETRY_ATTEMPTS,
|
|
444
|
+
lastFailureAt: timestamp,
|
|
445
|
+
error: failure.error,
|
|
446
|
+
errorDetails: failure.errorDetails,
|
|
447
|
+
lastAssistantMessage: failure.lastAssistantMessage,
|
|
448
|
+
retryable: failure.retryable,
|
|
449
|
+
failedAt: timestamp
|
|
450
|
+
}
|
|
451
|
+
});
|
|
356
452
|
}
|
|
357
453
|
function clearStopFailureRecovery(repoRoot, taskSlug, role) {
|
|
358
|
-
|
|
454
|
+
clearStopFailureRetryTimer(repoRoot, taskSlug, role);
|
|
455
|
+
}
|
|
456
|
+
async function clearStopFailureRecoveryState(context, role) {
|
|
457
|
+
clearStopFailureRecovery(context.project.repoRoot, context.taskSlug, role);
|
|
458
|
+
await deps.roundService.clearRoleRecovery?.({
|
|
459
|
+
...createRoundStateInput(context),
|
|
460
|
+
role
|
|
461
|
+
});
|
|
462
|
+
}
|
|
463
|
+
function clearStopFailureRetryTimer(repoRoot, taskSlug, role) {
|
|
464
|
+
const key = stopFailureRecoveryKey(repoRoot, taskSlug, role);
|
|
465
|
+
const timer = stopFailureRetryTimers.get(key);
|
|
466
|
+
if (timer === undefined) {
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
retryClearTimeout(timer);
|
|
470
|
+
stopFailureRetryTimers.delete(key);
|
|
359
471
|
}
|
|
360
472
|
function stopFailureRecoveryKey(repoRoot, taskSlug, role) {
|
|
361
473
|
return `${repoRoot}:${taskSlug}:${role}`;
|
|
362
474
|
}
|
|
363
|
-
function
|
|
475
|
+
function createRoundStateInput(context) {
|
|
476
|
+
return {
|
|
477
|
+
repoRoot: context.project.repoRoot,
|
|
478
|
+
stateRepoRoot: context.taskRepoRoot,
|
|
479
|
+
stateRoot: context.config.stateRoot,
|
|
480
|
+
taskSlug: context.taskSlug
|
|
481
|
+
};
|
|
482
|
+
}
|
|
483
|
+
function renderStopFailureRecoveryPrompt() {
|
|
364
484
|
return [
|
|
365
485
|
"[VCM Recovery]",
|
|
366
|
-
"
|
|
367
|
-
"
|
|
368
|
-
"
|
|
369
|
-
"If
|
|
370
|
-
|
|
486
|
+
"Previous turn ended unexpectedly. Continue from current repo + VCM handoff state.",
|
|
487
|
+
"",
|
|
488
|
+
"Check whether your assigned work is already complete.",
|
|
489
|
+
"If complete, write the expected VCM completion artifact now.",
|
|
490
|
+
"Do not repeat completed edits, validation, or route messages."
|
|
371
491
|
].join("\n");
|
|
372
492
|
}
|
|
373
493
|
async function handlePermissionRequestHook(input) {
|
|
@@ -470,6 +590,38 @@ function throwUnsupportedEvent(eventName) {
|
|
|
470
590
|
hint: "Use the matching VCM hook endpoint for this event."
|
|
471
591
|
});
|
|
472
592
|
}
|
|
593
|
+
function parseStopFailureDiagnostic(event) {
|
|
594
|
+
const error = (normalizeDiagnosticString(event.error, 200) ?? "unknown").toLowerCase();
|
|
595
|
+
const errorDetails = normalizeDiagnosticString(event.error_details, DIAGNOSTIC_SNIPPET_MAX_LENGTH);
|
|
596
|
+
const lastAssistantMessage = normalizeDiagnosticString(event.last_assistant_message, DIAGNOSTIC_SNIPPET_MAX_LENGTH);
|
|
597
|
+
return {
|
|
598
|
+
error,
|
|
599
|
+
errorDetails,
|
|
600
|
+
lastAssistantMessage,
|
|
601
|
+
retryable: !NON_RETRYABLE_STOP_FAILURE_ERRORS.has(error)
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
function normalizeDiagnosticString(value, maxLength) {
|
|
605
|
+
let text;
|
|
606
|
+
if (typeof value === "string") {
|
|
607
|
+
text = value;
|
|
608
|
+
}
|
|
609
|
+
else if (value !== undefined && value !== null) {
|
|
610
|
+
try {
|
|
611
|
+
text = JSON.stringify(value);
|
|
612
|
+
}
|
|
613
|
+
catch {
|
|
614
|
+
text = String(value);
|
|
615
|
+
}
|
|
616
|
+
}
|
|
617
|
+
const trimmed = text?.trim();
|
|
618
|
+
if (!trimmed) {
|
|
619
|
+
return undefined;
|
|
620
|
+
}
|
|
621
|
+
return trimmed.length > maxLength
|
|
622
|
+
? `${trimmed.slice(0, maxLength)}...`
|
|
623
|
+
: trimmed;
|
|
624
|
+
}
|
|
473
625
|
function stringOrUndefined(value) {
|
|
474
626
|
return typeof value === "string" ? value : undefined;
|
|
475
627
|
}
|
|
@@ -1,4 +1,6 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
1
2
|
import path from "node:path";
|
|
3
|
+
import { checkMarkdownArtifact } from "../../shared/validation/artifact-check.js";
|
|
2
4
|
import { resolveRepoPath, toRepoRelativePath } from "../adapters/filesystem.js";
|
|
3
5
|
import { VcmError } from "../errors.js";
|
|
4
6
|
import { submitTerminalInput } from "../runtime/terminal-submit.js";
|
|
@@ -6,6 +8,7 @@ const FEEDBACK_ROOT = ".ai/vcm/harness-feedback";
|
|
|
6
8
|
const PENDING_DIR = `${FEEDBACK_ROOT}/pending`;
|
|
7
9
|
const ACTIVE_DIR = `${FEEDBACK_ROOT}/active`;
|
|
8
10
|
const COMPLETED_DIR = `${FEEDBACK_ROOT}/completed`;
|
|
11
|
+
const TASK_RETROSPECTIVE_DIR = `${FEEDBACK_ROOT}/task-retrospectives`;
|
|
9
12
|
const STATE_PATH = `${FEEDBACK_ROOT}/state.json`;
|
|
10
13
|
export function createHarnessFeedbackService(deps) {
|
|
11
14
|
const now = deps.now ?? (() => new Date().toISOString());
|
|
@@ -13,6 +16,80 @@ export function createHarnessFeedbackService(deps) {
|
|
|
13
16
|
await maybeDispatchNext(repoRoot, activeTaskSlug);
|
|
14
17
|
return buildStateReport(repoRoot);
|
|
15
18
|
}
|
|
19
|
+
async function startTaskRetrospective(repoRoot, input) {
|
|
20
|
+
const taskSlug = input.taskSlug.trim();
|
|
21
|
+
if (!taskSlug) {
|
|
22
|
+
throw new VcmError({
|
|
23
|
+
code: "HARNESS_TASK_REQUIRED",
|
|
24
|
+
message: "Select an active task before reviewing task harness.",
|
|
25
|
+
statusCode: 409
|
|
26
|
+
});
|
|
27
|
+
}
|
|
28
|
+
const state = await loadStoredState(repoRoot);
|
|
29
|
+
if (state) {
|
|
30
|
+
throw new VcmError({
|
|
31
|
+
code: "HARNESS_FEEDBACK_ACTIVE",
|
|
32
|
+
message: "Harness feedback is already active.",
|
|
33
|
+
statusCode: 409,
|
|
34
|
+
hint: "Review, approve, comment, or reject the current Harness feedback before starting Task Harness Retrospective."
|
|
35
|
+
});
|
|
36
|
+
}
|
|
37
|
+
const existingMarker = await loadTaskRetrospectiveMarker(repoRoot, taskSlug);
|
|
38
|
+
if (existingMarker) {
|
|
39
|
+
throw new VcmError({
|
|
40
|
+
code: "TASK_HARNESS_RETROSPECTIVE_EXISTS",
|
|
41
|
+
message: `Task Harness Retrospective has already been triggered for task: ${taskSlug}`,
|
|
42
|
+
statusCode: 409,
|
|
43
|
+
hint: "Review the existing Harness feedback item instead of starting another retrospective for the same task."
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
const finalAcceptancePath = path.posix.join(input.handoffDir, "final-acceptance.md");
|
|
47
|
+
const finalAcceptanceAbsolutePath = resolveRepoPath(input.taskRepoRoot, finalAcceptancePath);
|
|
48
|
+
const finalAcceptanceContent = await readAbsoluteOptionalText(finalAcceptanceAbsolutePath);
|
|
49
|
+
const finalAcceptanceCheck = checkMarkdownArtifact("final-acceptance", finalAcceptancePath, finalAcceptanceContent ?? null);
|
|
50
|
+
if (finalAcceptanceCheck.status !== "ok" || !finalAcceptanceContent) {
|
|
51
|
+
throw new VcmError({
|
|
52
|
+
code: "TASK_FINAL_ACCEPTANCE_NOT_READY",
|
|
53
|
+
message: "Task final acceptance is not complete yet.",
|
|
54
|
+
statusCode: 409,
|
|
55
|
+
hint: `${finalAcceptancePath} must pass the final-acceptance artifact check before Task Harness Retrospective can start.`
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
const session = await ensureIdleHarnessEngineer(repoRoot, taskSlug);
|
|
59
|
+
const timestamp = now();
|
|
60
|
+
const finalAcceptanceHash = `sha256:${sha256(finalAcceptanceContent)}`;
|
|
61
|
+
const id = sanitizeFeedbackId(`${timestamp}-task-retrospective-${taskSlug}`);
|
|
62
|
+
const feedbackPath = `${ACTIVE_DIR}/${id}/feedback.md`;
|
|
63
|
+
const analysisPath = `${ACTIVE_DIR}/${id}/analysis.md`;
|
|
64
|
+
const applyReportPath = `${ACTIVE_DIR}/${id}/apply-report.md`;
|
|
65
|
+
const active = {
|
|
66
|
+
id,
|
|
67
|
+
title: `Task Harness Retrospective: ${taskSlug}`,
|
|
68
|
+
path: feedbackPath,
|
|
69
|
+
source: "task-retrospective",
|
|
70
|
+
taskSlug,
|
|
71
|
+
summary: "Review the completed task workflow for reusable harness problems.",
|
|
72
|
+
trigger: input.trigger,
|
|
73
|
+
finalAcceptanceHash,
|
|
74
|
+
feedbackPath,
|
|
75
|
+
analysisPath,
|
|
76
|
+
applyReportPath,
|
|
77
|
+
startedAt: timestamp,
|
|
78
|
+
updatedAt: timestamp,
|
|
79
|
+
lastPromptAt: timestamp
|
|
80
|
+
};
|
|
81
|
+
const nextState = {
|
|
82
|
+
version: 1,
|
|
83
|
+
status: "analyzing",
|
|
84
|
+
active
|
|
85
|
+
};
|
|
86
|
+
await deps.fs.ensureDir(resolveRepoPath(repoRoot, path.posix.dirname(feedbackPath)));
|
|
87
|
+
await deps.fs.writeText(resolveRepoPath(repoRoot, feedbackPath), renderTaskRetrospectiveFeedback(active, finalAcceptancePath));
|
|
88
|
+
await persistStoredState(repoRoot, nextState);
|
|
89
|
+
await persistTaskRetrospectiveMarker(repoRoot, active, "analyzing");
|
|
90
|
+
await submitTerminalInput(deps.runtime, session.id, buildTaskRetrospectivePrompt(repoRoot, active));
|
|
91
|
+
return buildStateReport(repoRoot);
|
|
92
|
+
}
|
|
16
93
|
async function decide(repoRoot, input) {
|
|
17
94
|
const state = await loadStoredState(repoRoot);
|
|
18
95
|
if (!state || state.status !== "awaiting_user_approval") {
|
|
@@ -48,6 +125,7 @@ export function createHarnessFeedbackService(deps) {
|
|
|
48
125
|
}
|
|
49
126
|
};
|
|
50
127
|
await persistStoredState(repoRoot, nextState);
|
|
128
|
+
await persistTaskRetrospectiveMarker(repoRoot, nextState.active, "analyzing");
|
|
51
129
|
await submitTerminalInput(deps.runtime, session.id, buildFeedbackCommentPrompt(repoRoot, nextState.active, input.comment ?? ""));
|
|
52
130
|
return buildStateReport(repoRoot);
|
|
53
131
|
}
|
|
@@ -63,6 +141,7 @@ export function createHarnessFeedbackService(deps) {
|
|
|
63
141
|
}
|
|
64
142
|
};
|
|
65
143
|
await persistStoredState(repoRoot, nextState);
|
|
144
|
+
await persistTaskRetrospectiveMarker(repoRoot, nextState.active, "applying");
|
|
66
145
|
await submitTerminalInput(deps.runtime, session.id, buildFeedbackApplyPrompt(repoRoot, nextState.active, input.comment ?? ""));
|
|
67
146
|
return buildStateReport(repoRoot);
|
|
68
147
|
}
|
|
@@ -76,14 +155,16 @@ export function createHarnessFeedbackService(deps) {
|
|
|
76
155
|
}
|
|
77
156
|
const timestamp = now();
|
|
78
157
|
if (state.status === "analyzing") {
|
|
79
|
-
|
|
158
|
+
const nextState = {
|
|
80
159
|
...state,
|
|
81
160
|
status: "awaiting_user_approval",
|
|
82
161
|
active: {
|
|
83
162
|
...state.active,
|
|
84
163
|
updatedAt: timestamp
|
|
85
164
|
}
|
|
86
|
-
}
|
|
165
|
+
};
|
|
166
|
+
await persistStoredState(repoRoot, nextState);
|
|
167
|
+
await persistTaskRetrospectiveMarker(repoRoot, nextState.active, "awaiting_user_approval", timestamp);
|
|
87
168
|
return;
|
|
88
169
|
}
|
|
89
170
|
if (state.status === "applying") {
|
|
@@ -132,6 +213,7 @@ export function createHarnessFeedbackService(deps) {
|
|
|
132
213
|
const active = {
|
|
133
214
|
...next,
|
|
134
215
|
feedbackPath: next.path,
|
|
216
|
+
source: next.source ?? "role-feedback",
|
|
135
217
|
analysisPath,
|
|
136
218
|
applyReportPath,
|
|
137
219
|
startedAt: timestamp,
|
|
@@ -209,12 +291,15 @@ export function createHarnessFeedbackService(deps) {
|
|
|
209
291
|
id: state.active.id,
|
|
210
292
|
title: state.active.title,
|
|
211
293
|
path: state.active.path,
|
|
294
|
+
source: state.active.source,
|
|
212
295
|
reporterRole: state.active.reporterRole,
|
|
213
296
|
taskSlug: state.active.taskSlug,
|
|
214
297
|
summary: state.active.summary,
|
|
215
298
|
status: state.status,
|
|
216
299
|
startedAt: state.active.startedAt,
|
|
217
300
|
updatedAt: state.active.updatedAt,
|
|
301
|
+
trigger: state.active.trigger,
|
|
302
|
+
finalAcceptanceHash: state.active.finalAcceptanceHash,
|
|
218
303
|
feedbackContent,
|
|
219
304
|
analysisPath: state.active.analysisPath,
|
|
220
305
|
analysisContent,
|
|
@@ -249,6 +334,7 @@ export function createHarnessFeedbackService(deps) {
|
|
|
249
334
|
id,
|
|
250
335
|
title: compactLine(title),
|
|
251
336
|
path: relativePath,
|
|
337
|
+
source: "role-feedback",
|
|
252
338
|
reporterRole: metadata["reporter role"] ?? metadata.reporter,
|
|
253
339
|
taskSlug: metadata["task slug"] ?? metadata.task,
|
|
254
340
|
summary: metadata.summary
|
|
@@ -278,6 +364,16 @@ export function createHarnessFeedbackService(deps) {
|
|
|
278
364
|
"</HARNESS_FEEDBACK>"
|
|
279
365
|
].join("\n");
|
|
280
366
|
}
|
|
367
|
+
function buildTaskRetrospectivePrompt(repoRoot, active) {
|
|
368
|
+
return [
|
|
369
|
+
"[VCM Task Harness Retrospective]",
|
|
370
|
+
"",
|
|
371
|
+
"Review the completed task from the current active task worktree.",
|
|
372
|
+
"",
|
|
373
|
+
`Write the analysis to Result Path: ${resolveRepoPath(repoRoot, active.analysisPath)}`,
|
|
374
|
+
"End your turn after writing the result."
|
|
375
|
+
].join("\n");
|
|
376
|
+
}
|
|
281
377
|
function buildFeedbackCommentPrompt(repoRoot, active, comment) {
|
|
282
378
|
return [
|
|
283
379
|
"[VCM Harness Feedback Revision]",
|
|
@@ -345,6 +441,7 @@ export function createHarnessFeedbackService(deps) {
|
|
|
345
441
|
comment,
|
|
346
442
|
completedAt: now()
|
|
347
443
|
});
|
|
444
|
+
await persistTaskRetrospectiveMarker(repoRoot, state.active, outcome === "rejected" ? "rejected" : "completed");
|
|
348
445
|
await deps.fs.removePath?.(resolveRepoPath(repoRoot, state.active.feedbackPath), { force: true });
|
|
349
446
|
await deps.fs.removePath?.(resolveRepoPath(repoRoot, path.posix.dirname(state.active.analysisPath)), { recursive: true, force: true });
|
|
350
447
|
}
|
|
@@ -357,6 +454,7 @@ export function createHarnessFeedbackService(deps) {
|
|
|
357
454
|
if (state?.version !== 1 || !state.active?.id || !state.status) {
|
|
358
455
|
return undefined;
|
|
359
456
|
}
|
|
457
|
+
state.active.source = state.active.source ?? "role-feedback";
|
|
360
458
|
return state;
|
|
361
459
|
}
|
|
362
460
|
async function persistStoredState(repoRoot, state) {
|
|
@@ -372,8 +470,38 @@ export function createHarnessFeedbackService(deps) {
|
|
|
372
470
|
}
|
|
373
471
|
return deps.fs.readText(absolutePath);
|
|
374
472
|
}
|
|
473
|
+
async function readAbsoluteOptionalText(absolutePath) {
|
|
474
|
+
if (!(await deps.fs.pathExists(absolutePath))) {
|
|
475
|
+
return undefined;
|
|
476
|
+
}
|
|
477
|
+
return deps.fs.readText(absolutePath);
|
|
478
|
+
}
|
|
479
|
+
async function loadTaskRetrospectiveMarker(repoRoot, taskSlug) {
|
|
480
|
+
const markerPath = resolveRepoPath(repoRoot, getTaskRetrospectiveMarkerPath(taskSlug));
|
|
481
|
+
if (!(await deps.fs.pathExists(markerPath))) {
|
|
482
|
+
return undefined;
|
|
483
|
+
}
|
|
484
|
+
return deps.fs.readJson(markerPath);
|
|
485
|
+
}
|
|
486
|
+
async function persistTaskRetrospectiveMarker(repoRoot, active, status, timestamp = now()) {
|
|
487
|
+
if (active.source !== "task-retrospective" || !active.taskSlug) {
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
await deps.fs.writeJsonAtomic(resolveRepoPath(repoRoot, getTaskRetrospectiveMarkerPath(active.taskSlug)), {
|
|
491
|
+
version: 1,
|
|
492
|
+
taskSlug: active.taskSlug,
|
|
493
|
+
activeId: active.id,
|
|
494
|
+
trigger: active.trigger ?? "manual",
|
|
495
|
+
status,
|
|
496
|
+
finalAcceptanceHash: active.finalAcceptanceHash,
|
|
497
|
+
createdAt: active.startedAt,
|
|
498
|
+
updatedAt: timestamp,
|
|
499
|
+
...(status === "completed" || status === "rejected" ? { completedAt: timestamp } : {})
|
|
500
|
+
});
|
|
501
|
+
}
|
|
375
502
|
return {
|
|
376
503
|
getState,
|
|
504
|
+
startTaskRetrospective,
|
|
377
505
|
decide,
|
|
378
506
|
recordHarnessEngineerHook,
|
|
379
507
|
assertHarnessEngineerAvailable
|
|
@@ -397,10 +525,29 @@ function firstHeading(content) {
|
|
|
397
525
|
function compactLine(value) {
|
|
398
526
|
return value.replace(/\s+/g, " ").trim().slice(0, 160);
|
|
399
527
|
}
|
|
528
|
+
function renderTaskRetrospectiveFeedback(active, finalAcceptancePath) {
|
|
529
|
+
return [
|
|
530
|
+
`# ${active.title}`,
|
|
531
|
+
"",
|
|
532
|
+
`Source: ${active.source}`,
|
|
533
|
+
`Task slug: ${active.taskSlug ?? ""}`,
|
|
534
|
+
`Trigger: ${active.trigger ?? "manual"}`,
|
|
535
|
+
`Final acceptance: ${finalAcceptancePath}`,
|
|
536
|
+
`Final acceptance hash: ${active.finalAcceptanceHash ?? ""}`,
|
|
537
|
+
"",
|
|
538
|
+
"Summary: Review the completed task workflow for reusable harness problems."
|
|
539
|
+
].join("\n");
|
|
540
|
+
}
|
|
541
|
+
function getTaskRetrospectiveMarkerPath(taskSlug) {
|
|
542
|
+
return `${TASK_RETROSPECTIVE_DIR}/${sanitizeFeedbackId(taskSlug)}.json`;
|
|
543
|
+
}
|
|
400
544
|
function sanitizeFeedbackId(value) {
|
|
401
545
|
const sanitized = value.replace(/[^A-Za-z0-9._-]/g, "-").replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
402
546
|
return sanitized || "feedback";
|
|
403
547
|
}
|
|
548
|
+
function sha256(content) {
|
|
549
|
+
return createHash("sha256").update(content).digest("hex");
|
|
550
|
+
}
|
|
404
551
|
export function getHarnessFeedbackRelativePath(repoRoot, absolutePath) {
|
|
405
552
|
return toRepoRelativePath(repoRoot, absolutePath);
|
|
406
553
|
}
|