Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions packages/opencode/src/tasks/pulse.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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({
Expand Down Expand Up @@ -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) {
Expand Down
88 changes: 79 additions & 9 deletions packages/opencode/test/tasks/pulse.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
})
})
})
})
})