diff --git a/packages/opencode/src/tasks/pulse.ts b/packages/opencode/src/tasks/pulse.ts index 911344ad95c5..734e84aa3eca 100644 --- a/packages/opencode/src/tasks/pulse.ts +++ b/packages/opencode/src/tasks/pulse.ts @@ -583,11 +583,12 @@ async function commitTask(task: Task, projectId: string, pmSessionId: string): P } const commitMsg = `feat(taskctl): ${task.title} (#${task.parent_issue})` - const opsPrompt = `Commit all changes in the current directory. + const opsPrompt = `Commit all changes in the worktree directory: ${task.worktree} Commit message: "${commitMsg}" Do NOT push to remote. Only commit locally. +Use ${task.worktree} as the working directory for all bash commands (workdir parameter). Run: git add -A && git commit -m "${commitMsg}" -If there is nothing to commit, that is fine — report success.` +If there is an error, report the full error output.` try { await SessionPrompt.prompt({ @@ -627,6 +628,31 @@ If there is nothing to commit, that is fine — report success.` return } + // Read final ops session message to verify commit + const msgs = await Array.fromAsync(MessageV2.stream(opsSession.id)) + const last = msgs.find((m) => m.info.role === "assistant") + const textPart = last?.parts.find((p): p is MessageV2.TextPart => p.type === "text" && !p.synthetic) + const text = textPart?.text ?? "" + + // Only verify if ops produced output — empty means no messages available, treat as success + if (text) { + const nothingToCommit = /nothing to commit/i.test(text) + const hasCommitHash = /\b[0-9a-f]{7,40}\b/.test(text) + const hasFatal = /fatal|error/i.test(text) + + if (nothingToCommit) { + log.error("@ops reported nothing to commit", { taskId: task.id }) + await escalateCommitFailure(task, projectId, pmSessionId, "Nothing to commit — developer changes not found in worktree") + return + } + + if (hasFatal && !hasCommitHash) { + log.error("@ops commit failed", { taskId: task.id, output: text.substring(0, 200) }) + await escalateCommitFailure(task, projectId, pmSessionId, `Commit failed: ${text.substring(0, 200)}`) + return + } + } + if (task.worktree) { const safeWorktree = sanitizeWorktree(task.worktree) if (safeWorktree) { diff --git a/packages/opencode/test/tasks/pulse.test.ts b/packages/opencode/test/tasks/pulse.test.ts index 44ec5e2c5066..86dda98f6533 100644 --- a/packages/opencode/test/tasks/pulse.test.ts +++ b/packages/opencode/test/tasks/pulse.test.ts @@ -376,15 +376,85 @@ describe("pulse.ts", () => { }) }) - describe("lock file integrity", () => { - test("writeLockFile overwrites existing lock", async () => { - const { writeLockFile, readLockPid } = await import("../../src/tasks/pulse") + describe("lock file integrity", () => { + test("writeLockFile overwrites existing lock", async () => { + const { writeLockFile, readLockPid } = await import("../../src/tasks/pulse") + + await writeLockFile(TEST_JOB_ID, TEST_PROJECT_ID, 1000) + await writeLockFile(TEST_JOB_ID, TEST_PROJECT_ID, 2000) + + const pid = await readLockPid(TEST_JOB_ID, TEST_PROJECT_ID) + expect(pid).toBe(2000) + }) + }) + + describe("commitTask", () => { + test("commit verification: empty text (no ops output) is treated as success", async () => { + // When ops session produces no messages, text is empty string. + // The new logic should treat this as success (don't escalate), + // only escalate on explicit "nothing to commit" or fatal errors. + const text = "" + + // Should NOT escalate with empty text + const nothingToCommit = /nothing to commit/i.test(text) + const hasCommitHash = /\b[0-9a-f]{7,40}\b/.test(text) + const hasFatal = /fatal|error/i.test(text) + + expect(nothingToCommit).toBe(false) + expect(hasCommitHash).toBe(false) + expect(hasFatal).toBe(false) + + // Empty text doesn't trigger escalation (only escalates if text is set AND condition is met) + const shouldEscalate = !!(text && (nothingToCommit || (hasFatal && !hasCommitHash))) + expect(shouldEscalate).toBe(false) + }) + + test("commit verification: 'nothing to commit' message escalates", async () => { + const text = "On branch main\nnothing to commit, working tree clean" + + const nothingToCommit = /nothing to commit/i.test(text) + const hasCommitHash = /\b[0-9a-f]{7,40}\b/.test(text) + const hasFatal = /fatal|error/i.test(text) + + expect(nothingToCommit).toBe(true) + expect(hasCommitHash).toBe(false) + expect(hasFatal).toBe(false) + + // Should escalate with "nothing to commit" + const shouldEscalate = !!(text && (nothingToCommit || (hasFatal && !hasCommitHash))) + expect(shouldEscalate).toBe(true) + }) - await writeLockFile(TEST_JOB_ID, TEST_PROJECT_ID, 1000) - await writeLockFile(TEST_JOB_ID, TEST_PROJECT_ID, 2000) + test("commit verification: fatal error without commit hash escalates", async () => { + const text = "fatal: not a git repository" + + const nothingToCommit = /nothing to commit/i.test(text) + const hasCommitHash = /\b[0-9a-f]{7,40}\b/.test(text) + const hasFatal = /fatal|error/i.test(text) + + expect(nothingToCommit).toBe(false) + expect(hasCommitHash).toBe(false) + expect(hasFatal).toBe(true) + + // Should escalate with fatal error and no commit hash + const shouldEscalate = !!(text && (nothingToCommit || (hasFatal && !hasCommitHash))) + expect(shouldEscalate).toBe(true) + }) - const pid = await readLockPid(TEST_JOB_ID, TEST_PROJECT_ID) - expect(pid).toBe(2000) + test("commit verification: commit hash with error does not escalate", async () => { + const text = "[main abc1234] Commit message\nError: something minor" + + const nothingToCommit = /nothing to commit/i.test(text) + const hasCommitHash = /\b[0-9a-f]{7,40}\b/.test(text) + const hasFatal = /fatal|error/i.test(text) + + expect(nothingToCommit).toBe(false) + expect(hasCommitHash).toBe(true) + expect(hasFatal).toBe(true) + + // Should NOT escalate if we have commit hash (commit succeeded) + const shouldEscalate = !!(text && (nothingToCommit || (hasFatal && !hasCommitHash))) + expect(shouldEscalate).toBe(false) + }) }) - }) -}) \ No newline at end of file + }) \ No newline at end of file