diff --git a/src/resources/extensions/gsd/metrics.ts b/src/resources/extensions/gsd/metrics.ts index f27f34b00..ba86c7ab6 100644 --- a/src/resources/extensions/gsd/metrics.ts +++ b/src/resources/extensions/gsd/metrics.ts @@ -164,7 +164,7 @@ export function snapshotUnitMetrics( // Count tool calls in this message if (msg.content && Array.isArray(msg.content)) { for (const block of msg.content) { - if (block.type === "tool_call") toolCalls++; + if (block.type === "toolCall") toolCalls++; } } } else if (msg.role === "user") { diff --git a/src/resources/extensions/gsd/tests/auto-worktree.test.ts b/src/resources/extensions/gsd/tests/auto-worktree.test.ts index cb21d4f2b..1966c00bf 100644 --- a/src/resources/extensions/gsd/tests/auto-worktree.test.ts +++ b/src/resources/extensions/gsd/tests/auto-worktree.test.ts @@ -172,6 +172,29 @@ async function main(): Promise { teardownAutoWorktree(tempDir, "M005"); } + // ─── #1713: stale worktree directory recovery ───────────────────── + console.log("\n=== #1713: stale worktree directory without .git file ==="); + { + // Simulate a crash leaving a stale directory with no .git file. + // createAutoWorktree should detect and remove the stale directory, + // then successfully create a fresh worktree. + const { worktreePath } = await import("../worktree-manager.ts"); + const staleDir = worktreePath(tempDir, "M010"); + mkdirSync(staleDir, { recursive: true }); + // Write a dummy file to prove it's not an empty directory + writeFileSync(join(staleDir, "orphan.txt"), "stale leftover\n"); + assertTrue(existsSync(staleDir), "stale directory exists before recovery"); + assertTrue(!existsSync(join(staleDir, ".git")), "stale directory has no .git file"); + + // createAutoWorktree should remove the stale dir and create a real worktree + const recoveredPath = createAutoWorktree(tempDir, "M010"); + assertTrue(existsSync(recoveredPath), "worktree created after stale dir recovery"); + assertTrue(existsSync(join(recoveredPath, ".git")), "recovered worktree has .git file"); + assertTrue(!existsSync(join(recoveredPath, "orphan.txt")), "stale file removed by recovery"); + + teardownAutoWorktree(tempDir, "M010"); + } + // ─── #778: reconcile plan checkboxes on re-attach ───────────────── console.log("\n=== #778: reconcile plan checkboxes on re-attach ==="); { diff --git a/src/resources/extensions/gsd/tests/metrics.test.ts b/src/resources/extensions/gsd/tests/metrics.test.ts index 801bd7adb..98782460e 100644 --- a/src/resources/extensions/gsd/tests/metrics.test.ts +++ b/src/resources/extensions/gsd/tests/metrics.test.ts @@ -333,4 +333,52 @@ test("snapshotUnitMetrics handles simulated idle-watchdog duplicate pattern", () resetMetrics(); rmSync(tmpBase, { recursive: true, force: true }); } -}); \ No newline at end of file +}); + +// ── toolCall block counting ───────────────────────────────────────────────── + +test("snapshotUnitMetrics counts toolCall blocks correctly (#1713)", () => { + const tmpBase = mkdtempSync(join(tmpdir(), "gsd-metrics-toolcall-")); + mkdirSync(join(tmpBase, ".gsd"), { recursive: true }); + + try { + resetMetrics(); + initMetrics(tmpBase); + + const ctx = mockCtx([ + { role: "user", content: "Do something" }, + { + role: "assistant", + content: [ + { type: "text", text: "Let me help." }, + { type: "toolCall", name: "Read", input: { file: "foo.ts" } }, + { type: "toolCall", name: "Edit", input: { file: "bar.ts" } }, + ], + usage: { + input: 1000, output: 500, cacheRead: 0, cacheWrite: 0, totalTokens: 1500, + cost: 0.01, + }, + }, + { + role: "assistant", + content: [ + { type: "toolCall", name: "Bash", input: { command: "ls" } }, + { type: "text", text: "All done." }, + ], + usage: { + input: 800, output: 300, cacheRead: 0, cacheWrite: 0, totalTokens: 1100, + cost: 0.008, + }, + }, + ]); + + const unit = snapshotUnitMetrics(ctx, "execute-task", "M001/S01/T01", Date.now() - 3000, "test-model"); + assert.ok(unit); + assert.equal(unit!.toolCalls, 3, "should count 3 toolCall blocks across 2 assistant messages"); + assert.equal(unit!.assistantMessages, 2); + assert.equal(unit!.userMessages, 1); + } finally { + resetMetrics(); + rmSync(tmpBase, { recursive: true, force: true }); + } +}); diff --git a/src/resources/extensions/gsd/worktree-manager.ts b/src/resources/extensions/gsd/worktree-manager.ts index 191676ccf..6c54b90b9 100644 --- a/src/resources/extensions/gsd/worktree-manager.ts +++ b/src/resources/extensions/gsd/worktree-manager.ts @@ -15,7 +15,7 @@ * 4. remove() — git worktree remove + branch cleanup */ -import { existsSync, mkdirSync, readFileSync, realpathSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, realpathSync, rmSync } from "node:fs"; import { join, resolve, sep } from "node:path"; import { GSDError, GSD_PARSE_ERROR, GSD_STALE_STATE, GSD_LOCK_HELD, GSD_GIT_ERROR, GSD_MERGE_CONFLICT } from "./errors.js"; import { @@ -129,7 +129,19 @@ export function createWorktree(basePath: string, name: string, opts: { branch?: const branch = opts.branch ?? worktreeBranchName(name); if (existsSync(wtPath)) { - throw new GSDError(GSD_STALE_STATE, `Worktree "${name}" already exists at ${wtPath}`); + // A valid git worktree has a .git file (not directory) containing a + // "gitdir:" pointer. If the directory exists but has no .git file, + // it is a stale leftover from a prior crash — remove it so a fresh + // worktree can be created in its place. + const gitFilePath = join(wtPath, ".git"); + if (!existsSync(gitFilePath)) { + console.error( + `[GSD] Removing stale worktree directory (no .git file): ${wtPath}`, + ); + rmSync(wtPath, { recursive: true, force: true }); + } else { + throw new GSDError(GSD_STALE_STATE, `Worktree "${name}" already exists at ${wtPath}`); + } } // Ensure the .gsd/worktrees/ directory exists