vibe-coding-master 0.6.23 → 0.7.0

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.
@@ -118,61 +118,130 @@ export function createTaskService(deps) {
118
118
  await this.saveTask(repoRoot, updated);
119
119
  return updated;
120
120
  },
121
- async cleanupTask(repoRoot, taskSlug, options = {}) {
122
- assertValidTaskSlug(taskSlug);
123
- if (!deps.fs.removePath) {
124
- throw new VcmError({
125
- code: "FILESYSTEM_REMOVE_UNAVAILABLE",
126
- message: "This VCM runtime cannot remove task files.",
127
- statusCode: 500
128
- });
129
- }
130
- const config = await deps.projectService.loadConfig(repoRoot);
121
+ async markTaskCleaned(repoRoot, taskSlug) {
131
122
  const task = await this.loadTask(repoRoot, taskSlug);
123
+ if (task.cleanupStatus === "cleaned" && task.cleanedAt) {
124
+ return task;
125
+ }
126
+ const timestamp = now();
127
+ const updated = {
128
+ ...task,
129
+ status: "stopped",
130
+ cleanupStatus: "cleaned",
131
+ cleanedAt: timestamp,
132
+ updatedAt: timestamp
133
+ };
134
+ await this.saveTask(repoRoot, updated);
135
+ return updated;
136
+ },
137
+ async cleanupTask(repoRoot, taskSlug) {
138
+ assertValidTaskSlug(taskSlug);
139
+ const task = await this.markTaskCleaned(repoRoot, taskSlug);
132
140
  const taskRepoRoot = getTaskRuntimeRepoRoot(task);
133
141
  const taskStoreRoot = deps.projectService.getProjectDataRoot(repoRoot);
134
142
  const taskPath = getTaskPath(taskStoreRoot, taskSlug);
135
- const statePaths = getTaskStatePaths(taskStoreRoot, taskRepoRoot, config.stateRoot, config.handoffRoot, taskSlug);
136
- const removedStatePaths = [];
137
143
  const warnings = [];
138
- const cleanedAt = now();
139
- assertTaskWorktreePath(repoRoot, task.worktreePath);
140
- await removeTaskWorktreeIdempotent(deps.fs, deps.git, repoRoot, task.worktreePath, options.force ?? true, warnings);
141
- await deleteTaskBranchIdempotent(deps.git, repoRoot, task.branch, options.forceDeleteBranch ?? true);
144
+ if (!deps.fs.removePath) {
145
+ warnings.push("This VCM runtime cannot remove task files; the task remains logically closed.");
146
+ }
147
+ let stateRoot = ".ai/vcm";
148
+ let handoffRoot = task.handoffDir;
149
+ try {
150
+ const config = await deps.projectService.loadConfig(repoRoot);
151
+ stateRoot = config.stateRoot;
152
+ handoffRoot = config.handoffRoot;
153
+ }
154
+ catch (error) {
155
+ warnings.push(`Unable to load project cleanup paths; using task defaults: ${describeError(error)}`);
156
+ }
157
+ const statePaths = getTaskStatePaths(taskStoreRoot, taskRepoRoot, stateRoot, handoffRoot, taskSlug);
158
+ const removedStatePaths = [];
159
+ let worktreeRemoved = false;
160
+ try {
161
+ assertTaskWorktreePath(repoRoot, task.worktreePath);
162
+ worktreeRemoved = await removeTaskWorktreeBestEffort(deps.fs, deps.git, repoRoot, task.worktreePath, warnings);
163
+ }
164
+ catch (error) {
165
+ warnings.push(`Skipped unsafe task worktree path ${task.worktreePath}: ${describeError(error)}`);
166
+ }
167
+ const branchCleanup = await deleteTaskBranchBestEffort(deps.git, repoRoot, task.branch, warnings);
142
168
  for (const statePath of statePaths.filter((candidate) => candidate !== taskPath)) {
143
169
  await removeWorktreeStatePathBestEffort(deps.fs, statePath, removedStatePaths, warnings);
144
170
  }
145
- await deps.fs.removePath(taskPath, { recursive: true, force: true });
146
- removedStatePaths.push(taskPath);
171
+ let stateRemoved = false;
172
+ if (worktreeRemoved && branchCleanup.resolved) {
173
+ try {
174
+ await deps.fs.removePath?.(taskPath, { recursive: true, force: true });
175
+ stateRemoved = !(await deps.fs.pathExists(taskPath));
176
+ if (stateRemoved) {
177
+ removedStatePaths.push(taskPath);
178
+ }
179
+ else {
180
+ warnings.push(`Task state remained after cleanup: ${taskPath}`);
181
+ }
182
+ }
183
+ catch (error) {
184
+ warnings.push(`Unable to remove cleaned task state ${taskPath}: ${describeError(error)}`);
185
+ }
186
+ }
187
+ else {
188
+ warnings.push("Retained cleaned task state so unresolved resource cleanup can be retried later.");
189
+ }
147
190
  return {
148
191
  taskSlug,
149
- removedWorktreePath: task.worktreePath,
192
+ taskClosed: true,
193
+ worktreeRemoved,
194
+ branchDeleted: branchCleanup.resolved,
195
+ stateRemoved,
196
+ removedWorktreePath: worktreeRemoved ? task.worktreePath : null,
150
197
  removedStatePaths,
151
- deletedBranch: task.branch,
152
- cleanedAt,
198
+ deletedBranch: branchCleanup.deleted ? task.branch : null,
199
+ cleanedAt: task.cleanedAt ?? now(),
153
200
  warnings: warnings.length > 0 ? warnings : undefined
154
201
  };
155
202
  }
156
203
  };
157
204
  }
158
- async function removeTaskWorktreeIdempotent(fs, git, repoRoot, worktreePath, force, warnings) {
159
- const wasRegistered = await git.isWorktreeRegistered(repoRoot, worktreePath);
160
- if (wasRegistered) {
205
+ async function removeTaskWorktreeBestEffort(fs, git, repoRoot, worktreePath, warnings) {
206
+ let wasRegistered;
207
+ try {
208
+ wasRegistered = await git.isWorktreeRegistered(repoRoot, worktreePath);
209
+ }
210
+ catch (error) {
211
+ warnings.push(`Unable to inspect task worktree registration for ${worktreePath}: ${describeError(error)}`);
212
+ }
213
+ if (wasRegistered !== false) {
161
214
  try {
162
- await git.removeWorktree(repoRoot, worktreePath, { force });
215
+ await git.removeWorktree(repoRoot, worktreePath, { force: true });
163
216
  }
164
217
  catch (error) {
165
218
  await pruneWorktreesBestEffort(git, repoRoot, warnings);
166
- if (await git.isWorktreeRegistered(repoRoot, worktreePath)) {
167
- throw error;
219
+ let stillRegistered = true;
220
+ try {
221
+ stillRegistered = await git.isWorktreeRegistered(repoRoot, worktreePath);
222
+ }
223
+ catch (inspectionError) {
224
+ warnings.push(`Unable to verify task worktree registration after forced removal: ${describeError(inspectionError)}`);
225
+ }
226
+ if (stillRegistered) {
227
+ warnings.push(`Unable to force-remove Git worktree ${worktreePath}: ${describeError(error)}`);
228
+ }
229
+ else {
230
+ warnings.push(`Git worktree metadata was already cleared for ${worktreePath}; continuing cleanup.`);
168
231
  }
169
- warnings.push(`Git worktree metadata was already cleared for ${worktreePath}; continuing cleanup.`);
170
232
  }
171
233
  }
172
234
  else {
173
235
  await pruneWorktreesBestEffort(git, repoRoot, warnings);
174
236
  }
175
- if (await fs.pathExists(worktreePath)) {
237
+ let staleDirectoryExists = true;
238
+ try {
239
+ staleDirectoryExists = await fs.pathExists(worktreePath);
240
+ }
241
+ catch (error) {
242
+ warnings.push(`Unable to inspect stale task worktree directory ${worktreePath}: ${describeError(error)}`);
243
+ }
244
+ if (staleDirectoryExists) {
176
245
  try {
177
246
  await fs.removePath?.(worktreePath, { recursive: true, force: true });
178
247
  }
@@ -180,19 +249,67 @@ async function removeTaskWorktreeIdempotent(fs, git, repoRoot, worktreePath, for
180
249
  warnings.push(`Unable to remove stale task worktree directory ${worktreePath}: ${describeError(error)}`);
181
250
  }
182
251
  }
252
+ let pathExists = true;
253
+ let registered = true;
254
+ try {
255
+ pathExists = await fs.pathExists(worktreePath);
256
+ }
257
+ catch (error) {
258
+ warnings.push(`Unable to verify task worktree directory cleanup: ${describeError(error)}`);
259
+ }
260
+ try {
261
+ registered = await git.isWorktreeRegistered(repoRoot, worktreePath);
262
+ }
263
+ catch (error) {
264
+ warnings.push(`Unable to verify task worktree metadata cleanup: ${describeError(error)}`);
265
+ }
266
+ return !pathExists && !registered;
183
267
  }
184
- async function deleteTaskBranchIdempotent(git, repoRoot, branch, force) {
185
- if (!(await git.branchExists(repoRoot, branch))) {
186
- return;
268
+ async function deleteTaskBranchBestEffort(git, repoRoot, branch, warnings) {
269
+ let branchExists;
270
+ try {
271
+ branchExists = await git.branchExists(repoRoot, branch);
272
+ }
273
+ catch (error) {
274
+ warnings.push(`Unable to inspect task branch ${branch}: ${describeError(error)}`);
187
275
  }
276
+ if (branchExists === false) {
277
+ return { resolved: true, deleted: false };
278
+ }
279
+ await warnAboutDiscardedCommits(git, repoRoot, branch, warnings);
188
280
  try {
189
- await git.deleteBranch(repoRoot, branch, { force });
281
+ await git.deleteBranch(repoRoot, branch, { force: true });
282
+ return { resolved: true, deleted: true };
190
283
  }
191
284
  catch (error) {
192
- if (!(await git.branchExists(repoRoot, branch))) {
285
+ try {
286
+ if (!(await git.branchExists(repoRoot, branch))) {
287
+ return { resolved: true, deleted: true };
288
+ }
289
+ }
290
+ catch (inspectionError) {
291
+ warnings.push(`Unable to verify task branch cleanup for ${branch}: ${describeError(inspectionError)}`);
292
+ }
293
+ warnings.push(`Unable to force-delete task branch ${branch}: ${describeError(error)}`);
294
+ return { resolved: false, deleted: false };
295
+ }
296
+ }
297
+ async function warnAboutDiscardedCommits(git, repoRoot, branch, warnings) {
298
+ try {
299
+ const baseBranch = await git.getCurrentBranch(repoRoot);
300
+ const commits = await git.getCommitList(repoRoot, `${baseBranch}..${branch}`);
301
+ if (commits.length === 0) {
193
302
  return;
194
303
  }
195
- throw error;
304
+ const summary = commits
305
+ .slice(0, 5)
306
+ .map((commit) => `${commit.sha.slice(0, 8)} ${commit.subject}`)
307
+ .join("; ");
308
+ const remainder = commits.length > 5 ? `; and ${commits.length - 5} more` : "";
309
+ warnings.push(`${branch} has ${commits.length} commit(s) not contained in ${baseBranch}; close force-deletes the branch: ${summary}${remainder}`);
310
+ }
311
+ catch (error) {
312
+ warnings.push(`Unable to inspect commits before force-deleting ${branch}: ${describeError(error)}`);
196
313
  }
197
314
  }
198
315
  async function pruneWorktreesBestEffort(git, repoRoot, warnings) {
@@ -0,0 +1,122 @@
1
+ import { readTranscriptTurnEvidence } from "./claude-transcript-reply.js";
2
+ import { getTaskRuntimeRepoRoot } from "./task-service.js";
3
+ export const TURN_STALL_THRESHOLD_MS = 30 * 60_000;
4
+ export const TURN_INTERRUPT_GRACE_MS = 10_000;
5
+ export function createTurnReconcilerService(deps) {
6
+ const now = deps.now ?? (() => new Date().toISOString());
7
+ const stallThresholdMs = deps.stallThresholdMs ?? TURN_STALL_THRESHOLD_MS;
8
+ const interruptGraceMs = deps.interruptGraceMs ?? TURN_INTERRUPT_GRACE_MS;
9
+ const readEvidence = deps.readTranscriptEvidence ?? readTranscriptTurnEvidence;
10
+ const pendingInterrupts = new Map();
11
+ return {
12
+ async reconcileTask(repoRoot, task, stateRoot) {
13
+ const taskRepoRoot = getTaskRuntimeRepoRoot(task);
14
+ const round = await deps.roundService.getSessionRoundState({
15
+ repoRoot,
16
+ stateRepoRoot: taskRepoRoot,
17
+ stateRoot,
18
+ taskSlug: task.taskSlug
19
+ });
20
+ if (round.status !== "running"
21
+ || !round.activeRole
22
+ || !round.activeTurnStartedAt
23
+ || round.roleRecovery) {
24
+ clearTaskInterrupts(repoRoot, task.taskSlug);
25
+ return { status: "inactive" };
26
+ }
27
+ const role = round.activeRole;
28
+ const interruptKey = `${repoRoot}:${task.taskSlug}:${role}`;
29
+ const session = await deps.sessionService.getRoleSession(repoRoot, task.taskSlug, role);
30
+ const evidence = session
31
+ ? await readEvidence({ ...session, lastTurnStartedAt: round.activeTurnStartedAt })
32
+ : {};
33
+ if (evidence.completion) {
34
+ pendingInterrupts.delete(interruptKey);
35
+ await deps.claudeHookService.handleReconciledTurnEnd(buildReconciledHook(task.taskSlug, role, session, "Stop", {
36
+ vcm_reconcile_reason: "transcript-end-turn",
37
+ vcm_completion_id: evidence.completion.id,
38
+ vcm_completion_at: evidence.completion.timestamp
39
+ }));
40
+ return { status: "completed", role, reason: "transcript-end-turn" };
41
+ }
42
+ if (!session || session.status !== "running") {
43
+ pendingInterrupts.delete(interruptKey);
44
+ const reason = session ? "terminal-session-exited" : "terminal-session-missing";
45
+ await deps.claudeHookService.handleReconciledTurnEnd(buildReconciledHook(task.taskSlug, role, session, "StopFailure", {
46
+ error: reason.replaceAll("-", "_"),
47
+ error_details: `VCM reconciled an active turn because its ${reason.replaceAll("-", " ")}.`
48
+ }));
49
+ return { status: "failed", role, reason };
50
+ }
51
+ const lastActivityAt = latestTimestamp([
52
+ round.activeTurnStartedAt,
53
+ session.lastHookEventAt,
54
+ session.lastOutputAt,
55
+ evidence.lastActivityAt
56
+ ]);
57
+ const currentTime = now();
58
+ if (!isStale(lastActivityAt, currentTime, stallThresholdMs)) {
59
+ pendingInterrupts.delete(interruptKey);
60
+ return { status: "active" };
61
+ }
62
+ const pendingInterrupt = pendingInterrupts.get(interruptKey);
63
+ if (!pendingInterrupt || pendingInterrupt.turnStartedAt !== round.activeTurnStartedAt) {
64
+ deps.runtime.write(session.id, "\u0003");
65
+ pendingInterrupts.set(interruptKey, {
66
+ turnStartedAt: round.activeTurnStartedAt,
67
+ requestedAt: currentTime
68
+ });
69
+ return { status: "active" };
70
+ }
71
+ if (!isStale(pendingInterrupt.requestedAt, currentTime, interruptGraceMs)) {
72
+ return { status: "active" };
73
+ }
74
+ pendingInterrupts.delete(interruptKey);
75
+ await deps.claudeHookService.handleReconciledTurnEnd(buildReconciledHook(task.taskSlug, role, session, "StopFailure", {
76
+ error: "turn_stalled",
77
+ error_details: `No hook, terminal output, or transcript activity was observed for ${stallThresholdMs}ms.`
78
+ }));
79
+ return { status: "failed", role, reason: "turn-stalled" };
80
+ }
81
+ };
82
+ function clearTaskInterrupts(repoRoot, taskSlug) {
83
+ for (const key of pendingInterrupts.keys()) {
84
+ if (key.startsWith(`${repoRoot}:${taskSlug}:`)) {
85
+ pendingInterrupts.delete(key);
86
+ }
87
+ }
88
+ }
89
+ }
90
+ function buildReconciledHook(taskSlug, role, session, eventName, evidence) {
91
+ return {
92
+ taskSlug,
93
+ role,
94
+ event: {
95
+ hook_event_name: eventName,
96
+ ...(session?.claudeSessionId ? { session_id: session.claudeSessionId } : {}),
97
+ ...(session?.transcriptPath ? { transcript_path: session.transcriptPath } : {}),
98
+ ...(session?.cwd ? { cwd: session.cwd } : {}),
99
+ vcm_reconciled: true,
100
+ ...evidence
101
+ }
102
+ };
103
+ }
104
+ function latestTimestamp(values) {
105
+ return values.reduce((latest, value) => {
106
+ if (!value) {
107
+ return latest;
108
+ }
109
+ const valueMs = Date.parse(value);
110
+ const latestMs = latest ? Date.parse(latest) : Number.NaN;
111
+ return Number.isFinite(valueMs) && (!Number.isFinite(latestMs) || valueMs > latestMs)
112
+ ? value
113
+ : latest;
114
+ }, undefined);
115
+ }
116
+ function isStale(lastActivityAt, currentTime, thresholdMs) {
117
+ const activityMs = lastActivityAt ? Date.parse(lastActivityAt) : Number.NaN;
118
+ const currentMs = Date.parse(currentTime);
119
+ return Number.isFinite(activityMs)
120
+ && Number.isFinite(currentMs)
121
+ && currentMs - activityMs >= thresholdMs;
122
+ }