* 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(backend): split terminal_handler.go into pumps/sessions/messages
terminal_handler.go was 1,380 lines (1.7x the 800-line cap). Split it into
four sibling files in the same package, by responsibility:
- terminal_handler.go (525 lines): HTTP upgrade, route dispatch, top-level
handler entries, shell-label resolution.
- terminal_pumps.go (725 lines): wsWriter, bridgeBinaryWebSockets,
runTerminalBridge / runUserShellBridge and all input/resize/PTY-setup
helpers. Extracts replayBufferedOutput from setupPtyAccessCommon to keep
nestif under threshold.
- terminal_sessions.go (133 lines): execution-readiness helpers
(ensurePassthroughExecutionReady, waitForRemoteExecutionReadyWithTimeout,
remoteExecutionWithClientReady, shouldUseWorkspaceShell) and related
timeout constants.
- terminal_messages.go (53 lines): protocol primitives (ResizePayload,
isResizeCommand, resizeCommandByte, stripTerminalResponses), with a
top-of-file doc comment describing the binary wire protocol.
Backed off the "typed TerminalMessage envelope" half of the task: the
current protocol is binary, not JSON-enveloped (terminal I/O is raw PTY
bytes; the only JSON is ResizePayload, sent as a binary frame prefixed by
0x01). Wrapping PTY bytes in a JSON envelope would require base64 and
break xterm.js AttachAddon for every client — the task brief explicitly
authorizes shipping just the file split when the format would change.
Adds terminal_messages_test.go pinning the wire format: exact JSON output
of ResizePayload, the 0x01-prefix detection, and a full resize-frame
round-trip. These fail loudly on any accidental change to the struct tags
or prefix byte.
Pure relocation otherwise: no signature changes, no behavior changes, no
frontend impact.
* refactor(backend): simplify detectInputSubmission with bytes.ContainsAny
Addresses Claude bot's PR review on #1056: replace the manual loop +
flag pattern with bytes.ContainsAny(data, "\n\r"). Behavior identical
(detect CR or LF anywhere in the input). Also drops the now-redundant
"Check for Enter key (CR or LF)" comment.
* ci: retrigger CI to re-test known-flaky session-resume spec
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* 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>
The Manager type owned creation, removal, state queries, and git helpers
in a single 1,615-line file (2x the lint threshold). Decompose into five
files within the same package, preserving the Manager public API and all
existing test coverage:
- manager.go struct, interfaces, NewManager, repo locks
- manager_lifecycle Create, recreate, git worktree add paths
- manager_state cache, persistence, Get* lookups, IsValid
- manager_cleanup RemoveByID, setup/cleanup scripts, dir removal
- manager_git branch inspection, non-interactive git, pull sync
Extract four helpers to satisfy the new-code lint baseline that fires on
moved-but-unchanged complexity hot-spots:
- tryReuseExisting + resolveBaseRefWithFallback split Create (cyclo 21)
- gitAddWorktreeForRecreate trims recreate below 80 lines
- retryFetchAsRemoteTrackingRef flattens fetchBranchToLocal nesting
No behavior change; all existing internal/worktree tests pass unchanged.
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
* fix(backend): inherit parent workspace for subtasks on all start paths
Subtasks with workspace_mode inherit_parent were provisioning a fresh
worktree and tasks folder instead of reusing the parent's. Two causes:
- propagateInheritedEnvironment ran only in PrepareTaskSession, so the
direct start path (startTask, used by MCP auto-start) never bound the
child session to the parent's TaskEnvironment. Moved it into
prepareSessionForStart so every launch entry point inherits.
- The service CreateTask path did not inherit the parent's repositories
when none were supplied (the UI omits them expecting inheritance), so
the child was repo-less and could not establish/reuse a worktree. Now
mirrors the MCP path: inherit RepositoryID + BaseBranch, drop
CheckoutBranch.
* fix(web): hide executor profile for inherit-parent subtasks
When a subtask inherits the parent's materialized workspace, its
executor is inherited too, so picking an executor profile is
meaningless. Hide the selector under inherit_parent and omit
executor_profile_id from the create payload.
* fix(backend): fail fast when subtask parent-repo inheritance lookup errors
Return the error from inheritParentRepositories instead of swallowing it:
a subtask silently created with no repositories would reintroduce the
fresh-worktree bug at launch. Also clarified that inheritance applies to
any repo-less subtask, matching the MCP path.
Splits the 2,842-line apps/backend/internal/github/service.go monolith into nine focused files, all under the 800-line revive threshold. No public API changes, no behavior changes — Service struct, constructor, and every method signature preserved verbatim.
Files: errors.go (sentinels), service.go (struct + constructor + getters), service_tokens.go, service_pr.go, service_pr_watch.go, service_reviews.go, service_issues.go, service_cleanup.go, service_pr_status.go.
Only semantic-equivalent code rewrites: CheckPRWatch hasNew collapsed to a single || expression; three narrow //nolint directives for pre-existing complexity reflagged by golangci's new: true mode after the move.
Rebased onto current main after PR #1057 (sentinel extraction) landed — took its errors.go (now 4 sentinels incl. ErrInvalidToken) and ported the %w wraps for ConfigureToken and AssociateExistingPRByURL into the split files.
* refactor(acp): split adapter.go into per-concern files
Pure file split of the 2,083-line transport/acp/adapter.go into sibling
files inside the same package — no behavior change, no new types, no
signature changes. The struct, constructor, lifecycle, and locking-
sensitive helpers (sendUpdate, trace ctx, Close) stay co-located in
adapter.go (422 lines); session/mcp/auth, prompt/cancel/wakeup, update
dispatch, tool-call conversion, permission flow, and cross-file helpers
each move to their own file, all under the 800-line lint cap.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(acp): restore struct doc wording on availableAuthMethods
independent-review fix: the file split should have been byte-identical for
comments. Restore "surface a picker" (matches origin/main) — accidentally
rewritten as "drive a picker" during the adapter.go rewrite in ccfad56d.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* ci(acp): extract toolStatusInProgress / toolStatusCancelled constants
CI golangci-lint flagged 'in_progress' (9 occurrences) and 'cancelled'
(4 occurrences) as goconst violations after the file split surfaced them
as "new" lines. Extend the existing toolStatus* constant set in
normalize.go and use the new constants at every production call site
(adapter_tools.go, adapter_prompt.go, monitor.go). Behavior unchanged.
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>
* fix(backend): allow IDLE office sessions to accept follow-up prompts
The IDLE session state (introduced with the office feature) was missing from
checkSessionPromptable's accept bucket, so any prompt to an IDLE office
session fell into the default branch and returned ErrAgentPromptInProgress
with the misleading message "agent is currently processing a prompt, session
is in IDLE state" — even though IDLE means the agent process is in fact gone
(torn down between turns) and the conversation is parked waiting to resume.
Net effect before this fix: once an office session went IDLE it could not be
re-engaged from either the UI composer or MCP message_task_kandev. The five
IDLE sessions sitting in the local DB exhibit this exactly.
Fix:
- Add TaskSessionStateIdle to the accept bucket in checkSessionPromptable.
ensureSessionRunning already does the right thing for IDLE: it detects the
missing in-memory execution, finds the preserved executors_running row,
and routes through ResumeSession (which uses the stored ACP resume token).
- Introduce ErrSessionNotPromptable for STARTING / CREATED / FAILED /
CANCELLED rejections. The previous code wrapped those in
ErrAgentPromptInProgress, which is semantically wrong and breaks any
caller doing errors.Is(err, ErrAgentPromptInProgress) checks (the MCP
promptWithAutoResume retry path keys on a different sentinel, so this
doesn't regress, but the categorisation is now honest).
Tests:
- TestPromptTask_IdleSession_IsPromptable — reproduces the bug end-to-end.
- TestCheckSessionPromptable_StateMatrix — pins down every state's
classification, including the IDLE→accept and the
STARTING/CREATED/FAILED/CANCELLED → ErrSessionNotPromptable (NOT
ErrAgentPromptInProgress) split.
- TestEnsureSessionRunning_IdleSessionTriggersResume — confirms IDLE
sessions reach ResumeSession (the second half of the recovery path).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: preserve requeue for STARTING/CREATED + drop time.Sleep in test
Address PR #1119 review feedback from Greptile, cubic, and Claude:
P1: ErrSessionNotPromptable broke the requeue guard in executeQueuedMessage
and the auto-start retry loop. Pre-PR, STARTING/CREATED bucketed into
ErrAgentPromptInProgress and matched isAgentPromptInProgressError, so
queued messages hitting a booting session were retried; post-PR they would
have been silently dropped (no DLQ — see TODO at event_handlers_agent.go).
Introduce isSessionBusyError covering both sentinels and swap it into the
two requeue call sites. Pinned by TestIsSessionBusyError.
P2: Replace the 50ms time.Sleep in TestEnsureSessionRunning_IdleSessionTriggersResume
with a deterministic state-watching poll (waits for STARTING from
persistResumeState before flipping to WAITING_FOR_INPUT). No CI flake
exposure, no timing assumption.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: replace test poll loop with ticker+select per AGENTS.md
Address CodeRabbit + Claude review on PR #1119: the 5ms time.Sleep poll
loop in the launchAgentFunc watcher goroutine flunked the backend
convention of "channel-based synchronization over sleep-based waits."
testing/synctest doesn't help here (SQLite repo runs real I/O goroutines
that synctest can't observe as idle), so use a ticker + select instead,
keeping the 5s timeout as the safety bound.
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>
* fix(backend): drain orphaned queued messages on agent boot ready
When autoStartStepPrompt hits a transient PromptTask error (e.g. "agent
stream disconnected" mid-prompt), it records the auto-start user message
and then queues the same prompt for retry. The drain only fires from
handleAgentReady (turn-end) — but the failed prompt never reached the
agent, so no agent.ready ever arrives for it. After the user resumes the
session, handleAgentBootReady only flipped state and returned, leaving
the workflow auto-start prompt stuck on the queue forever (the "1 queued"
that wouldn't go away after resume).
handleAgentBootReady now drains via drainQueuedMessageAfterTransition
after the WAITING_FOR_INPUT flip — also on the early-return path where
state was already WAITING (boot signal raced persistResumeState).
Terminal sessions short-circuit before the drain so cancelled/completed
sessions don't revive their queued prompts.
Tests: TestAutoStartTransientError_BootReadyDrainsOrphanedQueue
reproduces the production scenario end-to-end (transient PromptAgent
failure → record + queue → boot_ready → drained). Plus
TestHandleAgentBootReady_DrainsOrphanedQueuedMessage covers both
STARTING→WAITING and the persistResumeState-race path, and
TestHandleAgentBootReady_DoesNotDrainForTerminalSession guards the
terminal-session short-circuit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): wait for drain goroutine and verify prompt dispatch in repro
Greptile flagged that the queue-empty assertion in
TestAutoStartTransientError_BootReadyDrainsOrphanedQueue raced the
drain's executeQueuedMessage goroutine: the goroutine would re-enter
the same isTransientPromptError path and re-queue the message
immediately, but the assertion would usually win on timing.
Clear agentMgr.promptErr before Phase 2 (simulating the agent
recovering), then poll capturedPrompts until the drained prompt
actually reaches PromptAgent. This turns a timing-fragile assertion
into a deterministic end-to-end check: drain → executeQueuedMessage
→ PromptTask → PromptAgent → queue empty.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): wire executor in boot-ready drain test to fix nil-deref under -race
Claude and cubic flagged that TestHandleAgentBootReady_DrainsOrphanedQueuedMessage
constructed Service without an executor field. The drainQueuedMessageAfterTransition
goroutine spawned by handleAgentBootReady would nil-deref s.executor via
PromptTask -> ensureSessionRunning -> s.executor.GetExecutionBySession.
Passed today by luck of scheduling but would panic under -race.
Wire a real executor (built from the same mockAgentManager and repo as the
other tests) and seed an executors_running row so the drained PromptTask
lands on a working code path instead of crashing. Verified passes with
`go test -race -count=3`.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): replace time.Sleep polling with promptDone channel sync in repro
Claude flagged that the polling loops in
TestAutoStartTransientError_BootReadyDrainsOrphanedQueue used time.Sleep
where mockAgentManager.promptDone (closes on first PromptAgent call) gives
a deterministic sync point per AGENTS.md.
- Phase 1: wait on firstPromptCalled, then a 500ms / 1ms tight poll covers
the synchronous PromptAgent-returns → queue-row-commits gap.
- Phase 2: swap in a fresh secondPromptCalled channel (the mock's
`first := len(capturedPrompts) == 0` guard requires resetting captured*
slices so the close re-fires for the drain's second call), then wait on
that signal before the queue-empty assertion.
Verified with `go test -race -count=5` — all pass, no goroutine leaks.
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>
* fix(backend): strip non-ASCII chars from branch/worktree names
Non-ASCII titles (CJK, Cyrillic, Arabic, emoji) flowed verbatim into git
branch names and worktree directory names because the sanitizers used
unicode.IsLetter/IsDigit, which accept any Unicode letter or digit. The
resulting names broke many downstream tools and left worktrees
effectively inaccessible.
Restrict SanitizeForBranch and SanitizeRepoDirName to ASCII
alphanumerics. Non-ASCII runes now route through the existing
hyphen-replace + collapse + trim pipeline, and the existing fallback
chain (sanitized title -> sanitized task ID -> "task") handles
titles that become empty after stripping. The "Issue #N:" / "PR #N:"
prefix already added by the GitHub ingestion path means a non-ASCII
issue title still produces a recognizable name like "issue-123".
Closes#1081
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(worktree): fix comment accuracy and harden test assertion
- Remove "emoji" from isASCIIAlphaNum doc — emoji are not Unicode
letters (unicode.IsLetter returns false), so the prior code never
admitted bare emoji. Correct examples to CJK, Cyrillic, Arabic.
- Replace hard-coded len(dirName) != 8 with strings.Contains check
so the assertion stays correct if suffix length ever changes.
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>
* fix(backend): forward base_branch override for same-repo subtasks via MCP
The create_task_kandev MCP handler dropped base_branch silently unless
the caller also passed a repository identifier. Same-repo subtasks then
inherited the parent's repos verbatim, so "branch off feature/X" calls
landed on the parent's branch instead.
Forward base_branch at the top level of the WS payload and apply it as
an override on the resolved repo list when no per-repo entries were
supplied; explicit per-repo BaseBranch wins as before.
* docs(backend): correct BaseBranch field comment to match actual fallback semantics
The struct doc on `req.BaseBranch` claimed it won over explicit per-repo
entries, but the override only fires when `len(req.Repositories) == 0`.
Reword as a fallback applied to inherited/resolved repos when no
per-repo list is supplied; explicit per-repo BaseBranch stays
authoritative when set. Flagged by CodeRabbit and Greptile.
* refactor(backend): forward top-level base_branch only when no per-repo entries
When the MCP caller supplies a repo identifier, the per-repo base_branch
is already authoritative on the backend side, so emitting a top-level
base_branch in the same payload was dead weight that the backend's
override gate explicitly ignored. Move the top-level forward into the
else of hasRepo so the two paths are mutually exclusive and the wire
contract reads as "top-level base_branch means inherit-with-override".
Flagged by the Claude review bot.
* fix(backend): force fresh git status on session subscribe
Every new WS subscriber to a session now triggers a fresh `git status
--porcelain` in agentctl instead of replaying the cached snapshot.
This makes the cache an optimization for already-subscribed observers
only — a fresh observer revalidates ground truth on every connect, so
a stale cache (e.g. left over when the poll loop missed a HEAD change
in paused mode or dropped a tick) self-heals on the next subscribe.
- `?fresh=true` on `/api/v1/git/status/multi` propagates through to
`WorkspaceTracker.GetGitStatus(ctx, true)`.
- New `GetGitStatusMultiFresh` client method; `tryGetLiveGitStatus`
(the session-subscribe hydration path) uses it.
- Regression test reproduces the bug: dirty file → cache primed →
commit without refresh → cached read still shows the file as
modified; fresh=true returns the clean truth.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address PR review feedback
- Reframe regression-test precondition comment so it reads as scenario
setup (Claude review).
- Document GetGitStatusMultiFresh's read-only-vs-cache contract in its
godoc so a future reader doesn't try to write the result back into
the tracker's currentStatus (Claude review).
- Trim multi-line block comment in helpers.go to a single line per the
backend AGENTS.md convention (Claude review).
- Parallelize handleGitStatusMulti's per-repo fan-out via sync.WaitGroup
to keep the fresh-path wall-clock close to the slowest single repo
regardless of repo count, so multi-repo workspaces don't blow past
the 2s subscribe-path timeout (Greptile review). Pre-sized result
slice means each goroutine writes its own slot — no shared-slice
contention. collectStatusForRepo now takes context.Context directly
instead of *gin.Context.
- Add a third assertion in the regression test that locks in the
documented cache-unchanged contract — after fresh=true, a subsequent
GetGitStatus(ctx, false) call must still return the stale cached
value, so any future change that silently writes through fresh would
fail the test (Greptile review).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix: address round 2 review feedback
- Trim the parallelization rationale in handleGitStatusMulti to a
single line per AGENTS.md convention (Claude review).
- Drop unused GetGitStatusMulti client method (cached variant) and its
internal getGitStatusMulti helper — only GetGitStatusMultiFresh has
a caller after this PR, and AGENTS.md says to delete unused code
outright (Claude review).
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>
IdleTimeoutManager, the BuildPrompt family, ChannelRelay, and
configloader.Watcher were forked into internal/office/service/ and
internal/orchestrator/watcher/ during the scheduler/→service/ refactor
but never removed from their original homes. All four types (and their
two same-package self-tests) had zero importers across the repo.
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
* fix(backend): keep cancelled office turns promptable instead of parking IDLE
A user-initiated turn cancel on an office task was racing the
agent.complete stream handler. The complete handler unconditionally
flipped the session to IDLE (the "parked between scheduler runs"
state), overwriting Service.CancelAgent's WAITING_FOR_INPUT write
that arrived a moment earlier. The next prompt was then rejected by
checkSessionPromptable with "session is in IDLE state".
Skip the office IDLE branch when the complete event carries
stop_reason="cancelled". The cancel handler already drives state
reconciliation for user cancels; falling through to the kanban
WAITING_FOR_INPUT path leaves the session promptable.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(backend): shorten extractStopReason and stopReasonCancelled comments
Trim multi-line docstrings that violated CLAUDE.md's one-short-line rule
down to one or two lines, keeping only the non-obvious cross-file sync note.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(backend): compress remaining multi-line comment blocks to one line
Second pass of comment trimming per CLAUDE.md convention — the
handleCompleteStreamEvent stop_reason context block and the
handleOfficeTurnComplete stopReason paragraph were still too long.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* refactor(backend): collapse remaining multi-line comments to one-liners
Address Claude bot review threads on PR #1088: the production helper
docs (extractStopReason, stopReasonCancelled, handleOfficeTurnComplete)
and the new test functions still carried multi-line comment blocks,
which violate CLAUDE.md's "one short line max" rule.
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>
* fix(backend): drain stuck auto-start prompts after step transitions
Adds flipStaleRunningToWaiting, a helper that detects when a session's
in-memory state reports RUNNING/STARTING but the orchestrator's
authoritative activeTurns map has no entry, and pre-flips the session
to WAITING_FOR_INPUT. processOnEnter now calls this before reaching
queueAutoStartPromptIfRunning, so the auto-start prompt is sent directly
via PromptTask instead of being queued against a session whose agent.ready
will never fire.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* docs(backend): clarify reset guard scope and add STARTING-state test
- Expand isSessionResetInProgress comment to note it guards concurrent
goroutines only; sequential reset_agent_context OnEnter actions run
after flipStaleRunningToWaiting returns (markIdleAfterReset no-ops
if state is already WAITING_FOR_INPUT).
- Add call-site comment explaining why the pre-flip must happen before
the len(step.Events.OnEnter)==0 early-return check.
- Add test: flipStaleRunningToWaiting flips STARTING session with no
active turn (addresses reviewer gap noted in code review).
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>
* feat(web): normalize /gitlab page to match /github layout
Adds a presets sidebar (MR/Issue toggle, Inbox/Created groups, Saved
section), a top toolbar with project filter and custom-query input,
paginated MR/Issue lists, and a save-preset dialog backed by a
localStorage store at kandev:gitlab-presets:v1.
Replaces the bespoke tabbed layout and supersedes
hooks/domains/gitlab/use-user-search with the pagination-aware
useGitLabSearch under components/gitlab/my-gitlab/. Project filtering is
applied client-side since GitLab's global MR/issue endpoints have no
path_with_namespace qualifier. No backend changes — the controller
already accepts filter / custom_query / page / per_page.
* fix(web): address /gitlab review feedback
- Merge duplicate cn/formatRelativeTime imports in list-toolbar.
- Drop the generic T from useGitLabSearch — it always resolved to MR | Issue,
so the as-unknown-as double-cast was just hiding that. Now returns
Array<MR | Issue> directly and exposes rawTotal alongside total.
- When projectFilter is active, total reflects the client-side filtered
count instead of the server's unfiltered total so the toolbar badge and
pagination don't claim "47 results" while only 3 rows render.
- Save flow uses committedQuery, not the draft customQuery, so a preset
saved without pressing Enter matches what's actually displayed.
- Wrap mobile sidebar handlers in useCallback to keep props stable.
- Drop the unused refresh callback in useGitLabStatusFetch (no caller).
- Add hook-level tests for useSavedPresets save/remove/sync; use
resetKnownProjectsStore directly in the known-projects test.
* fix(web): address round-2 /gitlab review feedback
- useSavedPresets save/remove now read from localStorage on every call so a
concurrent write from another tab can't be silently overwritten by a stale
in-memory snapshot.
- Tighten persisted shape validation: customQuery, projectFilter, and
createdAt must all be strings (plus 3 regression tests).
- ResultsPagination clamps page to [1, totalPages] before deriving start/end
and prev/next state, so a stale out-of-range page doesn't render an
invalid range like "101–30 of 30" after a refresh that shrinks results.
- PresetsSidebar: ignore keydown bubbling from the nested delete button so
Enter/Space on delete doesn't also trigger row selection, and add an
aria-label to the icon-only delete control.
- Revert the hook's filtered-total policy: useGitLabSearch always returns
the raw server total now (so pagination can still navigate to later
pages). The page client owns the toolbar's displayedCount, computing
filtered.length only when a project filter is active.
* fix(web): drop dead gitlab preset re-exports, tighten save dialog deps
- Remove unused IconBug / IconPencil imports and the re-export from
presets.ts — no consumer imports them from this module.
- save-preset-dialog: inline the trimmed check inside handleSubmit so
canSubmit no longer needs to be in the deps array. ESLint exhaustive-
deps forced the previous duplication; inlining removes it cleanly.
* fix(web): null-safe response unwrap in useGitLabSearch
If the API returns null, the cast `(response as {mrs: ...}).mrs` would
crash before the `?? []` fallback could run. Optional-chain the cast so a
null payload yields an empty list, matching the existing total_count guard.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
* chore: remove dead non-ACP provider client packages
Delete five Go packages under apps/backend/pkg/ that were unreachable
from any binary: amp, codex, claudecode, copilot, opencode. These were
the client libraries for legacy non-ACP agent implementations removed in
a5b54b53 — that commit deleted the callers but missed the client libraries.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
* chore: drop orphaned copilot-sdk dep and stale architecture doc
go mod tidy removes github.com/github/copilot-sdk/go v0.1.18 and its
transitive dep github.com/google/jsonschema-go v0.4.2; both were only
imported by the deleted pkg/copilot/client.go.
Delete docs/agents/claude_code.md: it describes the pre-ACP streamjson
transport and pkg/claudecode/ package. Every source file it references
has been removed (pkg/claudecode/ in this PR; streamjson/ and
agents/claude_code.go in a5b54b53).
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(backend): classify errors via sentinels instead of substring matches
Introduce package-local sentinel errors in the github, task repo, agent
lifecycle, and office/skills packages. Producers wrap with fmt.Errorf("%w: ...")
so callers can classify via errors.Is/errors.As instead of brittle
strings.Contains(err.Error(), ...) checks that break on library upgrades.
Sentinels added (or relocated to errors.go):
- github: ErrInvalidToken (new); ErrInvalidPRURL, ErrTaskNotFound, ErrSelfApprove
moved from service.go.
- task/repository/sqlite: ErrTaskNotFound, ErrOfficeSessionRaceConflict.
- lifecycle: ErrAgentAlreadyRunning, ErrAgentReported.
- office/skills: ErrSkillNotFound, ErrSkillFileNotFound.
Consumers updated to errors.Is/errors.As across orchestrator, task/handlers,
github, and office/skills. AssociateExistingPRByURL switches %v -> %w so the
inner parsePRURL error stays in the chain. process_handlers now uses
errors.Is(err, syscall.ECONNREFUSED) and lifecycle.ErrNoExecutionForSession
plus a small isTimeoutError helper backed by errors.As.
A new taskDeleterAdapter in cmd/kandev/turn_adapters.go translates
taskrepo.ErrTaskNotFound to github.ErrTaskNotFound at the wiring boundary,
avoiding a layering violation between the github and task packages.
The one remaining substring check, in CreateTaskSession, is encapsulated
inside the repo and converts the SQLite UNIQUE-violation message into the
typed ErrOfficeSessionRaceConflict sentinel so callers never see the driver
string.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): add TestErrorsAreClassifiable for refactored packages
Pin the new sentinel-based contract for each refactored package. Each test
exercises the producer paths and asserts errors.Is reaches the sentinel
through the wrap chain, and (where applicable) confirms untyped lookalike
strings no longer classify.
- github: isTaskNotFound, AssociateExistingPRByURL/ErrInvalidPRURL,
ConfigureToken/ErrInvalidToken (using a noop secret manager).
- task/repository/sqlite: GetTask, DeleteTask, UpdateTaskState wrap
ErrTaskNotFound.
- task/handlers: isNotFound + isAgentReportedError follow the typed chain.
- orchestrator: each isXError classifies exact + wrapped sentinels, rejects
untyped lookalikes.
- office/skills: readUserHomeSkillInventoryFile wraps ErrSkillFileNotFound;
ErrSkillNotFound survives fmt.Errorf("%w", ...).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(github): drop network-hitting subtest from errors_test
The "ConfigureToken wraps ErrInvalidToken when validation fails" subtest
constructed a real PATClient and called GetAuthenticatedUser, which makes
a live HTTP request to api.github.com. That's flaky in CI / offline dev
and violates the unit-test boundary (caught in review).
Replace with a sentinel-survives-wrap-pattern check that locks in the
exact %w/%w shape used by ConfigureToken, plus an assertion that the
inner cause stays reachable through errors.Is. The full ConfigureToken
flow stays covered by service_token_test.go and integration tests.
Remove the now-unused noopSecretManager stub type.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(task): restore "timed out" UX for plain-fmt.Errorf timeout producers
The original createPromptErrorMessage rendered the friendly "Request timed
out…" hint via strings.Contains(err.Error(), "timeout"). The refactor
replaced it with errors.Is(context.DeadlineExceeded) + a net.Error
Timeout() helper — strictly typed. But several upstream producers along
the prompt path (service.WaitForSessionReady, agent-stream connect waits
in lifecycle/session.go and manager_interaction.go, agentctl health waits
in agentctl/client.go and launcher.go, the orchestrator's own
waitForSessionReady) return plain fmt.Errorf("timeout …") with no typed
Timeout() method, so the typed check missed them and the user saw the
generic "Failed to send message to agent" instead.
Add a substring fallback inside isTimeoutError as a UX safety net while
keeping the primary typed paths intact. Lock the contract with table
tests covering all three classes (net.Error timeout, plain string,
context.DeadlineExceeded) so any future trim of the fallback breaks
loudly. Migrating each upstream producer to a typed sentinel is tracked
as out-of-scope follow-up.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(backend): tighten classification tests to exercise typed-sentinel contracts
Addresses bot-review feedback on PR #1057:
- task/handlers/errors_test.go: cover all four not-found sentinels
(taskrepo.ErrTaskNotFound, service.ErrDocumentNotFound,
service.ErrTaskPlanNotFound, service.ErrRevisionNotFound) in
TestErrorsAreClassifiable via a table-driven loop, instead of only
one. Drop the spurious Deadline() *time.Time method on timeoutNetErr
— it isn't part of net.Error and the existing `var _ net.Error =
timeoutNetErr{}` line already pins the interface.
- orchestrator/scheduler/scheduler_test.go: testTaskRepository was
still returning the local scheduler.ErrTaskNotFound, which doesn't
satisfy the production processTasks check (now errors.Is on
taskrepo.ErrTaskNotFound). Mirror the real sqlite repo's wrap so the
mock and prod paths agree. The local scheduler.ErrTaskNotFound is
truly dead after that, so drop it from scheduler.go.
- office/skills: the wrap-pattern subtest in errors_test.go was only
testing fmt.Errorf, not production code. Move the contract assertion
to service_test.go (external skills_test package) where it drives
the real GetSkillFromConfig lookup path against an empty in-memory
repository.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* refactor(backend): drop redundant context.DeadlineExceeded check and fix doc grammar
Addresses bot-review feedback on PR #1057:
- task/handlers/message_handlers.go::createPromptErrorMessage: the
explicit errors.Is(promptErr, context.DeadlineExceeded) is redundant
with isTimeoutError, which already matches via errors.As against
Timeout() — context.DeadlineExceeded implements Timeout()==true.
Inline comment documents the consolidation.
- github/errors.go: fix grammar in the ErrTaskNotFound doc comment
("is the sentinel cleanup paths check for to distinguish" →
"is the sentinel that cleanup paths check to distinguish").
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>
* fix(web): wire permission approval UI for Kandev MCP tools
KandevToolMessage adapter ignored the permissionMessage prop, so MCP tools
that block on a permission request rendered the result row but no Approve/
Deny buttons — users could not unblock the agent. Extract the shared
permission handlers into use-permission-handlers.ts and reuse them from
both ToolCallMessage and KandevToolMessage. KandevRow now also shows an
amber pending clock icon and hides the result-count summary while waiting
for approval so the row reads correctly before the result arrives.
Adds a mock-agent scenarioKandevMCPPermission emitting
mcp__kandev__list_workspaces_kandev plus a Playwright e2e and unit tests
covering the pending UI and the absence of "0 workspaces" while pending.
* fix(web): collapse tool + permission status into resolved Kandev row
After approve/reject the orchestrator stamps the decision onto the
permission_request message immediately, but the tool_call's own
meta.status stays "pending" until the next tool_call update lands.
KandevToolMessage previously passed meta.status straight to the renderer,
so the row kept the amber pending clock and the hidden-summary state for
the brief window between the two events. resolveKandevStatus now prefers
the permission decision once present, mapping approved -> complete and
rejected -> error.
Drops a redundant Playwright `.first()` after a toHaveCount(1) assertion
on the same locator. Adds unit coverage for the resolved-decision state.
* fix(web): keep Kandev tool status independent of permission decision
The previous fix collapsed an approved permission into tool status
"complete" and a rejected permission into "error". That was wrong on two
counts: an approved tool that's still running rendered as already done
(green check + visible result summary built from an empty payload), and
any later tool_call error was silently masked because the status had
already been forced to "complete".
Replace the status override with a separate display overlay. KandevRow
now reads a `KandevPermissionUIContext` ("pending" | "rejected" |
undefined) and uses it only to drive the waiting-on-user affordances —
amber clock, hidden summary — and a rejection mark that wins over the
tool's own success state. The renderer always receives the real
tool_call status, so a tool that's approved-but-pending still reads as
pending and a tool that errors after approval still renders the red X.
Adds explicit regression tests for the two race-window failure modes
plus the new rejected overlay.
* test(web): tolerate duplicate-render backend race in Kandev MCP e2e
CI shard 1 sometimes renders two approve buttons for the same Kandev MCP
permission because the orchestrator can create a second generic tool_call
message for the same tool_call_id alongside the kandev-routed one. Both
buttons resolve the same pending_id, so clicking the first unblocks the
agent either way.
The bug the test guards against is "no approve button at all" — the
strict toHaveCount(1) assertion was tying the test to that separate
backend issue. Relax to at-least-one and click the first.
* fix: persist Deny clicks as rejected, not approved
Backend RespondToPermission previously derived status purely from cancelled:
cancelled=true -> "rejected", otherwise "approved". Deny clicks sent
cancelled=false with a reject_* option_id, so they were recorded as
"approved". Add an explicit rejected flag from the WS request so the
backend can record "rejected" without taking the cancellation path (which
races against permission_cancelled -> "expired").
Also refactor permission-request-message.tsx to consume the shared
usePermissionResponseHandlers hook (the duplicated inline handler had the
same bug) and introduce a typed models.PermissionStatus enum on the
backend so status strings are not scattered literals.
* test(web): scope Kandev MCP permission e2e to the Kandev row
Previously the test clicked the first matching permission-approve button,
which could be a duplicate generic ToolCallMessage fallback rendered for
the same pending_id by a backend race -- a green test no longer proved the
custom Kandev approval UI rendered.
Add a `data-testid="kandev-tool-permission"` wrapper around the Kandev
action row and a `kandevPermissionApproveButtons` page-object locator that
scopes through it, then assert `toHaveCount(1)` so the test only passes
when the Kandev custom row actually mounts.
* chore(orchestrator): log rejected flag in RespondToPermission
Debug log was missing the new rejected param, making it impossible to
distinguish explicit Deny from dialog cancellation when triaging
permission-flow issues.