Issue Lifecycle Home
Slop · Internals

Issue Lifecycle: Ready to Merged

Every database write, GitHub API call, file created, skill invoked, and prompt sent - traced step by step from the moment an issue enters the ready queue to the moment its PR merges and the worktree is cleaned up.

11+ phases 15 GitHub API calls 6 skills dispatched SQLite writes at every step Isolated git worktrees SSE live events

The Pipeline

The daemon's poll cycle owns the entire flow. Every transition is a guarded CAS against the Worker row's current status - lost races don't stomp concurrent state.

flowchart TD A([ReadyIssue row]) --> B[Daemon tick\nclaimWorkerWithIssueConfig] B --> C[(Worker: claimed)] C --> D[spawnRunner\nrunWorker] D --> E[createWorktree\n~/.slop/worktrees/owner@name/N/] E --> F[/implement-issue reuse-worktree/] F --> G{verifyGate\nenabled?} G -- yes --> H[/verify-gate reuse-worktree/] H -- pass --> I[remoteShipping\nenableAutoMerge] H -- findings --> H G -- no --> I I --> J[(Worker: waiting_ci)] J --> K[Lifecycle Poll\nevery cycle] K -- CI fails --> L[/fix-ci/] K -- conflict --> M[/resolve-conflict/] K -- CI green --> N{autoReview?} L --> J M --> J N -- yes --> O[/pr-review/] N -- no --> P[(Worker: waiting_merge)] O -- approve --> P O -- request_changes --> Q[/address-comments/] Q --> J P --> R[mergePullRequest] R --> S[closeIssue\nremoveWorktree] S --> T([Worker: merged]) style A fill:#1a1d27,stroke:#6c63ff,color:#e2e4f0 style T fill:#1a1d27,stroke:#2dd4aa,color:#2dd4aa style C fill:#1a1d27,stroke:#a78bfa,color:#a78bfa style J fill:#1a1d27,stroke:#4ea8de,color:#4ea8de style P fill:#1a1d27,stroke:#f5c542,color:#f5c542

Phase-by-Phase Walkthrough

0

Issue becomes ready

ReadyIssue row

The daemon does not poll GitHub labels. An issue enters the queue when the user clicks Set Ready on the board (Server Action setIssueReady in src/app/actions.ts), which writes a ReadyIssue row. Internal issues are enqueued the same way. That row is the only gate the daemon reads.

1

Daemon tick claims the issue

src/server/daemon/tick.ts status: claimed

Every poll cycle (default 30 s), runTick() runs only when autoMode === "true". It reads parallelismCap and compares against active workers per repo, then claims from the ReadyIssue queue in drag-sorted order.

// src/server/daemon/claim-worker.ts
const worker = await claimWorkerWithIssueConfig(deps, repoId, {
  number: issue.number, title: issue.title, htmlUrl: issue.htmlUrl
});
// 1. Reads IssueConfig for per-issue overrides (model, mode, review config)
// 2. workerRepo.upsertWorker() → Worker row, status = "claimed"
// 3. Deletes IssueConfig row
await deps.eventBus.publish({ type: "worker.claimed", workerId: worker.id, ... });
deps.spawnRunner(worker.id); // reserve registry slot → runWorker() async

DB write: Worker row created, status = "claimed".

2

Worktree creation

src/server/workers/worktree.ts status: implementing

The worktree path is persisted to the DB before the directory exists so the orphan reaper can never reap it mid-creation. Then the git worktree is created under a repo lock.

// Path schema: ~/.slop/worktrees/<owner@name>/<issueNumber>/
await workerRepo.setWorkerWorktreePath(workerId, worktreePathFor(repoId, issueNumber));

// Inside withRepoLock():
// git fetch origin main
// git worktree add --force -B slop/issue-42 <path> origin/main
const handle = await deps.createWorktree(repoId, issueNumber);

// Outside lock: auto-install deps
// pnpm-lock.yaml → pnpm install --frozen-lockfile
// yarn.lock      → yarn install --frozen-lockfile
// package-lock.json → npm ci

// Copy .env* files from main checkout into the worktree
// find "$MAIN" -maxdepth 1 -name '.env*' -type f -exec cp -n {} "$WT/" \;

Folder created: ~/.slop/worktrees/<owner@name>/<N>/
Branch created: slop/issue-<N> off origin/<baseBranch>
File copied: .env.local into the worktree
DB write: Worker.worktreePath set, then Worker.status = "implementing"

Issue body injection: Before the agent session, Slop fetches the issue body to pre-load as @-file context. For GitHub issues: GET /repos/{owner}/{repo}/issues/{N}. For internal issues: GET http://localhost:3100/api/internal-issues?repo=<slug>.
3

/implement-issue agent session

src/server/runs/driver.ts kind: implement

A Run row is created (kind = "implement"), then runSkillSession() launches the agent subprocess in the worktree directory.

// Prompt sent to agent:
"/implement-issue reuse-worktree 42"
// cwd: ~/.slop/worktrees/owner@name/42/
// SLOP_URL, ANTHROPIC_API_KEY, GITHUB_TOKEN injected
// UNATTENDED_SYSTEM_PROMPT injected (forbids AskUserQuestion)
// PreToolUse hook blocks: git stash + AskUserQuestion

What the skill does, step by step:

Step 1 - Validate the issue

# Fetch from internal store (never gh issue view - numbers collide)
curl -fsS "${SLOP_URL}/api/internal-issues?repo=$SLUG" \
  | jq '.[] | select(.number == 42)'

Step 2 - Resolve mode. reuse-worktree is active (passed by the daemon), so the agent reads the shipping reference from harness/skills/core/implement-issue/references/remote-mode.md and skips worktree setup.

Step 3 - Worktree setup (--reuse path).

source "$CLAUDE_SKILL_DIR/scripts/worktree-setup.sh" --reuse
# Exports: IMPLEMENT_ISSUE_MAIN, IMPLEMENT_ISSUE_WT, IMPLEMENT_ISSUE_BRANCH

Step 4 - Execute. Reads CLAUDE.md, ARCHITECTURE.md, docs/surface-map.md, and all source files named in the issue in one parallel batch. Implements all acceptance criteria. Runs the combined quality gate derived from .github/workflows/:

{ pnpm exec biome check --write .
  && pnpm exec biome check .
  && pnpm typecheck
  && pnpm test:coverage
} 2>&1 | grep -E "FAIL|passed|error TS|^Error"

If the diff touched frontend files, runs /design-pass.

Step 6 - Completion (remote mode).

git add <file1> <file2> ...
git commit -m "feat: <summary>"
git push --force-with-lease -u origin "$IMPLEMENT_ISSUE_BRANCH"
gh pr create --title "<title>" --body "## Summary\n- ...\n\n## How to test\n- ..."
# No "Closes #N" - Slop manages issue close directly

Events streamed to Slop: system.init (captures sessionId), assistant messages (captures file changes), result (captures cost/tokens/summary).
DB writes: Worker.sessionId set, WorkerReport upserted, WorkerReport.implementGateSha set.

4b

Verify gate (conditional - config: verifyGate)

src/server/workers/run-verify.ts kind: verify status: verifying

Before the verify session, a context file is written to the worktree root so the agent inherits state without needing it in the prompt.

// File: ~/.slop/worktrees/owner@name/N/.slop-verify-context.json
{
  "issueNumber": 42,
  "docsOnly": false,
  "implementGateSha": "abc1234",  // HEAD after implement
  "context": "agent summary...",
  "findings": null             // null on first round, prior issues on re-dispatch
}

Prompt: /verify-gate reuse-worktree

The skill reads the context, runs /cover (missing test coverage) + /review (acceptance criteria). If implementGateSha matches HEAD, the test suite is skipped (already green). Ends with exactly one final line:

SLOP_VERDICT: pass      // or
SLOP_VERDICT: findings  // missing/unparseable verdict → findings (fail-open-safe)

Routing: pass → proceed to shipping. findings → increment Worker.verifyAttempts, re-dispatch implement with findings seeded in the context file. Budget: maxVerifyAttempts (default 5).

5

Remote shipping

src/server/workers/shipping.ts status: waiting_ci

After the implement session (and optional verify), remoteShipping.finishImplementation() runs. It finds the PR the agent opened, arms auto-merge, and parks the worker.

// 1. Find the PR - up to 5 retries × 3 s (pulls.list lags PR creation)
prNumber = await githubClient.findOpenPullRequestForIssue(issueNumber, handle.branch);

// Recovery: agent exited without pushing → Slop commits + creates PR
// GitHub API: POST /repos/{owner}/{repo}/pulls
prNumber = await githubClient.createPullRequest({ title, body, head, base });

// 2. Enable auto-merge (squash)
// GitHub GraphQL: mutation enablePullRequestAutoMerge
await githubClient.enableAutoMerge(prNumber);

// If PR is already clean (mergeable right now):
// GitHub API: PUT /repos/{owner}/{repo}/pulls/{N}/merge
await githubClient.mergePullRequest(prNumber);

// 3. Park and let the lifecycle poll drive from here
await transitionToWaitingCi(workerId, prNumber, deps);

DB write: Worker.status = "waiting_ci", Worker.prNumber set.

6

Lifecycle poll - CI gate

src/server/daemon/lifecycle-poll.ts

Every daemon cycle, runLifecyclePoll() reconciles all non-terminal workers with GitHub. Workers run in parallel with a concurrency cap of 5.

GitHub StateAction
PR already mergedFinalize → merged, close issue, remove worktree
PR closed (not merged)stopWorker()cancelled
Issue closed out-of-bandstopWorker()cancelled
Merge conflict (dirty)transitionToResolvingConflict() - daemon spawns /resolve-conflict
CI check failedtransitionToFixingCi() - daemon spawns /fix-ci
CI green + autoReview ontransitionToWaitingReview() - daemon spawns /pr-review
CI green + autoReview offtransitionToWaitingMerge()
CI still pendingWait for next cycle
// GitHub API: GET /repos/{owner}/{repo}/pulls/{N}
const pr = await client.getPullRequest(worker.prNumber);

// GitHub API: GET /repos/{owner}/{repo}/commits/{sha}/check-runs
const checks = await client.listCheckRuns(pr.headSha);
// Collapses to latest attempt per check name (absorbs re-run stale results)

CI Fix, Conflict Resolution, Review, Address

These phases run on demand when the lifecycle poll detects problems. Each rebuilds the worktree from the PR head branch, dispatches a skill, and returns the worker to waiting_ci.

7

CI fix

kind: ci_fix status: fixing_ci budget: 5 attempts

Triggered when a CI check run has conclusion failure, cancelled, or timed_out. Worktree is rebuilt from PR head. Failing check's output.summary (truncated to 2 000 chars) is appended to the prompt.

// Prompt sent:
"/fix-ci 42 main --check 'typecheck' reuse-worktree"

// Skill steps:
// 1. git fetch origin main + git rebase origin/main
// 2. Reproduce failing check locally
// 3. Fix root cause
// 4. git push --force-with-lease

After session: Worker.ciAttempts++. If ciAttempts >= maxCiAttemptstransitionToFailed().

8

Conflict resolution

kind: conflict status: resolving_conflict budget: 5 attempts

Triggered when the PR's mergeable_state === "dirty". Worktree is rebuilt from PR head, then the agent rebases and resolves conflicts in place.

// Prompt sent:
"/resolve-conflict 42 main reuse-worktree"

// Skill steps:
// 1. git fetch origin main
// 2. git rebase origin/main
// 3. Resolve conflicts (edit → git add → git rebase --continue)
// 4. git push --force-with-lease
// Never: git rebase -i, git stash, git rebase-merge hand-edit

After session: Worker.conflictAttempts++. If exhausted → transitionToFailed().

9

PR review (conditional - config: autoReviewMode)

kind: pr_review status: in_review budget: 3 attempts
// Prompt sent:
"/pr-review 42 reuse-worktree"

// Skill API calls:
gh pr view "$PR" --json number,title,body,headRefName,baseRefName,state,statusCheckRollup
gh pr diff "$PR"
curl -fsS "$SLOP_URL/api/internal-issues?repo=$SLUG" | jq '.[] | select(.number == $N)'

// On re-review: also walks threads
// GitHub API: GET /repos/{owner}/{repo}/pulls/{N}/reviews
// GitHub API: GET /repos/{owner}/{repo}/pulls/{N}/comments

// Posts ONE comment-event review:
// GitHub API: POST /repos/{owner}/{repo}/pulls/{N}/reviews
// Body includes: reviewer: **Verdict: Approve** (or Request changes)

Verdict parsed by Slop from getLatestPrReviewVerdict(prNumber). Stale verdicts (commitId ≠ PR headSha) are ignored.

VerdictNext state
approve / approve_with_commentswaiting_merge
request_changeswaiting_address → spawns Phase 10
10

Address comments

kind: pr_address status: in_address
// Prompt sent:
"/address-comments 42 reuse-worktree"

// Skill steps:
// 1. gh api "repos/{owner}/{repo}/pulls/{N}/comments" --paginate
// 2. gh api "repos/{owner}/{repo}/pulls/{N}/reviews" --paginate
// 3. For each open reviewer: thread → fix / decline / question
// 4. Fix commits: "fix: ... (review)" using conventional-commits format
// 5. git push (once, at end)
// 6. Reply in-thread:
//    gh api .../comments/{id}/replies -f body='addresser: fixed in <sha>...'

After session: Worker returns to waiting_ci. The loop is /pr-review → /address-comments → /pr-review until no blockers remain or the review budget (3) is exhausted.

Phase 11 - Merge and finalize

GitHub fires the auto-merge armed in Phase 5 once CI passes. Alternatively, the operator clicks Merge on the board (Server Action mergeWorker()).

// GitHub API: PUT /repos/{owner}/{repo}/pulls/{N}/merge
await githubClient.mergePullRequest(prNumber);

// Lifecycle poll detects pr.merged === true on the next cycle:
await transitionToMerging(workerId, deps);
await transitionToMerged(workerId, prNumber, deps);

// Close the issue
// GitHub issue: PATCH /repos/{owner}/{repo}/issues/{N} (state: "closed")
// Internal issue: internalIssueRepo.closeInternalIssue(worker.internalIssueId)
await closeWorkerIssue(worker, githubClient, internalIssueRepo, logger);

// Remove the worktree
// git worktree remove --force <path>
// git worktree prune
// git branch -D slop/issue-N
// rm -rf <path>
await deps.removeWorktree(handle.path);

If autoReportMode is on, a run_report Run fires after merge to synthesize a performance summary and file it as an issue in the watched repo.

Folder deleted: ~/.slop/worktrees/<owner@name>/<N>/ - the entire worktree is removed. DB write: Worker.status = "merged".

Skills Dispatched

Every skill lives in harness/skills/core/ and is symlinked into ~/.claude/skills/ by harness/bootstrap.sh.

/implement-issue
Phase 3 - always runs
/implement-issue reuse-worktree <issueNumber>

Fetches the issue from the internal HTTP store, reads orientation docs, implements all acceptance criteria, runs the combined quality gate (format + lint + typecheck + tests + design-pass for UI), commits, pushes, and opens a PR. Never adds "Closes #N" - Slop manages issue close.

/verify-gate
Phase 4b - when verifyGate = true
/verify-gate reuse-worktree

Reads .slop-verify-context.json, runs /cover (new test coverage) and /review (acceptance-criteria check). If implementGateSha matches HEAD, skips redundant test re-run. Ends with exactly one verdict line that Slop machine-reads. A missing/malformed verdict resolves to findings (fail-open-safe).

/resolve-conflict
Phase 8 - when PR has merge conflicts
/resolve-conflict <N> <base> [--local] reuse-worktree

Fetches the base branch, rebases the issue branch onto it, and resolves every conflict in place. Never uses git stash, git rebase -i, or interactive commands. Force-pushes when the working tree is clean and checks pass locally.

/fix-ci
Phase 7 - when a CI check fails
/fix-ci <N> <base> [--check <name>] reuse-worktree

Rebases onto base, reproduces the failing check locally (from CI config), fixes the root cause (never weakens tests), re-runs until passing, and force-pushes. The failing check's output.summary is appended to the prompt as diagnostic context.

/pr-review
Phase 9 - when autoReviewMode = true
/pr-review <prNumber> reuse-worktree

Judges the PR against the issue's acceptance criteria, then collects findings (correctness, scope, tests) with an adversarial verification pass on every blocker. Posts one COMMENT-event review with inline findings. Emits a machine-readable reviewer: **Verdict: …** line Slop parses to route to merge or address-comments.

/address-comments
Phase 10 - after request_changes verdict
/address-comments <prNumber> reuse-worktree

Reads every reviewer: thread. For each: fixes (commit + addresser: fixed in <sha> reply), declines with evidence (addresser: declined - <file:line>), or questions (addresser: question - …). Pushes once at end. Posts a round-summary top-level comment. Worker returns to waiting_ci.

Complete GitHub API Call Inventory

Calls made by src/server/github/client.ts (Octokit REST + GraphQL) and by the agent process (gh CLI in skill scripts).

GET /repos/{owner}/{repo}/issues/{N} Fetch issue body for @-file injection (Phase 2)
GET /repos/{owner}/{repo}/pulls?state=open&head=… Find open PR for issue - up to 5 retries × 3 s (Phase 5)
POST /repos/{owner}/{repo}/pulls Create PR (recovery path - agent exited without opening one) (Phase 5)
GQL mutation enablePullRequestAutoMerge (SQUASH) Arm auto-merge on the PR (Phase 5)
PUT /repos/{owner}/{repo}/pulls/{N}/merge Direct merge when PR is already clean (Phase 5)
GET /repos/{owner}/{repo}/pulls/{N} Poll PR state - merged, conflict, head SHA (Phase 6, lifecycle poll)
GET /repos/{owner}/{repo}/commits/{sha}/check-runs List CI checks - collapses to latest attempt per name (Phase 6)
GET /repos/{owner}/{repo}/check-runs/{id} Fetch failing check's output.summary for /fix-ci prompt (Phase 7)
GET /repos/{owner}/{repo}/pulls/{N}/reviews Get /pr-review verdict (newest-first, anchored to headSha) (Phase 9)
GET /repos/{owner}/{repo}/pulls/{N}/comments Read inline reviewer: threads for re-review and /address-comments (Phase 9/10)
POST /repos/{owner}/{repo}/pulls/{N}/reviews Post COMMENT-event review with inline findings (Phase 9)
POST /repos/{owner}/{repo}/pulls/{N}/comments/{id}/replies Reply in-thread: addresser: fixed in / declined / question (Phase 10)
GQL mutation resolveReviewThread Resolve settled reviewer: threads on re-review (Phase 9)
PUT /repos/{owner}/{repo}/pulls/{N}/merge Operator-triggered merge (Phase 11)
PATCH /repos/{owner}/{repo}/issues/{N} Close issue (state: "closed") after merge (Phase 11)

Files and Folders Created

~/.slop/worktrees/<owner@name>/<N>/
Created: Phase 2 (createWorktree)  ·  Deleted: Phase 11 (removeWorktree)

Isolated git worktree for the issue. Branch: slop/issue-<N> off origin/<baseBranch>. The / in owner/name is encoded as @ to keep the path two levels deep.

~/.slop/worktrees/…/<N>/.env.local
Created: Phase 2 (copied from main checkout, best-effort)

Carries DATABASE_URL and other local env vars the agent needs. cp -n skips files already present. If absent, seeded from .env.example.

~/.slop/worktrees/…/<N>/.slop-verify-context.json
Created: Phase 4b (before each verify session)

Written by Slop before each /verify-gate dispatch. Contains issueNumber, docsOnly, implementGateSha (HEAD after implement), context (agent summary), and findings (null on first round, prior issues on re-dispatch).

node_modules/ (worktree-local)
Created: Phase 2 (installDepsInWorktree, if lockfile present)

Auto-installed after worktree creation. pnpm install --frozen-lockfile (or yarn/npm equivalent). Skipped on worktree resume - untracked files survive rebuild.

Agent-edited source files
Created/modified: Phase 3 (/implement-issue agent)

The agent's Edit, Write, and MultiEdit tool calls inside the worktree. All tracked as FileChange entries and persisted to WorkerReport.filesChanged.

Database Write Inventory

All status transitions use updateWorkerStatusFrom() - an atomic compare-and-swap that only writes when the Worker is in the expected state, preventing lost-race stomps.

Phase 1 - Claim
WorkerRow created: status = "claimed", all per-issue overrides copied from IssueConfig
IssueConfigRow deleted (now canonical in Worker)
Phase 2 - Worktree
WorkerworktreePath set (pre-creation)
WorkerworktreePath updated (post-creation)
Workerstatus = "implementing"
RunRow created: kind = "implement", status = "running"
Phase 3 - Implement session (streaming)
WorkersessionId + sessionHarness set on first system.init message
WorkeragentPid set on subprocess spawn (orphan reaping)
EventRow appended for each summarized driver event (assistant text, result)
WorkerReportUpserted: filesChanged, summary, costUsd, inputTokens, outputTokens, numTurns
WorkerReportimplementGateSha set (HEAD SHA after implement)
RunRow closed: status = "completed" | "failed"
Phase 4b - Verify (conditional)
Workerstatus = "verifying"
RunRow created: kind = "verify"
WorkerverifyAttempts++ (on findings re-dispatch)
Phase 5 - Shipping
Workerstatus = "waiting_ci", prNumber set
Conditional phases (as triggered)
Workerstatus = "fixing_ci"; Run kind = "ci_fix"; ciAttempts++
Workerstatus = "resolving_conflict"; Run kind = "conflict"; conflictAttempts++
Workerstatus = "waiting_review" → "in_review"; Run kind = "pr_review"; reviewAttempts++
Workerstatus = "in_address"; Run kind = "pr_address"
Phase 11 - Merge
Workerstatus = "merging"
Workerstatus = "reporting" (if autoReportMode on)
Workerstatus = "merged"
InternalIssuestate = "closed" (internal issues only)

Status Vocabulary

Terminal statuses: merged · failed · cancelled. Display overrides: prState === "merged" → show merged regardless of internal status; issueState === "closed" → show issue_closed.

claimed └─▶ implementing └─▶ verifying (conditional - verifyGate config) └─▶ waiting_ci (prNumber stored; lifecycle poll takes over) ├─▶ fixing_ci ──── /fix-ci ────▶ waiting_ci ├─▶ resolving_conflict ──── /resolve-conflict ────▶ waiting_ci ├─▶ waiting_review └─▶ in_review ──── /pr-review ├─▶ waiting_merge (approved) └─▶ waiting_address └─▶ in_address ──── /address-comments ────▶ waiting_ci └─▶ waiting_merge (no review gate, or auto-merge off) └─▶ merging └─▶ reporting (optional - autoReportMode) └─▶ merged ✓ failed ✗ (from any state - budget exhausted or unrecoverable error) cancelled ✗ (operator stop, or issue/PR closed out-of-band) paused (operator pause - in-flight work intact)
Resume on restart: When the daemon restarts, any worker whose agentPid is still live is reaped first. Workers in implementing or verifying are re-dispatched with resumeSessionId set - the harness picks up the surviving session rather than re-implementing from scratch. Codex workers (no session persistence) restart fresh from the surviving worktree.