* fix: gate step_complete_kandev tool on per-step signal flag
Two ADR-0015 follow-up bugs:
- "Wait for agent completion signal" checkbox snapped back because
updateWorkflowStepAction dropped auto_advance_requires_signal from the
PUT body. Add it to the Pick<>, body builder, BackendWorkflowStep, and
transformWorkflowStep.
- step_complete_kandev MCP tool was injected into every first-turn
prompt, polluting context and tempting agents on non-signal-gated
steps to call it. Replace the static description in
kandev-context.md with a {step_complete_section} placeholder filled
only when the current step has auto_advance_requires_signal=true.
Add orchestrator.Service.StepRequiresCompletionSignal helper and
thread it through all four InjectKandevContext call sites.
* fix: address PR 1300 review feedback
- Hoist ProcessOnTurnStart above the first-turn MCP wrap so an
on_turn_start step transition is reflected in the
auto_advance_requires_signal flag baked into the stored/forwarded
prompt (coderabbit, cubic).
- Add Service.WorkflowStepRequiresCompletionSignal step-ID variant and
use it from task_operations.go / autoStartStepPrompt to skip the
redundant GetTask round-trip at hot first-turn launch sites (claude).
- Add table-driven tests covering every silent-false branch and the
positive path of both helpers (greptile, claude, cubic).
* docs(sysprompt): document stepCompleteSection trailing newline contract
Address Claude review suggestion on PR 1300: make explicit that the
constant must end with "\n" because the kandev-context.md template inlines
{step_complete_section} immediately before the next bullet, so the
trailing newline is what separates the two list items.
* fix: restore task-id lookup at startTask wrap site
Address PR 1300 review regression: the previous swap to
WorkflowStepRequiresCompletionSignal(ctx, workflowStepID) silently hid
step_complete_kandev on manual starts where the workflowStepID parameter
is empty but the task is already bound to a signal-gated step in the DB.
The `task` variable in scope at this call site is `*v1.Task` (from the
scheduler) and does not carry WorkflowStepID, so the cheaper step-ID
variant is not applicable here — go back through StepRequiresCompletionSignal
so the repo lookup pulls the canonical step.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(workflow): explicit step-completion signal for auto-advance
Adds `step_complete_kandev` MCP tool + `WorkflowStep.auto_advance_requires_signal`
column so workflow steps can gate their `on_turn_complete` transitions on an
agent-emitted completion signal instead of inferring "done" from any turn-end.
See docs/decisions/0015-explicit-completion-signal-for-auto-advance.md for the
motivation, alternatives considered, and trade-offs.
* fix(workflow): address PR review feedback for explicit completion signal
- Dedup `LoadPendingStepSignal` into `internal/task/models` so the MCP
handler (write site) and the orchestrator (read site) share one decoder.
- Gate the out-of-band subscriber on `WorkflowStep.AutoAdvanceRequiresSignal`
so `step_complete_kandev` is not an unintended manual-advance path for
signalless steps.
- Fail closed on step-load errors in `processOnTurnCompleteViaEngine`: a
loader hiccup must block the transition rather than silently bypass the
gate.
- Reject `step_complete_kandev` for terminal sessions
(Completed / Failed / Cancelled) so the agent gets a clear error instead
of `accepted: true` followed by a no-op.
- Surface bus-publish failures back to the agent instead of swallowing
them — the idempotency guard makes the retry safe and prevents the
session from getting stuck.
- New `TestOnStepCompletionSignaled` covers the subscriber paths
(RUNNING / WAITING+match / stale step / not signal-gated).
- ADR doc fixes: payload field list now matches the wire shape, writer
ownership is documented, the unsubstantiated 250ms-debounce claim is
removed, and the late-signal re-close race is recorded as a deferred
open question.
- Drop the new toggle's HelpTip in favour of an inline description — the
shadcn tooltip portal was intercepting clicks on the agent-profile
dropdown options in the workflow settings E2E.
* fix(workflow): round-2 review fixes for explicit completion signal
- Split `handleStepComplete` into `resolveStepCompleteTarget` +
`publishStepCompletionEvent` helpers so the entrypoint clears funlen
(88 → under 80 lines) and the two halves are independently testable.
- Classify session/task load errors instead of returning `NotFound` for
every failure: use the exported `taskrepo.ErrTaskNotFound` sentinel for
task lookups, substring-match for the session repo (no sentinel today),
and surface anything else as `InternalError` with the raw error logged
but not leaked to the agent — so a transient DB hiccup triggers a retry
rather than session abandonment.
- Pin `step_complete_kandev` explicitly in the mode-specific tool-count
tests: `Contains` in `ModeTask`, `NotContains` in `ModeConfig` and
`ModeOffice`, so accidental dereg or cross-mode registration is caught
by the diff instead of by a still-equal count.
* fix(workflow): round-3 review fixes + defer pipeline-editor toggle
- Defer the "Wait for agent completion signal" sub-toggle to a follow-up
PR. Adding it to the step config card shifts the layout enough that the
agent profile select's Radix popper opens into a sibling tooltip's hover
path, breaking workflow-agent-profile.spec.ts on shard 7. The backend
column ships in this PR — workflows can opt in via the YAML import or
the MCP `update_workflow_step_kandev` tool — and the UI affordance gets
its own iteration where the test interference can be investigated
without blocking the rest of the change.
- Re-publish the bus event on the `already_signaled` short-circuit when
the session is WAITING_FOR_INPUT. Without this, an agent's retry after
the first call's publish failed would short-circuit on the persisted
bag, never fire the subscriber, and leave the session stuck.
- handleStepComplete docstring: drop the non-existent feature-flag claim
and the "audit entry" wording — gating is purely
`WorkflowStep.AutoAdvanceRequiresSignal` and the bag is cleared on the
next turn start with no separate audit trail.
- `step_complete_kandev` tool description: same fix — clarify that the
call succeeds for non-gated steps but the signal is discarded on the
next turn start (no audit history to query later).
* test(workflow): cover handleStepComplete + reword defer-comment
- Add `TestHandleStepComplete_*` (6 subtests, all passing) covering the
uncovered handler paths flagged by Claude/cubic on round 3:
missing-fields validation, session-does-not-belong-to-task ownership
guard, terminal-session rejection (Completed/Failed/Cancelled),
first-call happy path (bag persisted + bus payload shape pinned), dedup
while RUNNING (no re-publish), and dedup while WAITING_FOR_INPUT (the
retry-after-publish-failure path — must re-publish so the subscriber
fires).
- Reword the deferred-toggle comment to drop the YAML-export claim:
the workflow export model doesn't yet serialize
`auto_advance_requires_signal`, so MCP `update_workflow_step_kandev`
is the only configuration path that round-trips today. Note the
follow-up needs to extend the export schema alongside the UI toggle.
* test(workflow): pin signaled_at + drop redundant GetTaskSession on bag-clear
- Assert `signaled_at` is present and parses as RFC3339Nano in
`TestHandleStepComplete_FirstCallAccepted`. The field is part of the
handler's documented response contract; pinning it catches accidental
drop/rename regressions.
- Add `clearPendingStepSignalByID(ctx, sessionID)` as a thin DB-only
variant of `clearPendingStepSignal` and use it from
`executeStepTransition`, where the session struct isn't read after the
clear. Avoids an extra `GetTaskSession` round-trip per transition.
`clearPendingStepSignal(session)` still exists for the
in-memory-mutation case (subscriber's stale-step path).
* fix(workflow): subscriber stale-clear guard + ADR/comment drift fixes
- onStepCompletionSignaled's stale-step path now reads the bag and only
clears it when its current StepID matches the (stale) event's stepID.
Previously the unconditional clear could destroy a valid signal that
the new step's agent had just written when sessions are reused across
steps without auto_start_agent. (Greptile P1.)
- Drop the "feature flag" phrase from WorkflowStep.AutoAdvanceRequiresSignal
docstring — leftover from an earlier draft. The gating is purely the
boolean field; the global flag was removed two rounds ago.
- Merge the two adjacent `if triggerOnEnter` blocks in
executeStepTransition so the full triggerOnEnter=true path reads top-
to-bottom in one place. (CodeRabbit + cubic.)
- ADR 0015 Mechanism section: re-label the SessionStepHistory.Metadata
audit annotation, the `step_complete_signaled` system message, and the
expvar telemetry counters as deferred. They're planned shapes, not
current behaviour — calling them "Audit" / "Chat visibility" /
"Telemetry" in present tense was misleading for future readers.
* fix(workflow): drop subscriber complexity + reload session on stale-clear
- Extract `parseStepCompletionEvent` and `stepIsSignalGated` helpers from
`onStepCompletionSignaled`. Cyclomatic complexity goes 17 → under 15
(cyclop on new code, CI lint regression after the round-6
stale-clear guard).
- The stale-step branch now re-loads the session from the DB before
deciding whether to clear the bag. The previous round-6 fix checked
the local `session.Metadata` snapshot, which can be older than a
concurrent write from the new step's agent — that check could see a
stale signal that had since been overwritten with a valid one and
erase the new bag entry. The narrower load-on-clear is cheap and
closes the cubic P1 hole. (The broader race in
`processOnTurnCompleteViaEngine` reading session.Metadata is the
monotonic-marker open question in ADR 0015; out of scope here.)
* test(workflow): pin stale-event guard's preserve-current-bag side + dedup StringFromAny
- Add `TestOnStepCompletionSignaled` subtest "stale event → valid bag
for CURRENT step is preserved" — fires a stale step-A event while
the bag holds a valid step-B signal, asserts the bag still contains
StepID=step_b after the subscriber runs. Pins the negative side of
the round-6 `existing.StepID == stepID` guard, so a regression that
drops or inverts the guard (and would silently leave signal-gated
steps stuck) fails the test instead of slipping through.
- Promote `pendingSignalString` to exported `models.StringFromAny`
and reuse it from the orchestrator subscriber's
`parseStepCompletionEvent`. Eliminates the byte-for-byte duplicate
helper across the two packages; the bag-shape decoder and the bus-
event decoder now share one source of truth for "missing string".
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* feat(tasks): search tasks by PR number in command panel
Typing a PR number (e.g. 1243 or #1243) in the Cmd+K panel now surfaces
the task that PR is associated with, alongside the existing
title/description/repo search.
The github store gains a workspace-scoped ListTaskIDsByPRNumber lookup
over github_task_prs; the task service resolves it through an injected
PRTaskResolver interface (github service wired in cmd/kandev) and merges
PR matches into search results, deduped and respecting the
archived/ephemeral/config filters. Keeping the lookup at the service
layer avoids coupling the task repository to the github schema, so
existing search queries and tests are untouched.
* fix(tasks): scope PR-number search to page 1 and unfiltered queries
Address review feedback on PR-number search:
- Only augment results on the first page of an unscoped search. Running
on every page re-prepended the PR match and truncated a real result
off each subsequent page; running under a workflow/repository filter
leaked tasks that don't satisfy the filter.
- Add idx_github_task_prs_pr_number so the lookup is index-backed
instead of a full scan (pr_number is the 3rd column of the existing
UNIQUE constraint, unusable as a leading key).
- Log instead of silently dropping a task when GetTask fails in the
PR-match loop.
* test(github): dedup PR-lookup test setup into shared newTestStore
Add workspace_id to newTestStore's tasks table and drop the near-identical
newTestStoreWithWorkspaces helper, per review feedback.
* perf(tasks): batch-fetch PR-matched tasks instead of N+1 GetTask
Add a repository GetTasksByIDs and use it in augmentWithPRMatches so PR-number
search loads all matched tasks in one query instead of one GetTask per id,
addressing the N+1 noted in review. Fetch+filter is extracted into a
fetchPRMatchedTasks helper that preserves resolver order and dedup.
* feat(backend): full GitLab parity — store, service, poller, WS, presets
Add the watcher/poll/write-action surface on top of the existing GitLab
client + service skeleton:
* 5 new SQLite tables (mr_watches, review_watches, review_mr_tasks,
issue_watches, issue_watch_tasks, action_presets) + CRUD
* Client + Service: MergeMR, GetProjectMergeMethods, GetProtectedBranch,
ListUserProjects, SearchProjects, SetMRLabels, SetMRAssignees
* Service: review/issue/MR watch CRUD + Check/Trigger, GetStats,
CleanupAllReview/IssueTasks (cleanup-policy aware), TriggerMRSync,
action-presets CRUD with defaults
* Background poller (MR / review / issue loops) wired from main.go
* ~35 GitLab WS action constants + handlers.go registrations
* Mock controller for E2E seeding (MRs, issues, pipelines, discussions,
approvals, branches)
* eventBus + taskDeleter + taskSessionChecker plumbed through Service
* Event types: GitLabMRFeedback / NewReviewMR / NewIssue / TaskMRUpdated
* feat(web): GitLab frontend parity — types, API, store slice, domain hooks
Extend the frontend GitLab integration on top of the new backend surface:
* HTTP API client expansion (gitlab-api.ts): full CRUD for review/issue/MR
watches, MR write actions (merge/approve/labels/assignees), MR
files/commits/feedback, action presets, projects autocomplete, stats
* Store slice expansion: review/issue/MR watches lists, action presets
by workspace, stats + status with loading flags. Properties prefixed
with gitlab to avoid collision with GitHub slice
* Domain hooks: use-gitlab-status, use-gitlab-stats,
use-gitlab-review-watches, use-gitlab-issue-watches,
use-gitlab-action-presets — mirror github hook shapes
* Type model: ReviewWatch, IssueWatch, MRWatch, ActionPresets, Stats,
ProjectMergeMethods, MRApproval, Pipeline, MRFeedback, etc.
* Backend controller HTTP routes for watches/presets/write-actions/
projects/stats so the frontend HTTP API client has endpoints to hit
All passes: backend (build + vet + lint + tests), web (typecheck + lint +
2400 tests).
* fix(gitlab): wire orchestrator event handlers, fix label-filter drop, add tests
Address Claude review blockers on the parity PR:
1. Wire orchestrator subscriptions for GitLab review/issue watch events.
Add event_handlers_gitlab.go with handleGitLabNewReviewMR /
handleGitLabNewIssue, dedup reservation handshake, and task-creator
interfaces (mirrors event_handlers_github.go). main.go now calls
orchestratorSvc.SetGitLabService so the dedup APIs are usable
2. Fix silent label-filter drop: when a custom_query is set on an issue
watch, labels were being appended to the unused filter arg. Fold
labels into customQuery (or default filter) so they actually reach
GitLab; extracted appendLabelsToQuery helper with explicit handling
of pre-existing labels= clauses
3. Switch event payloads to pointer types (mirrors GitHub) so
handlers can type-assert pointer events
4. Backend tests: store_watches_test.go (CRUD + reserve/assign for all
4 new tables + presets), service_watches_test.go (label-filter
helper, cleanup policy, project normalisation), action_presets_test.go
5. Frontend slice tests: review/issue watch CRUD round-trips, action
presets + stats reducer coverage
All green: backend (build+vet+lint+tests), web (typecheck+lint+2403 tests).
* fix(gitlab): address remaining blockers — cleanup wiring, safe defaults, nil guards
Address the second round of Claude review blockers:
1. main.go: wire SetTaskDeleter + SetTaskSessionChecker on services.GitLab
(mirrors GitHub). Without these, manual cleanup sweeps would always
error with "task deleter not configured"
2. service_cleanup.go: default cleanup policy on transient DB error is now
CleanupPolicyNever (preserve tasks) instead of CleanupPolicyAuto
(silently delete). Genuine "watch was deleted" path also falls under
the safe-side default; user can manually delete the orphan tasks
3. service_watches.go: add nil guards on requireStore() so the 6 MR-watch
list/get/delete methods return an errStoreUnavailable error instead
of panicking when the SQLite store failed to construct at boot
4. use-gitlab-status.ts + use-gitlab-stats.ts: add per-mount attemptedRef
so an unreachable GitLab doesn't trigger an infinite re-fetch loop —
the previous useEffect re-ran every render because the failure path
left status null, satisfying the !status guard
* fix(gitlab): propagate session-check errors, clamp poll interval on update
Address remaining Claude blockers:
* service_cleanup.go: HasUserAuthoredMessage transient errors are now
preserved (return false → skip delete) instead of silently ignored;
the alternative was occasionally deleting tasks a user had touched
* service_watches.go: extract clampPollInterval helper applying the same
bounds (0 → default, <60 → 60) as the create path; both
applyReviewWatchPatch and applyIssueWatchPatch use it so user-supplied
zero or tiny values via UpdateXxxWatchRequest no longer hammer GitLab
* fix(gitlab): clear dupl lint, fix preset retry loop + per-workspace watch cache
* service_watches.go: applyReviewWatchPatch / applyIssueWatchPatch get
nolint:dupl markers — they share shape but per-domain field validation
lives in the create paths, so deduplicating via generics would obscure
the contract. Restores backend lint to 0 issues
* use-gitlab-action-presets.ts: per-workspace attemptedRef set so a
failing preset fetch doesn't retry on every render
* use-gitlab-review-watches.ts + use-gitlab-issue-watches.ts: track
lastFetchedRef per consumer so a workspace switch triggers a refetch.
The slice-level loaded flag is shared across instances and can't
double as a per-workspace cache key
* fix(gitlab): add nil-store guards across review/issue watch + cleanup paths
Round-3 Claude finding: ReviewWatch / IssueWatch / preset / reservation
methods still called s.requireStore().X() directly. If NewStore fails at
boot (table migration error), the service struct's store is nil and every
list/get/update/create/reserve method panics on first request.
Apply the same store-nil → errStoreUnavailable pattern from f95a929 to
every remaining method:
* CreateReviewWatch / CreateIssueWatch
* GetReviewWatch / ListReviewWatches / ListAllReviewWatches /
UpdateReviewWatch / TriggerReviewWatchAll
* GetIssueWatch / ListIssueWatches / ListAllIssueWatches /
UpdateIssueWatch / TriggerIssueWatchAll
* ReserveReviewMRTask / AssignReviewMRTaskID / ReleaseReviewMRTask
* ReserveIssueWatchTask / AssignIssueWatchTaskID / ReleaseIssueWatchTask
* lookupReviewPolicy / lookupIssuePolicy (return CleanupPolicyNever)
* fix(gitlab): nil-store guards on CheckXxxWatch + DeleteXxxWatch + cleanup paths
Round-4 Claude finding: 4 remaining panic sites the previous nil-guard
pass missed — these run from the background poller (every 5 min) and the
manual delete/cleanup flows, so a boot-time NewStore failure would
crash the orchestrator process on first tick.
* service_watches.go: add store-nil guards to DeleteReviewWatch /
DeleteIssueWatch / CheckReviewWatch / CheckIssueWatch
* service_cleanup.go: guard CleanupAllReviewTasks / CleanupAllIssueTasks
entry points; wrap the trailing DeleteReviewMRTask / DeleteIssueWatchTask
calls in `if store := s.requireStore(); store != nil` blocks so the
cleanup succeeds even when the dedup-row delete is unreachable
* refactor(gitlab): split service_watches.go to stay under 800-line revive limit
CI's --new-from-rev lint caught service_watches.go at 808 lines (limit
800). Split into three focused files:
* service_events.go (94 LOC) — publish helpers for MR feedback /
new review MR / new issue / watch lifecycle events
* service_issue_watches.go (302 LOC) — Issue watch CRUD + Check +
Trigger + fetch + helpers
* service_reservations.go (59 LOC) — Reserve/Assign/Release dedup
handles used by the orchestrator event handlers
service_watches.go is now 505 lines and contains MR watch + Review
watch only. No behavioral change.
* fix(gitlab): address inline code-review feedback
Address actionable inline comments from Claude/CodeRabbit/cubic/Greptile:
* appendLabelsToQuery: use url.ParseQuery for exact key match instead
of strings.Contains; previously false-matched keys like mylabels=
and silently dropped the watch's labels (test added)
* fetchReviewMRs: drop dead `filter = watch.CustomQuery` assignment —
SearchMRs's buildMRSearchQuery returns customQuery verbatim and
ignores filter when customQuery is non-empty
* store.go: add workspace_id indexes on gitlab_review_watches and
gitlab_issue_watches (the 5-min poller and HTTP list endpoints did
full table scans)
* mock_client.SearchProjects: switch from == to case-insensitive
Contains so partial-query autocomplete returns the seeded project,
matching the doc comment
* Poller: add sync.Mutex to guard `started` field; Start/Stop are
now safe to call concurrently (go test -race would have flagged
the previous read/write race)
* Add ErrWatchNotFound sentinel; controller_watches.go maps it to
HTTP 404 via httpRespondError helper. Update/Trigger handlers
previously returned 500 for missing watches, hiding the distinction
from real server faults
* service_events.go: log Publish errors on watch lifecycle events
instead of swallowing them silently
* handlers.go: reject malformed JSON payloads in list-style WS
handlers rather than silently falling through to broader
list/search behavior
* event_handlers_gitlab.go: remove dead gitlabRepoSlug helper +
blank-identifier suppression
* ci: re-trigger to clear flaky E2E shard 3
* fix(gitlab): address final review round — nil guards, store merge, logs, body validation
- CheckMRWatch, UpdateReviewWatch, UpdateIssueWatch: nil guards for store/req
- handlers: wsNewDiscussionNote rejects empty body
- action_presets: Update reads raw stored row (no default freezing);
Reset validates workspace_id
- service_search.GetStats: log Warn per sub-call failure before zero
- event_handlers_gitlab: log Release* dedup-row errors with context
- web/store.ts: post-slice merge restores every GitLab sub-state field;
extract buildStateOverrides helper
- web/default-state.ts: add GitLab fields to defaultState +
mergeInitialState via mergeGitLabFields helper
- web/gitlab-api.ts: UpdateXxxWatchRequest now Omit workspace_id
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* feat(tasks): multi-branch tasks — N (repo, branch) pairs per task
Extends multi-repo into multi-branch so a single task can hold multiple
worktrees on the same repo (one per branch). Sibling worktrees layout at
<task>/<repo>/ + <task>/<repo>-<slug>/, dedicated PRWatch per branch,
live agentctl tracker rescan + frontend store updates so the UI splits
PRs / files / commits per branch without a session restart. MCP
add_branch_to_task_kandev tool is shielded to the worktree executor.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web): unify multi-branch repo label as <repo> · <branch> across panels
PR Changes and Commits panels rendered repo labels differently for multi-branch
tasks — Commits showed "kandev · feature/x" while PR Changes leaked the raw
"kandev-feature-x" agentctl tracker tag. Reformat tracker-tagged repo names via
formatRepoLabel using the longest known-repo prefix so kandev-cli-* doesn't
collapse to kandev · cli-*.
* fix(tasks): address multi-branch PR review — data races, slug collisions, MCP error codes
Backend
- agentctl process.Manager: add repoTrackersMu RWMutex guarding repoTrackers
+ workspaceTracker. Reads snapshot under RLock; RescanRepositories builds
trackers first, takes Lock for swap/append. Validates newWorkDir via
os.Stat before publishing cfg.WorkDir.
- worktree.Manager: reject invalid explicit BranchSlug up-front; cache key
uses sanitized wt.BranchSlug so read/write paths are symmetric.
- worktree.store: narrow isDuplicateColumnError to "duplicate column name".
- task service AddBranchToTask: slug-collision guard rejects feat/a vs feat-a.
- orchestrator resolvePushRepo: two-pass exact-name match before subdir
prefix fallback so "backend" doesn't swallow pushes for "backend-admin".
- executor buildRepoSpecs: duplicate repos always get a non-empty BranchSlug
(falls back to branch-N when both branch fields sanitize to empty).
- MCP handler: classifyAddBranchError maps service errors to NOT_FOUND /
CONFLICT / VALIDATION_ERROR; add ErrorCodeConflict constant.
- MCP server: extract registerAddBranchToTaskTool, register in task mode
only (external mode contract: config tools + create_task_kandev).
- workspace_rescan handler: reject malformed JSON with 400 instead of
silently rescanning the current root.
- branch_materializer: split MaterializeBranch into prepare + finalize
helpers to satisfy the 80-line cap.
Frontend
- existingPrUrl returns undefined when a task has >1 PR so per-repo View PR
buttons take over (no silent linking to a sibling).
- formatRepoLabel checks exact known-repo match before prefix splitting so
"kandev-cli" isn't mislabeled as "kandev · cli".
- Repo chip key now includes base_branch so siblings differing only on
base render as distinct chips.
Docs
- multi-branch spec uniqueness wording aligned with the canonical 4-column
key (task_id, repository_id, base_branch, checkout_branch).
- worktree.CreateRequest.BranchSlug doc describes the sibling layout
(<repo>-<slug>) instead of the obsolete nested form.
* fix(tasks): scope slug-collision check to repo only + use errors.Is for ErrTaskNotFound
- service_branches: drop base_branch from the slug-collision check. Worktree
path is (repo, slug); two rows on the same repo cut from different bases
still collide on disk.
- service_branches: prepareBranchAdd wraps taskrepo.ErrTaskNotFound via
fmt.Errorf with %w so classifiers don't have to substring-match the
formatted task UUID.
- mcp handlers: classifyAddBranchError uses errors.Is(err, ErrTaskNotFound).
* fix(agentctl): sanitize rescan work_dir before os.Stat to clear CodeQL path-injection
CodeQL flagged the rescan endpoint as a path-injection sink (the
HTTP-supplied work_dir flowed straight into os.Stat and downstream
scanRepositorySubdirs). Add validateRescanPath which:
- requires the input to be a clean absolute path with no ".." segments
- rejects anything that isn't equal to or an ancestor of the current
cfg.WorkDir — the only legitimate use case is the branch materializer
promoting <task>/<repo>/ to <task>/
Defense in depth: agentctl binds to localhost, but any colocated process
could otherwise direct it at /etc or the user's home by POSTing
/api/v1/workspace/rescan.
* fix(agentctl): tighten validateRescanPath, close TOCTOU on existingTrackers, add tests
- Drop the strings.Contains(clean, "..") guard. filepath.Clean already
resolves traversal; the substring check rejects legitimate names like
/home/u/..hidden without adding defense (the ancestor check via
filepath.Rel is the real boundary).
- Drop the rel == "." branch in the ancestor check — early return for
clean == currentClean already covers identity.
- Read existingTrackers inside the write-lock section (not under RLock
then re-Lock) so two concurrent rescans can't both see 0 trackers and
double-bootstrap the multi-repo set.
- Add table-driven TestValidateRescanPath covering rejection paths
(relative, descendant, sibling, traversal-cleaned, empty input),
legitimate ancestor promotion, exact match, no-anchor first-launch,
and a /home/u/..hidden dotted-segment case.
* fix(agentctl): break taint flow into os.Stat — derive rescan path from cfg, not HTTP body
CodeQL's go/path-injection trace still flagged manager_rescan.go after the
prior sanitizer because the user-supplied newWorkDir flowed (after
validation) into os.Stat and then into cfg.WorkDir → tracker paths →
workspace_monitor os.Stat sinks.
Replace validateRescanPath with resolveRescanPath: it returns a path
DERIVED from currentWorkDir (no-op = currentClean, promotion =
filepath.Dir(currentClean)) and uses newPath only for comparison, never
as a source for the returned string. os.Stat now receives a path whose
provenance is trusted manager config.
Functionally the legitimate caller (branch materializer promoting
<task>/<repo>/ to <task>/) is unchanged.
* test(agentctl): make TestResolveRescanPath windows-portable
The Linux Backend Tests job passed but the Backend (windows) job rejected
every "absolute" case because filepath.IsAbs("/task/repo") is false on
Windows (no volume letter).
Build inputs via filepath.Join against absRoot() (filepath.Abs of "/" /
"\\") so the same table covers POSIX (`/...`) and Windows (`C:\...`)
without per-OS branches. Keep the traversal-after-Clean case POSIX-only
since filepath.Clean is the shared primitive and that input form needs a
volume to be considered absolute on Windows anyway.
* fix(agentctl): serialize RescanRepositories + re-check trackers under write lock
Two concurrent RescanRepositories calls could both observe an empty
tracker set (between the snapshot read and the bootstrap branch) and both
enter transitionToMultiRepoMode, leaking a duplicate bare-root tracker
and duplicate per-repo trackers. Same race in appendNewRepoTrackers would
double-append the same new child across both callers.
Fix:
- Add rescanMu sync.Mutex on Manager. Acquired for the full body of
RescanRepositories so the snapshot → branch → mutate sequence is
serialized. repoTrackersMu still guards individual field reads from
unrelated goroutines (subscribe/unsubscribe/poll-mode).
- appendNewRepoTrackers re-checks membership inside the write-lock
before appending and drops already-present trackers (Stop + detach
outside the lock). Defense in depth.
- Test: add `dotted segment is legal as promotion` case so the
..hidden-segment promotion path stays exercised.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(orchestrator): auto-resume dormant sessions after workflow queue
When a workflow auto-start prompt is queued after a transient ACP stream
disconnect, no live agent exists to drain the queue via agent.ready.
The prompt sits orphaned until the user manually resumes the session.
Add scheduleAutoResumeForWorkflowQueue (called from queueAutoStartPrompt)
which fires tryEnsureExecution in the background when GetExecutionBySession
shows no live agent. This drives ResumeSession → agent.boot_ready →
handleAgentBootReady → drainQueuedMessageAfterTransition — the same path
a manual resume would trigger.
Guard with s.executor == nil so existing tests that don't wire the executor
are unaffected. Add isAgentRunningFn override to mockAgentManager for
mid-sequence state transitions, and add a regression test that exercises
the full chain end-to-end.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(orchestrator): clarify scheduleAutoResumeForWorkflowQueue boundary condition
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(orchestrator): stop duplicating workflow auto-start prompt on boot-ready drain
When a workflow auto-start prompt failed transiently and was queued by the
retry branches in autoStartStepPrompt, the boot_ready drain later re-inserted
the same user message because executeQueuedMessage always called
CreateUserMessage — producing the two identical Merge prompts observed when
an ACP session resumed after going idle.
Tag the queued message with user_message_recorded when recordAutoStartMessage
has already run before the queue, and have executeQueuedMessage skip its
chat-history insert when the flag is set. The early-busy queue path
(queueAutoStartPromptIfRunning) still queues without the flag so the drain
side keeps owning the record for that path.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(orchestrator): propagate recordAutoStartMessage result and tighten else-if guard
recordAutoStartMessage now returns bool (true = chat row successfully
inserted). Both queueAutoStartPrompt call sites in autoStartStepPrompt
pass the return value instead of hardcoded true, so user_message_recorded
is only stamped true when CreateUserMessage actually succeeded. A silently
swallowed DB write failure no longer stamps the flag, letting
executeQueuedMessage fall back to inserting the message on drain.
Also scope the debug-log else-if to s.messageCreator != nil so it only
fires when a CreateUserMessage attempt would otherwise have been made.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* chore(backend): add go.uber.org/goleak test dependency
Promotes the previously-indirect goleak v1.3.0 to a direct require so
per-package TestMain calls can use it for goroutine leak detection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend/lifecycle): drain workspace-stream backoff on Manager.Stop
StreamManager.connectWorkspaceStream slept the full retry backoff in
time.Sleep between failed attempts. SessionTraceContext is deliberately
uncancellable (it carries a long-lived span), so if the Manager began
shutting down mid-backoff the goroutine outlived Stop() and stranded
until the timer fired.
Thread Manager.stopCh through the StreamManager constructor and add a
sleepOrStop helper that selects between the timer and the shutdown
signal. ReconnectAll's startup sleep gets the same treatment.
TestConnectWorkspaceStream_BackoffDrainsOnStop closes stopCh while a
failing-port retry is in flight and asserts the goroutine exits within
500ms — fails without the fix, passes with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend/github): close sqlite DB in newTestStore to drain connectionOpener
newTestStore opened *sqlx.DB via db.OpenSQLite but never registered a
Close on cleanup, leaving database/sql.(*DB).connectionOpener goroutines
parked across test boundaries. Add t.Cleanup(sqlxDB.Close); goleak's
per-package TestMain (added in a follow-up commit) is the regression
check.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): wire goleak.VerifyTestMain across leak-prone packages
Per-package TestMain in:
- internal/integrations/healthpoll (auth-health ticker loop)
- internal/jira, internal/linear (auth + issue-watch loops)
- internal/github (PR / review / issue poller loops)
- internal/gateway/websocket (per-conn pumps + terminal bridge)
- internal/orchestrator (detached event-handler goroutines)
- internal/agent/runtime/lifecycle (remote-status loop, StreamManager
helpers; ignores OTel batch-span processor and OTLP retry helper —
initialised early in the process and only exit on shutdown)
- internal/agentctl/server/process (extends the existing fixture-binary
TestMain so subprocess + PTY pump leaks surface there)
Catches regressions where a Start/Stop forgets to drain on WaitGroup,
or a detached goroutine forgets to honour ctx/stopCh cancellation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* docs(backend): document goroutine ownership and leak-testing convention
New "Goroutine ownership and leak testing" subsection under Conventions
in apps/backend/AGENTS.md (CLAUDE.md symlinks to it). Covers:
- Lifecycle: Start/Stop + WaitGroup, both idempotent.
- Cancellation: every long wait selects on ctx.Done() or stopCh; never
time.Sleep in retry loops (reference lifecycle.StreamManager.sleepOrStop).
- Detached helpers: must respect the owning type's shutdown signal.
- Leak testing: new packages spawning goroutines add goleak.VerifyTestMain;
IgnoreTopFunction is reserved for genuine third-party background
goroutines and must carry a comment explaining the suppression.
Lists the packages currently instrumented so future contributors can
mirror the pattern.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore(backend/lifecycle): drop dead OTel IgnoreTopFunction from goleak setup
Review follow-up. agentctl/tracing.initTracing only spawns the OTel SDK
provider when OTEL_EXPORTER_OTLP_ENDPOINT is set; in tests it isn't, so
the noop provider is used and no batchSpanProcessor / OTLP retry
goroutines ever exist. The IgnoreTopFunction calls were dead
suppressions and the comment ("called by NewManager via the agent
execution trace path") misdescribed when the SDK init runs. Drop the
ignores and correct AGENTS.md to stop citing OTel as the canonical
suppression example.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend/lifecycle): drop unneeded 50ms sleep from backoff-drain test
QA follow-up. The 50ms time.Sleep before close(stopCh) in
TestConnectWorkspaceStream_BackoffDrainsOnStop was meant to ensure the
goroutine had entered the backoff sleep before shutdown — but that
ordering doesn't affect the test outcome:
- With the fix: sleepOrStop sees stopCh already closed and returns
false on the first call, so the goroutine drains within one attempt
regardless of when stopCh closes.
- Without the fix: time.Sleep ignores stopCh, the goroutine burns
its full 5-retry backoff (~31s), and the 500ms timeout fires.
Removing the sleep makes the test ~12x faster (15ms vs 200ms total) and
removes the only time.Sleep-based wait from the new tests, aligning
with the codebase's "channel-based sync, no sleep waits" convention.
Verified by 50x -race and 5x revert-and-fail.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend/github): drain HTTP idle conns before goleak inspects
CI surfaced a leaked goroutine on the Linux runner that local runs
missed: a single net/http.(*http2ClientConn).readLoop parked on a
TLS read, created by net/http.(*http2Transport).newClientConn. The
package's anonymousHTTPClient and PATClient's rewriteTransport both
route through http.DefaultTransport's connection pool, so an idle
HTTP/2 connection in that pool keeps the reader goroutine alive past
m.Run() — invisible on sandboxed local runs where outbound network
is blocked.
Replace github/goleak_test.go's plain VerifyTestMain with an
expanded TestMain that drains http.DefaultTransport's idle
connections before goleak.Find runs. This is the stdlib's
documented teardown for the connection pool, keeps the assertion
strict (no IgnoreTopFunction), and doesn't leak into production
code. Other in-scope packages don't need this — they don't use
http.DefaultTransport for HTTPS calls.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend): drain ws.Done on stopCh; always run goleak in github tests
- lifecycle/streams.go: replace bare <-ws.Done() with a select that also
watches sm.stopCh so a connected goroutine drains when Manager.Stop()
fires before the remote WS closes. A nil stopCh (isolated tests) blocks
the nil channel case forever, degrading to plain <-ws.Done() semantics.
- github/goleak_test.go: remove the exitCode==0 guard around goleak.Find
so leaks are always surfaced even when a test has already failed; original
exit code is preserved (leaks only promote it to 1).
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(backend/lifecycle): close ws on stopCh so agentctl WS loops drain
cubic flagged that the previous shutdown path observed stopCh but never
called ws.Close(), so the underlying WebSocket and its agentctl-side
read/write goroutines (workspace_stream.go:67-68) kept running past
Manager.Stop(). ClearWorkspaceStream only removes the WS from the
execution's tracking — it doesn't close the connection.
Call ws.Close() on the stopCh path. ws.Close is idempotent via
closeOnce, so it's safe even if the remote end already closed.
TestConnectWorkspaceStream_ClosesWSOnStop connects to a mock workspace
server that stays open until the client closes its side; after stopCh
fires, the test asserts ws.Done() closes within 500ms — which is only
true if connectWorkspaceStream invoked ws.Close(). Verified to fail
without the fix and pass with it.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend/lifecycle): use channel sync instead of sleep polling in ws-close test
cubic flagged that TestConnectWorkspaceStream_ClosesWSOnStop polled
execution.GetWorkspaceStream() in a 500ms sleep loop, which is brittle
under CI load. connectWorkspaceStream already accepts a ready
channel that closes after SetWorkspaceStream — pass it in and wait on
the channel instead.
synctest isn't viable here (real network I/O), so channel-based sync is
the testing convention per AGENTS.md.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* chore: remove orphan executor tracing file and deprecated ResolveScript wrappers
- Delete internal/agentctl/tracing/executor.go (7 TraceExecutor* funcs)
- Delete internal/orchestrator/executor/script_template.go (deprecated wrapper)
- Delete scriptengine.ResolveScript convenience function
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove orphan setter methods across healthpoll, jira, linear, slack, updates
- Delete Poller.SetInterval from integrations/healthpoll (no callers)
- Delete Poller.SetIssueTickHook from jira and linear (no callers)
- Delete Trigger.SetInterval and Trigger.SetMatchHook from slack (no callers)
- Delete Service.SetPollInterval from system/updates (no callers)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove orphan wrapper functions and accessor method
- Delete updates.FetchLatestRelease shim (wraps FetchLatestReleaseFrom, no callers)
- Delete usage.NewClaudeUsageClient and NewCodexUsageClient shims (no callers)
- Delete database.Service.Path() accessor (no callers)
- Update ErrGitHubRateLimited godoc to reference FetchLatestReleaseFrom
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove unwired handleListExecutors MCP handler
The sibling profile handlers (list/create/update/delete) are registered
via RegisterFunc in handlers.go. handleListExecutors never was — dead flow.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove stale hook-field comments after setter deletion
SetIssueTickHook (jira, linear) and SetMatchHook (slack) were removed
in the deadcode cleanup. Their struct fields still claimed "tests use
this" — no test or caller can set these fields now.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: remove orphan hook fields and fire methods after setter deletion
issueTickHook (jira/linear) and matchHook (slack) are unexported and
never set after their public setters were removed; fireIssueTickHook /
fireHook were permanent no-ops.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(task/repo): split sqlite/base.go (1486 lines) into schema/migrations/queries
base.go grew to 1.85x the 800-line lint threshold by mixing the Repository
struct, schema initialization, ALTER TABLE migrations, recreate-and-copy
table rebuilds, and historical backfills/heals. Split along those seams:
- base.go (58 lines) keeps only the Repository struct, NewWithDB,
newRepository, Close, DB.
- base_schema.go owns the initSchema orchestrator, every init*Schema
function, ensure*Indexes, ensureRunnerProjectionTables, and the
plan-revision backfill that runs as part of initPlansSchema.
- base_migrations.go owns migrate*, recreateTable[Named], runMigrations,
the backfillRow type, and every backfill*/heal* function. Verbatim
function moves with no semantic changes.
- base_queries.go owns sqlLimitClause (still referenced by plan.go,
document.go, message.go).
To keep golangci-lint (new: true) green after the move:
- initSchema's 17 if-return chain becomes a for-each-step loop so
cyclomatic complexity drops from 19 to within the 15 limit.
- initInfraSchema and initSessionWorktreeSchema hoist their DDL strings
into package-level consts (infraSchemaDDL, sessionWorktreeSchemaDDL)
so each function body fits the 80-line funlen cap.
Step ordering in initSchema is preserved exactly (backfillTaskEnvironments
-> backfillTaskEnvironmentRepos -> healTaskEnvironmentWorkspacePaths ->
healDuplicateTaskEnvironments -> ensureTaskEnvironmentTaskUniqueIndex ->
healSessionTaskEnvironmentIDs) so existing prod DBs continue to migrate
correctly.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(task/repo): add BatchGetSessionsByTaskIDs to eliminate task-list N+1
Adds a single repository method that returns every task_session for a list
of task IDs in one chunked IN (...) query, grouped by task_id. The
workspace task-list endpoint previously needed three batched calls
(GetPrimarySessionIDsByTaskIDs + GetSessionCountsByTaskIDs +
GetPrimarySessionInfoByTaskIDs); with this loader the handler can derive
primary session ID, session count, and per-task session info from a
single round trip.
- buildInPlaceholders and chunkIDs land in base_queries.go.
sqliteMaxHostParams=500 keeps each chunk well below SQLite's
compile-time SQLITE_MAX_VARIABLE_NUMBER (999 on older builds, 32766
on newer).
- session.go gains BatchGetSessionsByTaskIDs, which reuses
taskSessionSelectCols / scanTaskSessions / loadWorktreesBatch so the
returned sessions are indistinguishable from ListTaskSessions output
(worktrees populated, JSON snapshots decoded). Empty input
short-circuits with a non-nil empty map.
- SessionRepository interface and Service.BatchGetSessionsForTasks
expose the new method to the handler layer. The three existing
*ByTaskIDs methods are left in place because service_events.go still
uses them for single-task event publishing.
- mockRepository implementations updated in two test files so the
packages still satisfy the interface.
TestBatchGetSessionsByTaskIDs covers the empty-input short-circuit, the
multi-task (3 x 2) case (primary derivation + no cross-task bleed), and
the asked-for-but-missing case (absent from result map).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* perf(task/handlers): use BatchGetSessionsForTasks in task-list endpoint
buildTaskDTOsWithSessionInfo previously issued three batched queries per
request (GetPrimarySessionIDsForTasks + GetSessionCountsForTasks +
GetPrimarySessionInfoForTasks). Each was already chunked by task_id,
but together they were three round trips for data that can be derived
from one. Collapse to a single BatchGetSessionsForTasks call and derive
primary session ID, count, and per-task primary info in memory.
The JSON response shape is unchanged: dto.FromTaskWithSessionInfo
still receives the same pointer-typed arguments, primarySessionID stays
nil when no primary exists, and sessionCount stays nil for tasks with
zero sessions (matches the existing pointer-when-present contract that
the frontend store hydration depends on).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task/handlers): restore JOIN-derived executor type/name in task-list response
buildTaskDTOsWithSessionInfo regressed when it stopped calling
GetPrimarySessionInfoForTasks: extractSessionInfo reads
ExecutorSnapshot["executor_type"] and ExecutorSnapshot["executor_name"],
but those keys exist only on the synthetic snapshot built by the
GetPrimarySessionInfoByTaskIDs LEFT JOIN to executors. The persisted
ExecutorSnapshot JSON uses different keys (e.g. "type" — see
share/builder.go:96), so BatchGetSessionsForTasks alone always yielded
empty executor_type / executor_name fields in the task-list response,
breaking the frontend task card's executor badge.
Fix (caught in code review):
- Handler now calls both BatchGetSessionsForTasks (for primary session
ID and session count) and GetPrimarySessionInfoForTasks (for the
JOIN-derived executor type/name and other session info). Two queries
instead of the original three; correctness preserved.
- New TestGetPrimarySessionInfoByTaskIDs_PopulatesExecutorJoinFields
seeds an executor + primary session, then asserts the JOIN populates
ExecutorSnapshot["executor_type"] / ["executor_name"] on the returned
session. Locks in the contract the handler depends on so a future
refactor that drops the JOIN fails loudly instead of silently
emitting empty fields.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task/repo): chunkIDs returns empty slice for empty input
chunkIDs previously returned [][]string{nil} for an empty/nil input
(a single chunk whose value is nil). The only current caller
(BatchGetSessionsByTaskIDs) short-circuits empty input before reaching
chunkIDs, so the bug stayed dormant — but any new caller that forgot
the short-circuit would feed a nil chunk to buildInPlaceholders (which
returns "" for nil), produce a `... WHERE task_id IN () ORDER BY ...`
query, and hit a SQL syntax error at runtime.
Make the helper safe by construction: an empty input returns nil
(empty outer slice), so `range chunkIDs(empty, n)` iterates zero
times. Add base_queries_test.go covering:
- Empty/nil input → no chunks (locks the safety contract).
- Single-pass input fits in one chunk.
- Over-threshold input splits into correctly-sized, in-order chunks.
- buildInPlaceholders normal + empty-input cases.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task/repo): propagate non-ErrNoRows errors in backfillSingleTask executor lookup
`backfillSingleTask` looked up `executors.type` for the legacy executor
referenced by a session and defaulted to `"local_pc"` on any error.
That silently masked driver failures, schema mismatches, and corruption
— every backfilled task_environment would get the wrong executor_type
without surfacing the underlying problem.
Narrow the default to the legitimate case: only `sql.ErrNoRows` (the
executor row was actually deleted, e.g. legacy session whose executor
is gone) falls back to `"local_pc"`. Any other scan error now aborts
the migration with a wrapped error so operators see the real cause.
Adds TestBackfillSingleTask_DefaultsExecutorTypeOnMissingRow which
seeds a session referencing a non-existent executor id and asserts the
backfill still completes with `executor_type = local_pc` — locks in
the legitimate-fallback path.
Addresses PR #1058 review feedback from cubic & claude.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task/repo): chunk ListWorktreesBySessionIDs to honor SQLite var limit
ListWorktreesBySessionIDs built its IN-clause from every input session ID
in one shot. BatchGetSessionsByTaskIDs chunks task IDs at 500 to stay
below SQLITE_MAX_VARIABLE_NUMBER (999 on older builds), but its
loadWorktreesBatch step then handed all sessions in that 500-task chunk
to ListWorktreesBySessionIDs un-chunked — so at modest workloads (500
tasks × ≥2 sessions each = 1000 placeholders) the query fails with
SQLITE_RANGE and bubbles up as a 404 on the task-list endpoint.
ListWorktreesBySessionIDs now applies the same chunkIDs / 500 cap that
BatchGet uses. Body extracted to appendWorktreesForSessionChunk so the
public method stays under the 80-line funlen cap.
New TestListWorktreesBySessionIDs_ChunksOverPlaceholderLimit feeds the
method 1001 non-existent session IDs and asserts it returns cleanly —
without chunking, this fails with SQLITE_RANGE on a default-built SQLite.
Also splits TestBatchGetSessionsByTaskIDs (was 98 lines) into focused
subtests + a shared seedBatchSessions helper, satisfying the test-size
convention. The three sub-scenarios (empty input, multi-task grouping,
missing-task absence) now fail in isolation.
Addresses PR #1058 review feedback from greptile (P1) and claude
(suggestion).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task/repo): port repositories.copy_files migration to split files
Rebased onto main; main added copy_files to runMigrations() and
initTaskSchema() in base.go while our branch had split those into
base_migrations.go and base_schema.go. Cherry-pick both additions
into the correct split files so the schema is consistent.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>