vibe-coding-master 0.7.42 → 0.7.43

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.
Files changed (33) hide show
  1. package/README.md +10 -5
  2. package/dist/backend/api/task-routes.js +31 -2
  3. package/dist/backend/api/workflow-control-routes.js +0 -31
  4. package/dist/backend/cli/install-vcm-harness.js +40 -6
  5. package/dist/backend/role-tool-policy.js +1 -1
  6. package/dist/backend/server.js +12 -5
  7. package/dist/backend/services/artifact-service.js +3 -2
  8. package/dist/backend/services/auto-memory-service.js +2 -2
  9. package/dist/backend/services/claude-hook-service.js +103 -4
  10. package/dist/backend/services/harness-feedback-service.js +105 -3
  11. package/dist/backend/services/harness-service.js +38 -6
  12. package/dist/backend/services/memory-review-paths.js +13 -0
  13. package/dist/backend/services/role-stall-detector-service.js +322 -0
  14. package/dist/backend/services/round-service.js +25 -0
  15. package/dist/backend/services/runtime-coordinator-service.js +10 -0
  16. package/dist/backend/services/session-service.js +70 -3
  17. package/dist/backend/services/workflow-control-service.js +439 -203
  18. package/dist/backend/templates/handoff.js +2 -3
  19. package/dist/backend/templates/harness/architect-agent.js +2 -2
  20. package/dist/backend/templates/harness/coder-agent.js +4 -5
  21. package/dist/backend/templates/harness/gate-review.js +7 -9
  22. package/dist/backend/templates/harness/harness-engineer-agent.js +18 -8
  23. package/dist/backend/templates/harness/project-manager-agent.js +5 -5
  24. package/dist/backend/templates/harness/vcm-code-navigation-skill.js +6 -7
  25. package/dist/backend/templates/harness/vcm-workflow-review-skill.js +7 -9
  26. package/dist/shared/types/role-stall.js +1 -0
  27. package/dist/shared/types/workflow.js +14 -0
  28. package/dist/shared/validation/artifact-registry.js +1 -1
  29. package/dist-frontend/assets/{index-C_XHGNBD.css → index-B0d4Z6ny.css} +1 -1
  30. package/dist-frontend/assets/{index-Bocc2DWF.js → index-VW9tYPP5.js} +38 -38
  31. package/dist-frontend/index.html +2 -2
  32. package/package.json +1 -1
  33. package/scripts/harness-tools/vcm-bash-guard +203 -13
@@ -76,6 +76,7 @@ export function createHarnessFeedbackService(deps) {
76
76
  });
77
77
  }
78
78
  const session = await ensureIdleHarnessEngineer(repoRoot, taskSlug);
79
+ const pendingFeedback = await listPendingFeedback(repoRoot);
79
80
  const timestamp = now();
80
81
  const analysisPath = `${TASK_RETROSPECTIVE_DIR}/${sanitizeFeedbackId(taskSlug)}.md`;
81
82
  const analysisAbsolutePath = resolveRepoPath(repoRoot, analysisPath);
@@ -87,11 +88,11 @@ export function createHarnessFeedbackService(deps) {
87
88
  status: "running",
88
89
  analysisPath,
89
90
  finalAcceptanceHash: `sha256:${sha256(finalAcceptanceContent)}`,
91
+ pendingFeedbackPaths: pendingFeedback.map((item) => item.path),
90
92
  ...(memoryReview ? { memoryRunId: memoryReview.runId } : {}),
91
93
  createdAt: timestamp,
92
94
  updatedAt: timestamp
93
95
  };
94
- const pendingFeedback = await listPendingFeedback(repoRoot);
95
96
  try {
96
97
  await persistTaskRetrospectiveMarker(repoRoot, marker);
97
98
  await submitTerminalInput(deps.runtime, session.id, buildTaskRetrospectivePrompt(repoRoot, analysisPath, pendingFeedback.map((item) => item.path), memoryReview));
@@ -135,7 +136,12 @@ export function createHarnessFeedbackService(deps) {
135
136
  const reportContent = await deps.fs.pathExists(reportPath)
136
137
  ? await deps.fs.readText(reportPath)
137
138
  : "";
138
- const reportErrors = reportContent.trim() ? getRetrospectiveReportErrors(reportContent) : ["Report is empty."];
139
+ const reportErrors = reportContent.trim()
140
+ ? [
141
+ ...getRetrospectiveReportErrors(reportContent),
142
+ ...getFeedbackDispositionErrors(repoRoot, marker.pendingFeedbackPaths ?? [], reportContent)
143
+ ]
144
+ : ["Report is empty."];
139
145
  const reportReady = reportErrors.length === 0;
140
146
  if (!reportReady || (marker.memoryRunId && !input.memoryReviewSucceeded)) {
141
147
  await persistTaskRetrospectiveMarker(repoRoot, {
@@ -149,6 +155,19 @@ export function createHarnessFeedbackService(deps) {
149
155
  });
150
156
  return true;
151
157
  }
158
+ try {
159
+ await removeProcessedFeedback(repoRoot, marker.pendingFeedbackPaths ?? []);
160
+ }
161
+ catch (error) {
162
+ await persistTaskRetrospectiveMarker(repoRoot, {
163
+ ...marker,
164
+ status: "failed",
165
+ failedAt: timestamp,
166
+ updatedAt: timestamp,
167
+ error: `VCM could not remove processed Harness Feedback: ${errorMessage(error)}`
168
+ });
169
+ return true;
170
+ }
152
171
  await persistTaskRetrospectiveMarker(repoRoot, {
153
172
  ...marker,
154
173
  status: "completed",
@@ -245,7 +264,15 @@ export function createHarnessFeedbackService(deps) {
245
264
  ...(pendingFeedbackPaths.length > 0
246
265
  ? [
247
266
  "",
248
- "Process every listed feedback inside this retrospective. Record every disposition in the retrospective report, then delete the processed feedback files before ending the turn."
267
+ "Process every listed feedback inside this retrospective. Record every disposition in the retrospective report using this exact block for each assigned path:",
268
+ "",
269
+ "### Feedback: <exact assigned absolute path>",
270
+ "Decision: confirmed|rejected|duplicate|already-covered",
271
+ "Evidence: <concise evidence>",
272
+ "Impact: <impact>",
273
+ "Required action: <action or none>",
274
+ "",
275
+ "Do not edit or delete pending feedback files. VCM removes the assigned files after validating every disposition in the accepted report."
249
276
  ]
250
277
  : []),
251
278
  "",
@@ -375,6 +402,20 @@ export function createHarnessFeedbackService(deps) {
375
402
  await deps.fs.removePath?.(statePath, { force: true });
376
403
  }
377
404
  }
405
+ async function removeProcessedFeedback(repoRoot, feedbackPaths) {
406
+ if (feedbackPaths.length === 0) {
407
+ return;
408
+ }
409
+ if (!deps.fs.removePath) {
410
+ throw new Error("The filesystem adapter does not support feedback removal.");
411
+ }
412
+ for (const feedbackPath of feedbackPaths) {
413
+ assertPendingFeedbackPath(feedbackPath);
414
+ }
415
+ for (const feedbackPath of feedbackPaths) {
416
+ await deps.fs.removePath(resolveRepoPath(repoRoot, feedbackPath), { force: true });
417
+ }
418
+ }
378
419
  async function readOptionalText(repoRoot, relativePath) {
379
420
  const absolutePath = resolveRepoPath(repoRoot, relativePath);
380
421
  return readAbsoluteOptionalText(absolutePath);
@@ -393,6 +434,67 @@ export function createHarnessFeedbackService(deps) {
393
434
  assertHarnessEngineerAvailable
394
435
  };
395
436
  }
437
+ function getFeedbackDispositionErrors(repoRoot, feedbackPaths, reportContent) {
438
+ if (feedbackPaths.length === 0) {
439
+ return [];
440
+ }
441
+ const section = readLevelTwoSection(reportContent, "Feedback Dispositions");
442
+ if (!section) {
443
+ return ["Feedback Dispositions is empty."];
444
+ }
445
+ const errors = [];
446
+ const lines = section.split(/\r?\n/);
447
+ for (const feedbackPath of feedbackPaths) {
448
+ try {
449
+ assertPendingFeedbackPath(feedbackPath);
450
+ }
451
+ catch (error) {
452
+ errors.push(errorMessage(error));
453
+ continue;
454
+ }
455
+ const absolutePath = resolveRepoPath(repoRoot, feedbackPath);
456
+ const heading = `### Feedback: ${absolutePath}`;
457
+ const start = lines.findIndex((line) => line.trim() === heading);
458
+ if (start < 0) {
459
+ errors.push(`Missing disposition for assigned feedback: ${absolutePath}.`);
460
+ continue;
461
+ }
462
+ const endOffset = lines.slice(start + 1).findIndex((line) => /^###\s+/.test(line.trim()));
463
+ const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
464
+ const block = lines.slice(start + 1, end);
465
+ const decision = readDispositionField(block, "Decision");
466
+ if (!decision || !["confirmed", "rejected", "duplicate", "already-covered"].includes(decision)) {
467
+ errors.push(`Feedback disposition Decision for ${absolutePath} must be confirmed|rejected|duplicate|already-covered.`);
468
+ }
469
+ for (const field of ["Evidence", "Impact", "Required action"]) {
470
+ if (!readDispositionField(block, field)) {
471
+ errors.push(`Feedback disposition ${field} is required for ${absolutePath}.`);
472
+ }
473
+ }
474
+ }
475
+ return errors;
476
+ }
477
+ function readLevelTwoSection(content, heading) {
478
+ const escapedHeading = heading.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
479
+ const match = new RegExp(`^##\\s+${escapedHeading}\\s*$`, "im").exec(content);
480
+ if (!match || match.index === undefined) {
481
+ return undefined;
482
+ }
483
+ const afterHeading = content.slice(match.index + match[0].length);
484
+ const nextHeading = /\n##\s+\S/.exec(afterHeading);
485
+ return (nextHeading ? afterHeading.slice(0, nextHeading.index) : afterHeading).trim();
486
+ }
487
+ function readDispositionField(lines, field) {
488
+ const escapedField = field.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
489
+ return lines
490
+ .map((line) => new RegExp(`^${escapedField}:\\s*(.+)$`, "i").exec(line.trim())?.[1]?.trim())
491
+ .find((value) => Boolean(value));
492
+ }
493
+ function assertPendingFeedbackPath(feedbackPath) {
494
+ if (!/^\.ai\/vcm\/harness-feedback\/pending\/[A-Za-z0-9._-]+\.md$/.test(feedbackPath)) {
495
+ throw new Error(`Invalid assigned Harness Feedback path: ${feedbackPath}.`);
496
+ }
497
+ }
396
498
  function parseSimpleMetadata(content) {
397
499
  const result = {};
398
500
  for (const line of content.split(/\r?\n/).slice(0, 80)) {
@@ -57,8 +57,15 @@ const VCM_HOOK_DEFINITIONS = [
57
57
  { eventName: "PreToolUse", matcher: "Bash", command: VCM_BASH_GUARD_HOOK_COMMAND, timeout: 10 },
58
58
  { eventName: "PreToolUse", matcher: "Write|Edit", command: VCM_BASH_GUARD_HOOK_COMMAND, timeout: 10 },
59
59
  { eventName: "UserPromptSubmit", command: VCM_HOOK_COMMAND, timeout: 5 },
60
+ { eventName: "PreToolUse", command: VCM_HOOK_COMMAND, timeout: 5 },
61
+ { eventName: "PostToolUse", command: VCM_HOOK_COMMAND, timeout: 5 },
62
+ { eventName: "PostToolUseFailure", command: VCM_HOOK_COMMAND, timeout: 5 },
63
+ { eventName: "PostToolBatch", command: VCM_HOOK_COMMAND, timeout: 5 },
64
+ { eventName: "SubagentStart", command: VCM_HOOK_COMMAND, timeout: 5 },
65
+ { eventName: "SubagentStop", command: VCM_HOOK_COMMAND, timeout: 5 },
60
66
  { eventName: "Stop", command: VCM_STOP_HOOK_COMMAND, timeout: 10 },
61
67
  { eventName: "StopFailure", command: VCM_HOOK_COMMAND, timeout: 5 },
68
+ { eventName: "PreCompact", command: VCM_HOOK_COMMAND, timeout: 5 },
62
69
  { eventName: "PostCompact", command: VCM_HOOK_COMMAND, timeout: 5 },
63
70
  { eventName: "PermissionRequest", command: VCM_PERMISSION_REQUEST_HOOK_COMMAND, timeout: 5 }
64
71
  ];
@@ -119,7 +126,7 @@ const HARNESS_FILES = [
119
126
  kind: "skill-vcm-code-navigation",
120
127
  path: ".claude/skills/vcm-code-navigation/SKILL.md",
121
128
  title: "VCM Code Navigation Skill",
122
- frontmatter: renderSkillFrontmatter("vcm-code-navigation", "Use when Architect, Coder, or Reviewer must resolve code symbols, references, implementations, call hierarchies, or bounded dependency paths."),
129
+ frontmatter: renderSkillFrontmatter("vcm-code-navigation", "Use when Architect must resolve code symbols, references, implementations, call hierarchies, or bounded dependency paths."),
123
130
  ownership: "whole-file",
124
131
  renderRules: renderVcmCodeNavigationSkillRules
125
132
  },
@@ -209,8 +216,8 @@ const HARNESS_FILES = [
209
216
  title: "Reviewer Agent",
210
217
  memoryBlock: true,
211
218
  requiredDisallowedTools: REVIEWER_DISALLOWED_TOOLS,
212
- requiredSkills: ["vcm-code-navigation"],
213
- frontmatter: renderAgentFrontmatter("reviewer", "VCM independent gate review role for architecture plans, validation adequacy, and code diffs.", { disallowedTools: REVIEWER_DISALLOWED_TOOLS.join(", "), skills: ["vcm-code-navigation"] }),
219
+ removedSkills: ["vcm-code-navigation"],
220
+ frontmatter: renderAgentFrontmatter("reviewer", "VCM independent gate review role for architecture plans, validation adequacy, and code diffs.", { disallowedTools: REVIEWER_DISALLOWED_TOOLS.join(", ") }),
214
221
  renderRules: renderReviewerAgentRules
215
222
  },
216
223
  {
@@ -297,8 +304,8 @@ const HARNESS_FILES = [
297
304
  title: "Coder Agent",
298
305
  memoryBlock: true,
299
306
  requiredDisallowedTools: CODE_ROLE_DISALLOWED_TOOLS,
300
- requiredSkills: ["vcm-code-navigation"],
301
- frontmatter: renderAgentFrontmatter("coder", "VCM implementation role for scoped code changes and focused tests.", { disallowedTools: CODE_ROLE_DISALLOWED_TOOLS.join(", "), skills: ["vcm-code-navigation"] }),
307
+ removedSkills: ["vcm-code-navigation"],
308
+ frontmatter: renderAgentFrontmatter("coder", "VCM implementation role for scoped code changes and focused tests.", { disallowedTools: CODE_ROLE_DISALLOWED_TOOLS.join(", ") }),
302
309
  renderRules: renderCoderHarnessRules
303
310
  },
304
311
  {
@@ -1444,7 +1451,8 @@ function ensureAgentAllowedTool(content, requiredTool) {
1444
1451
  function normalizeAgentFrontmatter(content, definition) {
1445
1452
  const allowedToolsUpdated = (definition.requiredTools ?? []).reduce(ensureAgentAllowedTool, content);
1446
1453
  const disallowedToolsUpdated = ensureAgentDisallowedTools(allowedToolsUpdated, definition.requiredDisallowedTools);
1447
- return (definition.requiredSkills ?? []).reduce(ensureAgentSkill, disallowedToolsUpdated);
1454
+ const skillsUpdated = (definition.requiredSkills ?? []).reduce(ensureAgentSkill, disallowedToolsUpdated);
1455
+ return (definition.removedSkills ?? []).reduce(removeAgentSkill, skillsUpdated);
1448
1456
  }
1449
1457
  function ensureAgentSkill(content, requiredSkill) {
1450
1458
  const frontmatterMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---/);
@@ -1466,6 +1474,30 @@ function ensureAgentSkill(content, requiredSkill) {
1466
1474
  const nextSkills = `${skillsMatch[0].trimEnd()}\n - ${requiredSkill}`;
1467
1475
  return content.replace(frontmatter, frontmatter.replace(skillsMatch[0], nextSkills));
1468
1476
  }
1477
+ function removeAgentSkill(content, removedSkill) {
1478
+ const frontmatterMatch = content.match(/^---\r?\n[\s\S]*?\r?\n---/);
1479
+ if (!frontmatterMatch) {
1480
+ return content;
1481
+ }
1482
+ const frontmatter = frontmatterMatch[0];
1483
+ const skillsMatch = frontmatter.match(/^skills:[ \t]*(?:\r?\n((?:\s+-\s+[^\r\n]+\r?\n?)*))?/m);
1484
+ if (!skillsMatch) {
1485
+ return content;
1486
+ }
1487
+ const listedSkills = (skillsMatch[1] ?? "")
1488
+ .split(/\r?\n/)
1489
+ .map((line) => line.match(/^\s+-\s+(.+)$/)?.[1]?.trim())
1490
+ .filter((skill) => Boolean(skill));
1491
+ const remainingSkills = listedSkills.filter((skill) => skill !== removedSkill);
1492
+ if (remainingSkills.length === listedSkills.length) {
1493
+ return content;
1494
+ }
1495
+ const lineEnding = skillsMatch[0].includes("\r\n") ? "\r\n" : "\n";
1496
+ const replacement = remainingSkills.length > 0
1497
+ ? `skills:${lineEnding}${remainingSkills.map((skill) => ` - ${skill}`).join(lineEnding)}${lineEnding}`
1498
+ : "";
1499
+ return content.replace(frontmatter, frontmatter.replace(skillsMatch[0], replacement));
1500
+ }
1469
1501
  function migrateLegacyHarnessFile(definition, currentContent, block) {
1470
1502
  const legacyContent = definition.legacyWholeFile?.trimEnd();
1471
1503
  if (!legacyContent) {
@@ -2,6 +2,19 @@ export const MEMORY_REVIEW_ROOT = ".ai/vcm/memory-review";
2
2
  export const MEMORY_REVIEW_RUNS_ROOT = `${MEMORY_REVIEW_ROOT}/runs`;
3
3
  export const MEMORY_REVIEW_STATE_PATH = `${MEMORY_REVIEW_ROOT}/state.json`;
4
4
  export const ARCHITECT_PLANNING_MEMORY_CANDIDATE_PATH = `${MEMORY_REVIEW_ROOT}/candidates/architect/planning.md`;
5
+ const MEMORY_REVIEW_ROLE_DRAFT_PATTERN = new RegExp(`^${escapeRegExp(MEMORY_REVIEW_RUNS_ROOT)}/[A-Za-z0-9._-]+/drafts/([A-Za-z0-9._-]+)\\.md$`);
6
+ export function memoryReviewRoleDraftPath(runId, role) {
7
+ return `${MEMORY_REVIEW_RUNS_ROOT}/${runId}/drafts/${role}.md`;
8
+ }
9
+ export function isMemoryProposalSubmissionPath(artifactPath, role) {
10
+ if (artifactPath === ARCHITECT_PLANNING_MEMORY_CANDIDATE_PATH) {
11
+ return role === "architect";
12
+ }
13
+ return MEMORY_REVIEW_ROLE_DRAFT_PATTERN.exec(artifactPath)?.[1] === role;
14
+ }
5
15
  export function architectPlanningCandidateSnapshotPath(runId) {
6
16
  return `${MEMORY_REVIEW_RUNS_ROOT}/${runId}/sources/architect-planning.md`;
7
17
  }
18
+ function escapeRegExp(value) {
19
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
20
+ }
@@ -0,0 +1,322 @@
1
+ import { isVcmRoleName } from "../../shared/constants.js";
2
+ import { VcmError } from "../errors.js";
3
+ export const ROLE_MODEL_STALL_TIMEOUT_MS = 10 * 60 * 1000;
4
+ export const ROLE_TOOL_STALL_TIMEOUT_MS = 10 * 60 * 1000;
5
+ export const ROLE_SUBAGENT_STALL_TIMEOUT_MS = 30 * 60 * 1000;
6
+ const TOOL_TIMEOUT_GRACE_MS = 30_000;
7
+ export function createRoleStallDetectorService(deps) {
8
+ const modelTimeoutMs = deps.modelTimeoutMs ?? ROLE_MODEL_STALL_TIMEOUT_MS;
9
+ const toolTimeoutMs = deps.toolTimeoutMs ?? ROLE_TOOL_STALL_TIMEOUT_MS;
10
+ const subagentTimeoutMs = deps.subagentTimeoutMs ?? ROLE_SUBAGENT_STALL_TIMEOUT_MS;
11
+ const now = deps.now ?? (() => new Date().toISOString());
12
+ const setTimer = deps.setTimeout ?? ((callback, delayMs) => globalThis.setTimeout(callback, delayMs));
13
+ const clearTimer = deps.clearTimeout ?? ((timer) => globalThis.clearTimeout(timer));
14
+ const monitors = new Map();
15
+ return {
16
+ async recordHook(input) {
17
+ if (!isVcmRoleName(input.role)) {
18
+ return;
19
+ }
20
+ const key = monitorKey(input.repoRoot, input.taskSlug);
21
+ if (input.eventName === "Stop" || input.eventName === "StopFailure") {
22
+ clearMonitor(key);
23
+ return;
24
+ }
25
+ const [session, round] = await Promise.all([
26
+ deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, input.role),
27
+ deps.roundService.getSessionRoundState(roundInput(input))
28
+ ]);
29
+ if (!session
30
+ || session.status !== "running"
31
+ || session.activityStatus !== "running"
32
+ || round.status !== "running"
33
+ || round.activeRole !== input.role
34
+ || !round.roundId) {
35
+ clearMonitor(key);
36
+ return;
37
+ }
38
+ let monitor = monitors.get(key);
39
+ if (!monitor
40
+ || monitor.sessionId !== session.id
41
+ || monitor.runtimeSessionToken !== session.runtimeSessionToken
42
+ || monitor.roundId !== round.roundId
43
+ || monitor.role !== input.role) {
44
+ clearMonitor(key);
45
+ monitor = {
46
+ context: taskInput(input),
47
+ role: input.role,
48
+ sessionId: session.id,
49
+ runtimeSessionToken: session.runtimeSessionToken,
50
+ roundId: round.roundId,
51
+ phase: "awaiting-model",
52
+ phaseStartedAt: now(),
53
+ generation: 0,
54
+ activeTools: new Map()
55
+ };
56
+ monitors.set(key, monitor);
57
+ }
58
+ advanceMonitor(key, monitor, input.eventName, input.event);
59
+ },
60
+ async reconcileTask(input) {
61
+ const key = monitorKey(input.repoRoot, input.taskSlug);
62
+ for (const [candidateKey, monitor] of monitors) {
63
+ if (monitor.context.repoRoot === input.repoRoot && candidateKey !== key) {
64
+ clearMonitor(candidateKey);
65
+ }
66
+ }
67
+ const monitor = monitors.get(key);
68
+ if (!monitor) {
69
+ return;
70
+ }
71
+ const [session, round] = await Promise.all([
72
+ deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, monitor.role),
73
+ deps.roundService.getSessionRoundState(roundInput(input))
74
+ ]);
75
+ if (!session
76
+ || session.id !== monitor.sessionId
77
+ || session.runtimeSessionToken !== monitor.runtimeSessionToken
78
+ || session.status !== "running"
79
+ || session.activityStatus !== "running"
80
+ || round.status !== "running"
81
+ || round.activeRole !== monitor.role
82
+ || round.roundId !== monitor.roundId) {
83
+ clearMonitor(key);
84
+ }
85
+ },
86
+ getWarning(repoRoot, taskSlug) {
87
+ const warning = monitors.get(monitorKey(repoRoot, taskSlug))?.warning;
88
+ return warning ? { ...warning } : null;
89
+ },
90
+ ignoreWarning(repoRoot, taskSlug, warningId) {
91
+ const monitor = requireWarning(repoRoot, taskSlug, warningId);
92
+ monitor.ignoredGeneration = monitor.generation;
93
+ monitor.warning = undefined;
94
+ cancelTimer(monitor);
95
+ return { ok: true, warning: null };
96
+ },
97
+ async recoverWarning(input) {
98
+ const key = monitorKey(input.repoRoot, input.taskSlug);
99
+ const monitor = requireWarning(input.repoRoot, input.taskSlug, input.warningId);
100
+ const [session, round] = await Promise.all([
101
+ deps.sessionService.getRoleSession(input.repoRoot, input.taskSlug, monitor.role),
102
+ deps.roundService.getSessionRoundState(roundInput(input))
103
+ ]);
104
+ if (!session
105
+ || session.id !== monitor.sessionId
106
+ || session.runtimeSessionToken !== monitor.runtimeSessionToken
107
+ || session.status !== "running"
108
+ || session.activityStatus !== "running"
109
+ || round.status !== "running"
110
+ || round.activeRole !== monitor.role
111
+ || round.roundId !== monitor.roundId) {
112
+ clearMonitor(key);
113
+ return { ok: true, warning: null };
114
+ }
115
+ await deps.sessionService.recoverRoleSession(input.repoRoot, input.taskSlug, {
116
+ role: monitor.role,
117
+ expectedSessionId: monitor.sessionId,
118
+ expectedRuntimeSessionToken: monitor.runtimeSessionToken,
119
+ recoveryPrompt: renderRecoveryPrompt(monitor)
120
+ });
121
+ clearMonitor(key);
122
+ return { ok: true, warning: null };
123
+ },
124
+ clearProject(repoRoot) {
125
+ for (const [key, monitor] of monitors) {
126
+ if (monitor.context.repoRoot === repoRoot) {
127
+ clearMonitor(key);
128
+ }
129
+ }
130
+ },
131
+ stop() {
132
+ for (const key of [...monitors.keys()]) {
133
+ clearMonitor(key);
134
+ }
135
+ }
136
+ };
137
+ function advanceMonitor(key, monitor, eventName, event) {
138
+ monitor.generation += 1;
139
+ monitor.warning = undefined;
140
+ monitor.ignoredGeneration = undefined;
141
+ cancelTimer(monitor);
142
+ switch (eventName) {
143
+ case "UserPromptSubmit":
144
+ case "PostCompact":
145
+ case "PostToolBatch":
146
+ monitor.activeTools.clear();
147
+ monitor.currentTool = undefined;
148
+ setPhase(key, monitor, "awaiting-model", modelTimeoutMs);
149
+ return;
150
+ case "PreToolUse": {
151
+ const tool = readTool(event, toolTimeoutMs, subagentTimeoutMs);
152
+ const toolKey = tool.toolUseId ?? `anonymous-${monitor.generation}`;
153
+ monitor.activeTools.set(toolKey, tool);
154
+ monitor.currentTool = tool;
155
+ setPhase(key, monitor, tool.toolName === "Agent" ? "subagent-running" : "tool-running", tool.timeoutMs);
156
+ return;
157
+ }
158
+ case "SubagentStart":
159
+ setPhase(key, monitor, "subagent-running", subagentTimeoutMs);
160
+ return;
161
+ case "PostToolUse":
162
+ case "PostToolUseFailure": {
163
+ const toolUseId = stringValue(event.tool_use_id);
164
+ if (toolUseId) {
165
+ monitor.activeTools.delete(toolUseId);
166
+ }
167
+ monitor.currentTool = firstTool(monitor.activeTools);
168
+ if (monitor.currentTool) {
169
+ setPhase(key, monitor, monitor.currentTool.toolName === "Agent" ? "subagent-running" : "tool-running", monitor.currentTool.timeoutMs);
170
+ }
171
+ return;
172
+ }
173
+ case "SubagentStop":
174
+ monitor.currentTool = firstTool(monitor.activeTools);
175
+ if (monitor.currentTool) {
176
+ setPhase(key, monitor, monitor.currentTool.toolName === "Agent" ? "subagent-running" : "tool-running", monitor.currentTool.timeoutMs);
177
+ }
178
+ return;
179
+ case "PreCompact":
180
+ monitor.activeTools.clear();
181
+ monitor.currentTool = undefined;
182
+ setPhase(key, monitor, "compacting", modelTimeoutMs);
183
+ return;
184
+ case "PermissionRequest":
185
+ setPhase(key, monitor, "awaiting-permission");
186
+ return;
187
+ default:
188
+ return;
189
+ }
190
+ }
191
+ function setPhase(key, monitor, phase, timeoutMs) {
192
+ monitor.phase = phase;
193
+ monitor.phaseStartedAt = now();
194
+ if (timeoutMs === undefined) {
195
+ return;
196
+ }
197
+ const generation = monitor.generation;
198
+ monitor.timer = setTimer(() => {
199
+ void detectStall(key, generation).catch(() => undefined);
200
+ }, timeoutMs);
201
+ }
202
+ async function detectStall(key, generation) {
203
+ const monitor = monitors.get(key);
204
+ if (!monitor || monitor.generation !== generation || monitor.ignoredGeneration === generation) {
205
+ return;
206
+ }
207
+ const [session, round] = await Promise.all([
208
+ deps.sessionService.getRoleSession(monitor.context.repoRoot, monitor.context.taskSlug, monitor.role),
209
+ deps.roundService.getSessionRoundState(roundInput(monitor.context))
210
+ ]);
211
+ if (!session
212
+ || session.id !== monitor.sessionId
213
+ || session.runtimeSessionToken !== monitor.runtimeSessionToken
214
+ || session.status !== "running"
215
+ || session.activityStatus !== "running"
216
+ || round.status !== "running"
217
+ || round.activeRole !== monitor.role
218
+ || round.roundId !== monitor.roundId) {
219
+ clearMonitor(key);
220
+ return;
221
+ }
222
+ const detectedAt = now();
223
+ monitor.timer = undefined;
224
+ monitor.warning = {
225
+ id: `${monitor.sessionId}:${monitor.roundId}:${generation}:${detectedAt}`,
226
+ taskSlug: monitor.context.taskSlug,
227
+ role: monitor.role,
228
+ sessionId: monitor.sessionId,
229
+ runtimeSessionToken: monitor.runtimeSessionToken,
230
+ roundId: monitor.roundId,
231
+ phase: monitor.phase,
232
+ phaseStartedAt: monitor.phaseStartedAt,
233
+ detectedAt,
234
+ toolUseId: monitor.currentTool?.toolUseId,
235
+ toolName: monitor.currentTool?.toolName
236
+ };
237
+ }
238
+ function requireWarning(repoRoot, taskSlug, warningId) {
239
+ const monitor = monitors.get(monitorKey(repoRoot, taskSlug));
240
+ if (!monitor?.warning || monitor.warning.id !== warningId) {
241
+ throw new VcmError({
242
+ code: "ROLE_STALL_WARNING_STALE",
243
+ message: "The role stall warning is no longer active.",
244
+ statusCode: 409,
245
+ hint: "Refresh the task workspace before retrying the action."
246
+ });
247
+ }
248
+ return monitor;
249
+ }
250
+ function cancelTimer(monitor) {
251
+ if (monitor.timer === undefined) {
252
+ return;
253
+ }
254
+ clearTimer(monitor.timer);
255
+ monitor.timer = undefined;
256
+ }
257
+ function clearMonitor(key) {
258
+ const monitor = monitors.get(key);
259
+ if (!monitor) {
260
+ return;
261
+ }
262
+ cancelTimer(monitor);
263
+ monitors.delete(key);
264
+ }
265
+ }
266
+ function monitorKey(repoRoot, taskSlug) {
267
+ return `${repoRoot}:${taskSlug}`;
268
+ }
269
+ function taskInput(input) {
270
+ return {
271
+ repoRoot: input.repoRoot,
272
+ taskRepoRoot: input.taskRepoRoot,
273
+ stateRoot: input.stateRoot,
274
+ taskSlug: input.taskSlug
275
+ };
276
+ }
277
+ function roundInput(input) {
278
+ return {
279
+ repoRoot: input.repoRoot,
280
+ stateRepoRoot: input.taskRepoRoot,
281
+ stateRoot: input.stateRoot,
282
+ taskSlug: input.taskSlug
283
+ };
284
+ }
285
+ function readTool(event, defaultTimeoutMs, subagentTimeoutMs) {
286
+ const toolName = stringValue(event.tool_name);
287
+ const toolUseId = stringValue(event.tool_use_id);
288
+ const input = objectValue(event.tool_input);
289
+ const requestedTimeout = numberValue(input?.timeout);
290
+ return {
291
+ toolName,
292
+ toolUseId,
293
+ timeoutMs: toolName === "Agent"
294
+ ? subagentTimeoutMs
295
+ : Math.max(defaultTimeoutMs, requestedTimeout ? requestedTimeout + TOOL_TIMEOUT_GRACE_MS : 0)
296
+ };
297
+ }
298
+ function firstTool(tools) {
299
+ return tools.values().next().value;
300
+ }
301
+ function renderRecoveryPrompt(monitor) {
302
+ const phase = monitor.currentTool?.toolName
303
+ ? `${monitor.phase} (${monitor.currentTool.toolName})`
304
+ : monitor.phase;
305
+ return [
306
+ "[VCM Session Recovery]",
307
+ `The previous ${monitor.role} turn may have stalled during ${phase}.`,
308
+ "Continue the current assigned work from the existing Claude session context.",
309
+ "Do not repeat completed work unless it is needed to verify the current state."
310
+ ].join("\n");
311
+ }
312
+ function objectValue(value) {
313
+ return value && typeof value === "object" && !Array.isArray(value)
314
+ ? value
315
+ : undefined;
316
+ }
317
+ function stringValue(value) {
318
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
319
+ }
320
+ function numberValue(value) {
321
+ return typeof value === "number" && Number.isFinite(value) && value > 0 ? value : undefined;
322
+ }
@@ -280,6 +280,31 @@ export function createRoundService(deps) {
280
280
  return toSessionRoundState(next, timestamp);
281
281
  });
282
282
  },
283
+ async recordRoleRecoveryFailure(input) {
284
+ return withTaskLock(input, async () => {
285
+ const timestamp = now();
286
+ const state = await load(input);
287
+ const stopped = applyTerminalExit({
288
+ state,
289
+ taskSlug: input.taskSlug,
290
+ role: input.recovery.role,
291
+ timestamp
292
+ });
293
+ const next = {
294
+ ...stopped,
295
+ roleRecovery: normalizeRoleRecovery({
296
+ ...input.recovery,
297
+ status: "failed",
298
+ failedAt: input.recovery.failedAt ?? timestamp
299
+ }),
300
+ updatedAt: timestamp
301
+ };
302
+ await save(input, next);
303
+ clearSettleTimer(input);
304
+ await updateSessionStatus(input, next.currentRound?.status === "running" ? "running" : "stopped");
305
+ return toSessionRoundState(next, timestamp);
306
+ });
307
+ },
283
308
  async setRoleRecovery(input) {
284
309
  return withTaskLock(input, async () => {
285
310
  const timestamp = now();
@@ -42,9 +42,17 @@ export function createRuntimeCoordinatorService(deps) {
42
42
  ]);
43
43
  const preferences = await deps.appSettings.getPreferences();
44
44
  if (!activeTask) {
45
+ deps.roleStallDetector.clearProject(repoRoot);
45
46
  return { activeTask: null, gatewayStatus };
46
47
  }
47
48
  const taskRepoRoot = getTaskRuntimeRepoRoot(activeTask);
49
+ const stateRoot = await deps.getStateRoot(repoRoot);
50
+ await deps.roleStallDetector.reconcileTask({
51
+ repoRoot,
52
+ taskRepoRoot,
53
+ stateRoot,
54
+ taskSlug: activeTask.taskSlug
55
+ });
48
56
  const harnessInitialized = await deps.harnessService.getHarnessStatus(taskRepoRoot)
49
57
  .then((status) => status.initialized)
50
58
  .catch(() => false);
@@ -77,6 +85,7 @@ export function createRuntimeCoordinatorService(deps) {
77
85
  void reconcileCurrentProject().catch(() => undefined);
78
86
  },
79
87
  stop() {
88
+ deps.roleStallDetector.stop();
80
89
  if (reconcileTimer === undefined) {
81
90
  return;
82
91
  }
@@ -88,6 +97,7 @@ export function createRuntimeCoordinatorService(deps) {
88
97
  async function reconcileCurrentProject() {
89
98
  const project = await deps.projectService.getCurrentProject();
90
99
  if (!project) {
100
+ deps.roleStallDetector.stop();
91
101
  return;
92
102
  }
93
103
  await reconcileProject(project.repoRoot);