a71ee2e2c1932f9ec99f4f067fbf91bb0cfc2233
145 Commits
| Author | SHA1 | Message | Date | |
|---|---|---|---|---|
|
|
d8436dfd8f | feat: improve passthrough composer and session recovery (#1411) | ||
|
|
8d0493d1a4 | fix: persist session runtime config (#1346) | ||
|
|
64140ae942 | fix(acpdbg): read claude agent models and modes from configOptions (#1310) | ||
|
|
3e92d3abc6 | fix: allow office mcp mode for existing tasks (#1315) | ||
|
|
4601dbc2d2 |
feat(tasks): search tasks by PR number in command panel (#1268)
* 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. |
||
|
|
a8db7478ac | fix(backend): cap gh/git fork concurrency via shared subproc throttles (#1216) | ||
|
|
fd79d6170f |
feat(tasks): multi-branch tasks — N (repo, branch) pairs per task (#1226)
* 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> |
||
|
|
86f756e280 | feat(workflow): add set_session_mode action and preserve mode across reset (#1188) | ||
|
|
fa4a48d5e7 |
chore: remove orphan helpers, setters, and deprecated wrappers (#1085)
* 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> |
||
|
|
a627957c5b |
refactor(task/repo): split sqlite/base.go and add session batch loader (#1058)
* 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> |
||
|
|
21db57bdfd | fix(orchestrator): repair worktree state and prompt persistence on resume (#1121) | ||
|
|
905b9f9727 | fix: improve UX for subtasks with repo setup scripts (#1105) | ||
|
|
dcfec0d23c |
refactor(backend): classify errors via sentinels instead of substring matches (#1057)
* 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>
|
||
|
|
f1dbad1dbd | feat(cli-mode): auto-inject task prompt + dynamic submit sequence + passthrough UX fixes (#923) | ||
|
|
777dfdb15b |
feat(ssh): add SSH executor (#927)
* feat(backend): add SSH executor runtime
Run agents on any Linux box reachable over SSH: own VPS, homelab, EC2,
shared dev box. One SSH connection per (host, user, identity) shared
across all sessions on that host; each session gets its own remote
agentctl process, remote port, and SSH-tunneled local port. Mirrors the
local ~/.kandev/tasks/<task-dir>/<repo>/ layout on the remote so
multi-session tasks share a worktree and per-session runtime state lives
under the task dir.
Implements ExecutorBackend + RemoteSessionResumer + RemoteStatusProvider
+ Closeable. Host-key trust is explicit: the test endpoint observes a
fingerprint without pinning; the user trusts it in settings; subsequent
connects reject any mismatch. Auth via ssh-agent or IdentityFile only
(passphrase-protected keys must be loaded into ssh-agent). Parses
~/.ssh/config for HostName/Port/User/IdentityFile/ProxyJump inheritance.
agentctl is content-hash-cached on the remote (~/.kandev/bin/agentctl
plus a .sha256 sidecar) and uploaded only when local and remote differ.
Linux/amd64 only in v1, matching Docker's current state; arm64 is
called out as a cross-cutting follow-up.
See docs/specs/ssh-executor/spec.md.
* feat(backend): add SSH test + sessions HTTP/WS endpoints
POST /api/v1/ssh/test runs the connection probe used by the settings UI:
dials the host with a permissive host-key callback that records (but
does not pin) the fingerprint, probes uname/arch/git, and reports
whether agentctl is already cached on the remote. The UI gates the
"Trust this host" checkbox on success + fingerprint, and Save persists
the trusted fingerprint into the executor config.
GET /api/v1/ssh/executors/:id/sessions returns the active SSH-runtime
sessions backing the "Active sessions" card on the executor settings
page, mirroring the SpritesInstancesCard / DockerContainersCard shape
(task, session, user@host, remote port, local forward port, uptime).
Both routes also exposed over WS via ssh.test / ssh.sessions.list.
* feat(web): add SSH executor settings UI
SSHConnectionCard: connection form (name, host_alias / host / port /
user, identity source = ssh-agent | file with optional file path,
optional ProxyJump) with a Test Connection button that runs the backend
probe, surfaces the observed host fingerprint, and only enables Save
when the user explicitly ticks "Trust this host". Editing the host /
port / user / identity fields resets both the test result and the trust
checkbox. On save, if the executor already has running sessions, a
confirm modal warns that those keep going on the old host and only new
sessions will use the new config.
SSHSessionsCard: 90s-polled table of active sessions for the executor,
mirroring SpritesInstancesCard / DockerContainersCard. Columns: task,
session, user@host, remote agentctl port, local forward port, uptime,
status badge. Backed by GET /api/v1/ssh/executors/:id/sessions.
Also registers the ssh entry in executor-icons.ts (label "SSH",
IconTerminal2) so it surfaces consistently in the rest of the settings
chrome.
* docs: add SSH executor v1 spec
Captures the design behind the new SSH executor: why ("run on a Linux
box I already own" between local and Sprites), the remote layout that
mirrors local ~/.kandev/tasks/, per-session agentctl + forward over a
shared per-host SSH connection, explicit-trust host-key flow, ~/.ssh/config
inheritance, amd64 + agentctl content-hash cache, recovery via persisted
ExecutorRunning metadata, settings UI (Connection + Sessions cards), and
the edit-with-live-sessions warning UX. Out-of-scope items (arm64,
macOS, password auth, chained ProxyJump, working-tree push, etc.) are
called out explicitly with the follow-ups they unblock.
* chore(e2e): rename docker project to containers + add e2e README
The Docker e2e Playwright project was the natural home for any test
that needs a real Docker daemon on the host — but its name implied it
was only about the Docker executor. SSH e2e tests (incoming in
follow-up commits) belong here too, since they use a Dockerised sshd
container as the SSH executor's remote target.
- Rename project: docker -> containers, widen testMatch to also include
tests/ssh/*.spec.ts.
- Rename env flag: KANDEV_E2E_DOCKER -> KANDEV_E2E_CONTAINERS, with the
old name kept as a deprecated alias for one release so local scripts
and CI configs don't break overnight.
- Rename CI job: e2e-docker -> e2e-containers, switch its blob report
name and the merge-job dependency.
- Add apps/web/e2e/README.md explaining the project layout, what the
containers project covers (Docker executor + SSH executor), why it
was renamed, and how to run it locally.
- Add docs/specs/ssh-executor/e2e-plan.md tracking the SSH e2e
coverage we'll implement on top of this rename (real sshd container,
no mocks, 17 spec files covering ~75 cases). Includes a "Deferred"
section that flags arm64 as something to revisit when
agentctl-linux-arm64 lands in the build pipeline.
* feat(web): add data-testid attributes to SSH settings UI
Sweep stable test ids across the SSH connection and sessions cards so
the incoming Playwright suite can hold steady against refactors. Splits
the formerly monolithic ssh-settings.tsx into three files to stay
within the per-file/per-function size limits:
- ssh-connection-card.tsx (form + test-then-trust flow)
- ssh-sessions-card.tsx (active-sessions table)
- ssh-settings.tsx (barrel re-export so existing imports keep working)
Test ids cover: card roots, every input, identity-source select +
options, test/save buttons, connection badge (with data-status), error
text, test-result panel (with data-success), per-step rows (with
data-success), observed/pinned fingerprint, fingerprint-change
warning, trust checkbox, sessions table + per-row cells, empty state,
refresh button, session status badge.
* feat(e2e): scaffold the SSH executor test harness
Lands the building blocks the SSH specs will compose on top of. Every
piece talks to a real sshd container — no mocks at any layer.
- fixtures/ssh-image.ts: builds kandev-sshd:e2e once per machine (Alpine
+ openssh-server + openssh-sftp-server + git + bash + sudo +
iproute2 + iptables, pre-bakes mock-agent at /usr/local/bin/mock-agent,
generates a fresh host key on entrypoint, reads the worker public key
out of a bind-mounted /authorized_keys). The agentctl binary is
deliberately NOT pre-baked so tests exercise the SFTP upload + sha256
cache path the production code uses.
- helpers/ssh.ts: per-worker sshd lifecycle (startSSHServer /
stopSSHServer), keypair generation, host-key fingerprint scanning,
fault injection (regenerateHostKey for host-key-rotation specs,
dropTrafficToPort22 / restoreTraffic for connection-drop tests,
killRemotePid for "agentctl died" recovery), and remote inspection
helpers (readRemoteFile / remotePathExists / listRemoteDir /
countSshdConnections).
- helpers/ssh-bastion.ts: two-container ProxyJump setup. Bastion is on
a private Docker network and has a published port; target is on the
same network with NO host port — any successful connection MUST have
traversed the bastion, which makes the proxy-jump assertion honest.
- fixtures/ssh-test-base.ts: worker-scoped fixture mirroring
docker-test-base. Skips when Docker isn't reachable. Spins up the
sshd container, generates keys, scans the fingerprint, then seeds a
workspace + workflow + bare repo + mock-agent profile + an SSH
executor with the fingerprint already trusted. Specs that need to
exercise the test-then-trust UI gate create their own executor on
top of the same target.
- pages/SSHSettingsPage.ts: Page Object covering the connection form,
test/save buttons, trust checkbox, observed/pinned fingerprint, per
step badges, and the sessions table.
- helpers/api-client.ts: new createSSHExecutor / updateExecutor /
getExecutor / testSSHConnection / listSSHSessions methods plus
matching DTO types.
* test(e2e): add SSH executor Playwright suite (17 specs, ~75 cases)
Every spec drives the real sshd container set up by the ssh-test-base
fixture; no mocks. Specs are grouped by concern (file per group),
mirroring the docker/ directory style. Each one maps 1:1 to a row in
docs/specs/ssh-executor/e2e-plan.md.
UI surface:
- connection-form.spec.ts — field rendering, save/test gating,
identity-source toggle, default port.
- test-result.spec.ts — successful + failed Test Connection paths,
per-step badges, fingerprint surfacing, cached/will-upload copy.
- trust-gate.spec.ts — save disabled until trust ticked, edits reset
result + trust, fingerprint-change warning when pinned != observed.
- sessions-card.spec.ts — empty state, row rendering, manual refresh,
status badges, truncation, polling pickup.
- persistence.spec.ts — fingerprint persists across reload, new form
starts blank, executor list label, partial state doesn't leak.
API contract:
- test-endpoint.spec.ts — POST /api/v1/ssh/test step ordering, invalid
body, host-required, handshake short-circuit, cached/uploaded states.
- sessions-endpoint.spec.ts — GET /api/v1/ssh/executors/:id/sessions
filters to ssh-runtime rows, populates metadata, monotonic uptime.
- executor-crud.spec.ts — type=ssh persists fingerprint, profile
workdir round-trip, edit-with-running-sessions keeps snapshots.
Real-SSH integration:
- launch-task.spec.ts — full smoke: agentctl upload + sha256 cache hit
on second launch, task/session dir layout, stop cleans session dir.
- recovery.spec.ts — backend restart reconnects, dead pid surfaces
failure, persisted metadata keys present.
- auth-methods.spec.ts — key + valid, missing key, passphrase-protected
rejection, agent without SSH_AUTH_SOCK, wrong-key auth failure.
- concurrency.spec.ts — two sessions share task dir distinct ports, two
tasks distinct dirs, traffic drop + restore exercises eviction +
reconnect.
- hostkey-rotation.spec.ts — regenerate host key surfaces new
fingerprint, stale pinned fingerprint blocks launch verbatim, re-trust
restores function.
- config-inheritance.spec.ts — ~/.ssh/config HostName / Port /
IdentityFile / ProxyJump inheritance with form-overrides-config rules.
- workdir-per-profile.spec.ts — per-profile ssh_workdir_root routes to
separate remote workdir trees; default falls back to ~/.kandev.
- proxy-jump.spec.ts — 2-container bastion topology asserts target is
unreachable directly, reachable via ProxyJump, chained ProxyJump fails.
- error-surfacing.spec.ts — TCP refused, auth failed, bastion unreachable
surface verbatim; backend 400 stays a 400 not a synthesised failure.
Also adds an AGENTS.md (CLAUDE.md symlink) line pointing at the
e2e/README.md so future agents discover the containers project +
KANDEV_E2E_CONTAINERS env var without grepping playwright.config.ts.
* fix(e2e): unlock sshd account, derive fingerprint from kandev probe
Three bugs surfaced by actually running the SSH e2e suite
(KANDEV_E2E_CONTAINERS=1 against the containers project):
1. Alpine's `adduser -D kandev` leaves the account password-locked
(`!` in /etc/shadow). sshd then refuses every auth method for that
user — including pubkey — with "User kandev not allowed because
account is locked". Fix: `usermod -p '*' kandev` in the Dockerfile
so the account is open for key-based login but still has no valid
password. Also adds the `shadow` apk package for `usermod`.
2. ssh-keyscan reports the ed25519 host fingerprint, but Go's ssh
client may negotiate a different host key type when sshd offers
multiple — meaning the fingerprint the fixture pre-scanned didn't
match the one kandev observed at handshake time. Fix: the fixture
now hits /api/v1/ssh/test with a permissive callback right after
the container is up and uses that fingerprint as the pinned value.
Same code path real users hit; eliminates the negotiation skew by
construction.
3. ssh.ts had a CPU-spin sleep helper and an nc-dependent TCP probe,
both fragile. Replaced with a proper sleep subprocess and a bash
/dev/tcp probe, plus retry-with-stderr on the fingerprint scan so
failures are diagnosable.
Also tightens test-endpoint.spec.ts F4: the test was using
identity_source=agent to "fail fast", but that fails inside
buildAuthMethods before the handshake step ever runs. Switched to
identity_source=file against a refused port so the test exercises
the actual TCP+handshake failure path.
After these fixes 4/6 test-endpoint cases pass against a real sshd.
* fix(ssh): make the end-to-end launch path actually work
A pass of issues uncovered by running the SSH e2e suite locally
(KANDEV_E2E_CONTAINERS=1 against the real sshd container). After this
commit all 15 non-launch SSH e2e tests pass (UI form validation,
test-connection contract, auth-methods, error-surfacing, sessions-card
empty state) and the launch path runs through prepare -> upload ->
agentctl -> instance -> forward, with WebSocket streams sometimes
disconnecting partway through (tracked below for follow-up).
Image changes (apps/web/e2e/fixtures/ssh-image.ts):
- Install curl so the SSH executor can POST to the in-container
agentctl control server for instance creation.
- Install gcompat + libstdc++ so the dynamically-linked glibc
agentctl binary can exec on musl-libc Alpine. Without it the
kernel returns ENOENT for /lib64/ld-linux-x86-64.so.2.
- Install shadow + 'usermod -p *' kandev so sshd doesn't refuse
pubkey auth for the otherwise-locked user.
- Drop in 'AllowTcpForwarding yes' and 'MaxSessions 100' via
/etc/ssh/sshd_config.d/. Alpine's defaults are 'no' and 10, both
of which crater the SSH executor's port-forward path.
- chown /home/kandev/.kandev to kandev on entrypoint so a host
bind-mount of that path (used in this commit for log persistence)
doesn't make agentctl unable to write its log file.
Image helper (apps/web/e2e/helpers/ssh.ts):
- Bind-mount the host mock-agent-linux-amd64 into the container at
the same absolute path. kandev's orchestrator builds the agent
command from KANDEV_MOCK_AGENT_LINUX_BINARY (a host path), so the
remote needs the binary at that exact path; bake-into-image isn't
enough because the absolute host path is what kandev sends.
- Bind-mount /home/kandev/.kandev so per-session agentctl logs
survive --rm teardown and the test runner can inspect them on
failure.
- Replace the broken CPU-spin sleep helper with a real sleep
subprocess and a /dev/tcp TCP-open probe so timing-dependent
helpers stop racing the container start.
- Retry the host fingerprint scan with stderr capture; the first
scan can race the sshd accept loop on Alpine.
Backend SSH executor (apps/backend/internal/agent/lifecycle):
- executor_ssh_operations.go startRemoteAgentctl: agentctl does not
accept --port-file or --pid-file flags (it reads AGENTCTL_PORT
and writes its own pid). Rewrite the launch wrapper to inject
AGENTCTL_PORT via env, capture $! as the pid, and tail the
on-remote agentctl.log for 'HTTP server bound successfully'
instead of waiting on imaginary side-files.
- executor_ssh_operations.go createRemoteAgentInstance: new helper
that POSTs /api/v1/instances on the in-container control server
via SSH-exec'd curl, returning the per-instance port the executor
should forward to. Mirrors what executor_sprites_operations.go
does inside its sprite. Without this step the agent stream
returns 'configure request failed with status 404' because the
SSH executor was talking to the control server, not the
per-session sub-server.
- executor_ssh.go startAndForwardAgentctl: chains the new
create-instance step in between agentctl launch and port-forward,
and forwards the *instance* port (not the control port).
- executor_ssh.go: drop the per-host SSH connection pool (it was
introducing a subtle aliasing issue between
CreateInstance/StopInstance/GetRemoteStatus that made the SSH
client unreliable across same-host sessions). Each session now
holds its own ssh.Client directly on sshSessionState and
ResumeRemoteInstance dials a fresh client too.
- executor_ssh_operations.go SSHPortForwarder: serialize the
client.Dial calls with a per-forwarder mutex. golang.org/x/crypto/
ssh.Client.Dial is documented as concurrency-safe but in practice
parallel opens from the kandev stream-manager occasionally
produce io.EOF as if the channel-open response was never
received.
Test fix (apps/web/e2e/tests/ssh/error-surfacing.spec.ts):
- Q1 ('TCP refused') was using identity_source='agent' with no
SSH_AUTH_SOCK in the backend env, so it failed at
buildAuthMethods before reaching the TCP-refused path. Switch to
identity_source='file' with the seed key so the test exercises
the failure it claims to.
Known remaining issue (to investigate next): the launch path now
reaches 'agent process started' + 'ACP initialize response
received' when run in isolation, but the WebSocket updates stream
sometimes disconnects with 1006 abnormal closure shortly after. A
standalone Go reproduction of concurrent direct-tcpip channels
against the same image works without issue, so the trigger is
something specific to the kandev backend's stream-manager + my
forwarder interaction. Tracked for the next iteration.
* fix(ssh): half-close forwarded SSH channels to avoid premature WS abnormal-close
The SSH executor's port-forwarder previously called the full Close() as soon
as either direction of the bidirectional copy finished. Under HTTP-Upgrade
WebSocket traffic this meant: when the WS server flushed its final frame and
shut down the write side, our local->remote io.Copy returned EOF, we closed
the entire SSH channel, the remote->local copy aborted mid-frame, and the
backend's WS reader observed close 1006 (unexpected EOF) instead of a clean
close frame.
Switch to a half-close pattern: each direction CloseWrite()s its destination
when its source EOFs, letting the other half drain the remaining frames. The
final Close happens after both copies return.
The "ssh forwarder dial remote failed: EOF" warning in e2e logs was a
follow-on symptom — the SSH client was being torn down by the abnormal close,
so any later channel-open returned EOF immediately.
* fix(ssh): resolve session executor_id via TaskSession join
GET /api/v1/ssh/executors/:id/sessions was filtering ExecutorRunning rows by
their ExecutorID column, but the lifecycle manager's buildRunningFromExecution
only sets that column from a prior row — for fresh sessions it's left empty.
The endpoint therefore returned [] for every just-launched SSH session.
Look up the TaskSession by session_id and compare its ExecutorID instead.
Keep the row's own ExecutorID as a fast-path for resume/recovery flows
where it's already populated.
* feat(web): register ssh entry in the new-executor type map
Without this entry, /settings/executors/new/ssh fell back to the
"unknown executor type" placeholder. Extracts the type map into its
own module so the page file stays under the 600-line lint cap.
The SSH-specific form is still TODO and will replace the standard
profile-create form for this type in a follow-up.
* test(e2e): poll listSSHSessions for SSH session row in group G
The simple-message scenario completes inside a couple hundred
milliseconds, so the previous "wait for executor_type=ssh, then
query once" pattern raced the row's removal during teardown. Poll
listSSHSessions directly with a 60s window so the row is observed
while the executor is up.
Adds dumpRemoteLogs helper for copying per-session agentctl.log
files out of the sshd container on assertion failure, easing
debugging when the row never appears.
* test(e2e): align H5 assertion with actual on-disk session-dir layout
The wrapper writes agentctl.pid and agentctl.log to <sessionDir>; the
remote agentctl port travels via AGENTCTL_PORT env + ExecutorRunning
metadata, not a separate .port file. Assert the files that actually
exist and the metadata field that carries the port.
* feat(web): mount SSH connection + sessions cards on dedicated routes
Wires the existing SSHConnectionCard and SSHSessionsCard into the
settings UI so the SSH executor flow has a home:
- /settings/executors/new/ssh renders SSHCreatePage (just the connection
card; Save POSTs a new executor and routes to the edit page).
- /settings/executors/ssh/[executorId] loads the executor and renders
the connection card pre-filled + the sessions card.
- Adds the SSH entry to the executors landing-page tile grid.
- Adds createExecutor / fetchExecutor / updateExecutor helpers to
settings-api so the new pages can talk to /api/v1/executors directly.
Profile management for SSH executors still flows through the existing
profile pages; this commit only adds the executor-level chrome the
SSHSettingsPage e2e tests expect.
* fix(web): keep SSH trust checkbox visible after editing a connection field
Previously, editing any form field nulled out `result` so the entire
TestResultDisplay (including the trust checkbox) unmounted. The trust-gate
e2e specs assert that after editing host/port/user/identity_source, the
trust checkbox is still present but unchecked — which matches the
intended UX: the user can re-test against the new target and re-tick
trust without losing the prior result for reference.
Now an edit only clears `trust` when the field could route to a
different machine (host_alias, host, port, user, identity_source,
identity_file, proxy_jump); the result panel stays mounted. Editing the
display-only `name` leaves trust alone.
* test(e2e): point SSHSettingsPage at the new SSH executor routes
Updates the page object to navigate to the SSH-specific routes that now
host the connection + sessions cards:
- gotoNew(): /settings/executors/new?type=ssh → /settings/executors/new/ssh
- gotoExisting(): /settings/executors/[executorId] → /settings/executors/ssh/[executorId]
The old URLs collided with the standard profile-create / profile-edit
flow and were never going to render the SSH cards. The new paths give
the SSH UI its own surface, leaving the generic profile pages untouched.
* feat(api): add top-level DELETE /api/v1/executor-profiles/:profileId
Profile IDs are globally unique, so callers that only have the
profile id (e2e api client, CLI tooling) shouldn't have to plumb
the executor id through. Aliases the existing nested handler.
* test(e2e): probe SSH fingerprint via kandev, not ssh-keyscan, in K specs
regenerateHostKey was returning the ssh-keyscan fingerprint, but
ssh-keyscan and the Go ssh client can negotiate different host-key
types — meaning the fingerprint kandev observes during a real probe
does not always match what keyscan saw. The keyscan output was used
verbatim as the expected value, which K1 / K3 then asserted against
kandev's pinned-fingerprint result, so the specs flapped depending
on which algorithm sshd advertised.
Drop the return value from regenerateHostKey and have callers hit
/api/v1/ssh/test to learn the canonical fingerprint, matching what
the SSH fixture already does at seeding time.
K2 was polling env.error_message which doesn't exist — the launch
fails at SSH dial time before the executor is recorded on the task
environment. Point the polling at the task-session error_message
instead, where updateTaskSessionState actually persists the dial
failure.
* test(e2e): wipe agentctl before F5 to assert from a fresh-install state
F5 asserts that the agentctl_action probe reports "uploaded" before
the first launch and "cached" after. Tests share a worker-scoped
sshd container, so an earlier test in the same worker may have
already primed the cache, making the "before" assertion flap.
Wipe ~/.kandev/bin/agentctl + .sha256 from the container at the top
of F5 so the assertion sees the documented fresh-install path.
Export execInContainer so the spec can reach into the container
without copying the docker-exec boilerplate.
Add error_message to the listTaskSessions return type so the
hostkey-rotation spec can read the field (which the backend already
returns).
* fix(ssh): re-read ~/.ssh/config on every resolve, no process-wide cache
kevinburke/ssh_config's package-level Get/GetStrict use a sync.Once
to load $HOME/.ssh/config once per process and cache the result. That
breaks two things:
* users who edit their config mid-session don't see updates until
kandev restarts;
* e2e tests that write a fresh ~/.ssh/config per test all see the
state captured by whichever test triggered the first lookup.
Open and Decode $HOME/.ssh/config on demand from a local
inheritFromSSHConfig helper. The library still parses the file the
same way — we're just dropping the global cache.
Unblocks the four ssh/config-inheritance.spec.ts cases that wrote a
per-test ~/.ssh/config.
* feat(ssh): parse literal user@host:port ProxyJump bastion strings
dialViaJump fed target.ProxyJump straight into ResolveSSHTarget as a
host alias. That works for `Host prod-jump` entries in ~/.ssh/config
but turns the literal `user@host:port` form into a single hostname
that the kernel resolver chokes on (`lookup kandev@127.0.0.1:23000: no
such host`).
Detect the literal shape inline (`[user@]host[:port]`) and build the
bastion SSHTarget from its parts. Falls back to the alias path when
no @ / : separators are present.
Fixes the proxy-jump.spec.ts bastion flow. While here:
* scanFingerprintInsideNetwork: alpine package is `openssh-client`,
not the (nonexistent) `openssh-keyscan`. Surface install errors
so future failures aren't silent.
* scanFingerprintViaHost: retry on empty output — sshd accepts TCP
before host-key negotiation completes on a cold container.
* startBastionAndTarget: share one keypair across bastion + target
so a single -i auths both hops (matches how OpenSSH ProxyJump
handles per-host identities outside ~/.ssh/config).
* proxy-jump N1: assert any SHA256 fingerprint came back, since the
test no longer pre-discovers a value to compare against.
* test(e2e): time-bound the drop-traffic SSH probe with an AbortController race
iptables -A INPUT -p tcp --dport 22 -j DROP silently discards SYN
packets — Go's net.Dialer sits in the connect syscall through the
kernel's TCP_SYN_RETRIES (default 6 ~ 127s) before the syscall is
abandoned, even though the dialer's logical Timeout is 30s. The
spec polled the testSSHConnection API for the failure path; the
request blocked long enough that Node's keep-alive evicted the
socket and the test failed with "other side closed" instead of
seeing kandev's success=false response.
Race the probe against a 5s timeout. If the request errored or
timed out the test passes (both are valid evidence that the drop
hit the dial path); if it came back with a response, assert
success=false the same way the original spec did. The restore-probe
poll still gates on a real success after restoreTraffic.
* test(e2e): rerun ssh.test in hostkey-rotation afterEach to repin
Hostkey-rotation specs mutate the worker-scoped sshd container and
the seeded SSH executor's pinned fingerprint. Without a cleanup hook,
the next spec in the same worker that resolves seedData.sshTarget.
hostFingerprint observes a stale value and the UI's trusted-badge
check (persistence R1) flaps.
Add an afterEach that re-probes the live container via kandev, syncs
the fingerprint into both seedData and the seeded executor's pinned
config. The cleanup is a no-op when the probe fails (e.g. K2 left an
intentionally bad fingerprint that no longer matches anything — the
next test will re-establish state via its own setup).
* test(e2e): restore the seeded executor's pinned fingerprint after R5
The fingerprint-change-warning spec pinned a deliberately bogus
fingerprint on the worker's seeded SSH executor to exercise the
mismatch UI, then exited without resetting it. Later specs in the
same worker that launched real tasks against the same executor
(workdir-per-profile R1/R2 in alphabetical order) failed at SSH dial
time with host-key-changed, since the executor was still pinned to
the bad value.
Wrap the assertion in a try/finally that restores the original
fingerprint from seedData when the test ends, so the worker leaves
the executor in the same state it inherited.
* fix(ssh): quote every remote path + clean identity file path
CodeQL flagged two paths from HTTP-supplied SSH-config fields into
shell + filesystem operations:
* runSSHCommand built "cat $remoteShaFile" / "test -x $remoteBin"
/ "mkdir -p $(dirname $remoteBin)" by direct concatenation; the
arguments come from $HOME on the remote (not the kandev user)
but the analyzer can't see that, and a remote $HOME containing
shell metacharacters would still misparse. Route them through
shellQuote for parity with every other call site in the file.
* dialAuth opened target.IdentityFile straight from the configured
string. Run it through filepath.Clean so any `..` segments slipped
in by string substitution are normalised before os.ReadFile, which
is the canonical mitigation CodeQL recognises here. Behaviour is
unchanged for normal paths.
* refactor(ssh): drop the unused conn pool + remote curl dependency
CodeRabbit and Greptile both flagged the SSHConnPool: the executor never
took the pool path (CreateInstance always dialDirect'd a fresh client and
parked it on sshSessionState), so the pool's keepalive goroutines and
refcount logic were dead code. Get() also overwrote map entries on every
call, which would have leaked keepalive goroutines if the pool *had* been
used. Delete the entire SSHConnPool + sshConnKey + pooledConn + keepalive
plumbing. Export DialSSH so the SSH test endpoint can dial directly and
close the client when its single-shot probe is done.
While here:
* pickRemoteAgentctlPort: switch from time.Now().UnixNano()%20000 to
math/rand/v2.IntN. Two concurrent CreateInstance calls on a fast
machine were getting the same port (UnixNano cycles in ~20µs through
the 20k range); randomized picks avoid the collision.
* createRemoteAgentInstance: POST to the remote control port over a
direct-tcpip dial via net/http instead of shelling out to `curl`. The
sshd container no longer needs curl, the kandev test endpoint no
longer needs to probe for it, and the call inherits Go's standard
timeout + error handling. Drop `curl` from the e2e Alpine image.
* maybeUploadCredentials: was probing `printf %s "$HOME"` and
discarding the output, then allocating an unused sshFileUploader.
SSH v1 doesn't ship credential upload — just log a warning when a
user sets remote_credentials so they know it won't take effect, and
delete the dead uploader file.
* fix(ssh): verify bastion host key + tighten the trust-gate UX
Bastion host-key verification (security)
resolveBastion built the bastion's ClientConfig with an empty
PinnedFingerprint, which buildClientConfig translated into a
callback that accepts any key. An attacker on the path to the
bastion (DNS poisoning, route hijack) could pivot through it
before the final-target check ever ran. Greptile flagged it as P1.
Swap in a callback that consults ~/.ssh/known_hosts via
knownhosts.New: matched key → accept; mismatched key → reject loud
(the alarm); host absent → accept with the same TOFU semantics
OpenSSH uses for StrictHostKeyChecking=accept-new, so e2e and
first-connect setups still work without manual `ssh-keyscan`
priming.
Stale-test trust gate (frontend)
Editing a connection field after a successful test previously kept
the prior result + cleared `trust`, but the trust checkbox could
be re-checked and saved with the old fingerprint bound to the new
target. CodeRabbit + cubic both flagged it as P1.
Add a `resultStale` flag that flips when a CONNECTION_FIELD edit
follows a successful test, surface a warning under the result, and
disable the trust checkbox + save button until the user re-runs
Test Connection. handleTest clears the flag.
While here
* Surface onSave failures in the card instead of swallowing them.
* ssh-create-page: read the executors slice from the live store
(useAppStoreApi + getState()) at write time so a concurrent
update doesn't get overwritten by a stale closure.
* Split FingerprintTrustBlock into its own file to keep
ssh-connection-card under the 600-line cap.
* fix(ssh): defend launch + handshake against several real bugs
manager_launch: trusted exec-config keys override req.Metadata.
buildLaunchMetadata applied executor config only when the matching
key was missing in req.Metadata. A request that carried metadata.
ssh_host_fingerprint (or ssh_host, ssh_identity_*) could pivot the
launch to a different target and bypass the pinned fingerprint.
Pin a list of connection-routing keys (ssh_host, ssh_port, ssh_user,
ssh_host_fingerprint, ssh_identity_source, ssh_identity_file,
ssh_proxy_jump) and have the executor config always win for those.
Cubic / CodeRabbit P1.
executor_ssh.CreateInstance: reuse the resumed session state.
ResumeRemoteInstance attaches the recovered ssh.Client + forwarder
to r.sessions[InstanceID] and refreshes metadata, then the lifecycle
manager unconditionally called CreateInstance, which dialed a fresh
client and started a second remote agentctl on top of the live one
— leaking the resumed state. CreateInstance now early-returns from
the existing r.sessions entry via buildResumedInstance. Cubic P1.
executor_ssh.Close: actually release tracked sessions.
After dropping the SSHConnPool, Close was a no-op. A hard kandev
exit (no per-session StopInstance) left forwarders and SSH clients
open. Iterate r.sessions and Close the forwarder + client for each.
Cubic P2.
buildAuthMethods: close the ssh-agent unix socket after handshake.
Each agent-authenticated dial opened net.Dial("unix", $SSH_AUTH_SOCK)
and never closed it; only PublicKeysCallback held the agent.Client
reference and that callback is consulted exactly once at handshake.
Return a cleanup func from buildAuthMethods + buildClientConfig and
defer it from dialSSH / dialViaJump so the socket releases as soon
as key exchange completes. Greptile P1 security.
waitAgentctlHealthy: probe HTTP /health, not just the TCP listener.
The local port is owned by the SSH forwarder, which Accepts before
it Dials the remote direct-tcpip channel — a TCP connect can succeed
before the channel actually establishes. Issue a real GET /health
and require a 2xx so a broken tunnel surfaces here rather than at
the first agent operation. Cubic P1.
ssh-connection-card: also gate the trust + save on in-flight tests.
Editing a connection field during a pending test silently kept
resultStale=false because prev.result was still null; when the test
returned, the new fingerprint was bound to the old form values but
the gate let the user trust + save anyway. Treat in-flight tests as
prior tests for staleness purposes, and preserve resultStale across
the test's success setState. Cubic P2.
* fix(ssh): drop stale polls + preserve port inheritance + sshd extra-keys
ssh-sessions-card: race-protect polls.
Polling setInterval + manual refresh shared one state setter, so a
slow request that resolved after a newer call (or after a parent
switched executorId) overwrote fresh data with stale rows. Bump a
monotonic sequence on each refresh and drop the response when the
sequence has moved on, plus reset on mount and invalidate on
unmount so an in-flight reply from a teardown can't land. Cubic P2.
ssh-config parseSSHExecutorConfig: don't default ssh_port to 22.
Reading an executor row that had no ssh_port set rewrote port=22
on the form, which then round-tripped back to the backend. That
silently broke ~/.ssh/config Port inheritance for aliases — a save
could redirect connections to 22 even though the underlying alias
was supposed to resolve to another port. Leave the field undefined
so the backend resolver can apply the alias' value. Cubic P2.
ssh-image entrypoint: guard authorized_keys.extra mount.
When only /authorized_keys.extra (and not /authorized_keys) was
bind-mounted, the >> redirect ran under set -e against a missing
destination and aborted container startup. mkdir + touch the
destination before the append and reapply the ownership/perms.
CodeRabbit P2.
* test(e2e): make G2 + F6 actually exercise the contract
G2 (sessions-endpoint filter) iterated `for (const s of sessions)` over
the listSSHSessions result without first asserting `length > 0` — an
empty response let the test pass without checking anything. Seed an SSH
launch before the loop and require at least one row, so a handler
regression that returned [] would now fail loud. CodeRabbit P1.
F6 (ws ssh.test parity) called apiClient.testSSHConnection twice (HTTP
both times) and compared the two HTTP responses, which would have
passed even if the WS dispatcher entry was broken. Use apiClient.
wsRequest("ssh.test", payload) for the second call so the gateway's
WS handler is actually invoked, and assert the step names match across
transports. CodeRabbit P1.
* refactor(e2e): extract SSH HTTP shapes into api-client-ssh.ts
api-client.ts was already over the 600-line lint cap on main (1528
lines); the SSH additions made it worse. Split the SSH HTTP shape
types into a dedicated module and re-export from api-client.ts so
existing callers keep working. The methods themselves stay on
ApiClient as thin pass-throughs since splitting them out across the
class boundary would change every test call site.
Also document the per-executor bound on the SSH listSessions per-row
GetTaskSession lookup (typical N < 10), and what a batch path would
need (a repo method to fetch task_sessions by id list). Cubic /
Greptile P2.
* docs(spec): correct SSH recovery keying after pool removal
The spec said ResumeRemoteInstance re-opens 'per (host, user) — one
connection serves all surviving sessions on that host', which was
the pool design. The current executor gives each session its own
SSH client, so the resume keys on the full (host, port, user,
identity_*, proxy_jump, host_fingerprint) tuple. That way two
profiles on the same host with different keys can resume side by
side without merging. Cubic P2.
* fix(ssh): IPv6 ProxyJump + port range + dedup + docker timeouts
parseLiteralProxyJump: handle bracketed IPv6 + reject invalid ports.
The literal parser used LastIndex(":") to split host from port, which
for [2001:db8::1]:22 left host as "[2001:db8::1]" and produced an
invalid double-bracket address through net.JoinHostPort. Recognise
the bracketed form explicitly via a parseBracketedHostPort helper
and strip the brackets. Validate port is in [1, 65535] instead of
accepting any integer (Atoi happily parses negative or out-of-range
values). Split the host/port branch into its own function so the
nesting stays under the golangci nestif limit.
ssh-create-page: dedupe on append.
setExecutors([...current, next]) would double-list the same executor
if a WS event (or another refresh) inserted it while createExecutor
was in flight. Skip the append when current already has the id.
ssh-image fixture: time-bound docker probe + build.
hasSSHContainerSupport ran `docker info` with no timeout — an
unreachable / hung daemon would stall the Playwright worker
indefinitely. Cap at 10s for the probe. buildE2ESSHImage runs
`docker build` with no timeout; cap at 15 min so a stuck registry
pull or BuildKit hang fails fast.
* fix(ssh): reject invalid ssh_port + retry in-net keyscan + restore D5 + doc count
executor_ssh.targetFromMetadata: reject malformed / out-of-range
ssh_port instead of silently falling back to 0 → 22 in the resolver.
"abc", "0", "70000" all now return a "must be 1-65535" error so a
broken config can't masquerade as a successful launch routed through
port 22. Cubic P2.
scanFingerprintInsideNetwork (bastion fixture): retry on empty output.
Mirrors the host-side scanFingerprintViaHost retry loop — sshd accepts
TCP before host-key negotiation completes on a cold container, so the
keyscan can come back empty on the first try. Bound at 15s; surfaces
"status=..., stderr=..." on final failure for diagnosis. Cubic P2.
executor-crud D5 (changing config…): restore the seeded executor in a
try/finally. The test pinned the executor to host=10.255.255.1 to
verify the snapshot stayed sticky on the live session, but never put
the real host back, so any later spec in the same worker that reused
seedData.sshExecutorId would dial the unroutable address and hang.
Cubic P2.
e2e-plan.md: replace the "roughly 75" estimate with the actual 80 /
17-files current count plus the ~2.4 min wall-clock. Cubic P3.
* fix(ssh): use bare mock-agent name so the remote PATH resolves it
For SSH the agent runs on a remote host whose filesystem doesn't share
paths with the kandev backend's, so the host-absolute mock-agent path
configureMockAgent computes can't apply. Extend the bare-name branch in
MockAgent.BuildCommand to include RuntimeSSH (the SSH e2e image already
bakes mock-agent into /usr/local/bin) and drop the now-redundant SSH e2e
bind-mount that worked around this by mirroring the host path into the
container.
* fix(ssh): unique sftp temp filename so concurrent uploads don't collide
When two sessions on the same task launch back-to-back they both call
ensureAgentctlOnHost which streams to <remotePath>.tmp before renaming.
With a shared .tmp name the second uploader's rename errors with
"file does not exist" once the first uploader's rename consumed the temp
file. Give each uploader its own pid+random-suffix temp; uploaders write
identical sha256-keyed content, so the rename race is then safe.
* fix(ssh): don't leak session-scoped metadata across sibling sessions
reuseExistingEnvironment copies persistent metadata from the previous
ExecutorRunning row into the new launch request so executors can pick up
worktree/container/sandbox IDs. For SSH that swept in session-scoped
keys too — agentctl PID/port, session dir, local forward port — which
made the second session's ResumeRemoteInstance reattach to the first
session's agentctl process. Mark those keys as session-scoped and skip
them in applyExecutorRunningMetadata; the same-session resume path
(applyRunningRecordToResumeRequest) still copies them because it works
off the session's own ExecutorRunning record.
* test(ssh): cover ProxyJump parsers + sibling-session metadata filter
Addresses the two coverage gaps Claude flagged on PR review round 3:
- parseLiteralProxyJump / parseProxyJumpHostPort / parseBracketedHostPort
/ parsePortString are pure parsers that gate every ProxyJump dial,
including IPv6 (`[2001:db8::1]:22`). Table-driven tests pin the happy
paths and the bracket / port / range edge cases — silent regressions
in bracket stripping would otherwise break every IPv6 ProxyJump
session without a single test failing.
- applyExecutorRunningMetadata is the function commit
|
||
|
|
adc13c6b5a | feat: copy gitignored files from repo into new worktrees (#946) (#950) | ||
|
|
3bc092bf20 |
feat(terminal): first-class user terminals — stable seq, rename, park/resume (#1009)
* feat(terminal): persist user terminals with stable seq + park state
Ordinary user shells now have a DB-backed `user_terminals` row keyed by
the agentctl PTY id, carrying a per-task monotonic seq (gaps preserved on
delete), custom_name override, and open/parked state. The terminal
service owns the create/list/rename/park/resume/destroy surface and
guards reject `bottom-panel` (cmd+J) + `script-*` ids so passthrough
flows are untouched. Closing a tab now parks (PTY keeps running);
destroy kills the PTY and removes the row.
* feat(web): expose terminal seq badge, rename, and parked-terminals menu
Wires the right-panel strip and SSR page state to the new task-keyed
terminals endpoint and discriminated UserShell union, adds the `#N`
badge for ordinary terminals, plumbs context-menu / double-click rename,
and renders a "Parked" submenu so closed-but-running terminals can be
resumed. Closing a managed tab now parks instead of destroying so the
PTY (and its buffer) survives accidental closes.
* test(terminal): e2e coverage for first-class persistent entities
Covers the persistence guarantees the user explicitly called out:
monotonic seqs, refresh-survives-rename-and-park, gap-preserving destroy,
park+resume cycle, and bottom-panel/script guards. Exercises the new
user_shell.{create,list,rename,park,resume,destroy} WS actions through
the shared ApiClient.wsRequest helper.
* fix(terminal): atomic seq allocation + propagate destroy errors
- Repository.Create folds MAX(seq) read + INSERT into a single
`INSERT … SELECT` statement so concurrent creates can no longer pick
the same seq. Locked in with TestCreate_ConcurrentSeqAllocation (25
parallel creates, all unique).
- wsUserShellStop now propagates the service-Destroy error so the
frontend stops optimistically removing a row the backend failed to
delete.
- Service.Destroy / CleanupTask log PTY-stop failures at Warn (instead
of silently swallowing) while keeping the proceed-on-failure semantics
intentional — the DB row is the source of truth for "user expects
this terminal gone".
- IsUserShellAlive copies ProcessID under the read lock before calling
r.Get(), removing the post-unlock pointer-deref race.
- Get-not-found test asserts errors.Is(err, ErrNotFound) so it locks to
the repository contract.
* fix(web): close-tab waits for backend ack + className undefined guard
- handleCloseTab defers `removeTerminal` into the `.then()` of the
park/destroy promise. A failed backend call leaves the tab visible
rather than vanishing it optimistically; the next `user_shell.list`
poll reconciles state.
- session-tabs guards `tab.className` with `?? ""` so an undefined value
no longer produces "undefined group relative..." in the rendered
class string.
- user-shells.test simplification: `createSessionRuntimeSlice` only
consumes `set`, so the test passes the slice creator straight to
`immer()` instead of wrapping it in an adapter with unused args.
* fix(terminal): task-scoped mutations + legacy-shell list + UI hardening
Round-2 review feedback (cubic P1/P2, claude, coderabbit):
Backend:
- Rename/Park/Resume/Destroy now require a task_id and verify the
terminal belongs to that task via a shared requireOwnership helper.
Cross-task mutations return ErrTaskMismatch instead of silently
succeeding by raw terminal id.
- wsUserShellList no longer drops managed-shaped agentctl shells when
the terminal service is disabled or task_id is missing — legacy
passthrough callers see their existing shells again. `managedCovered`
flag scopes the orphan filter to the DB-backed path.
- service_test.go uses the shared-cache in-memory SQLite + MaxOpenConns(1)
setup, matching repository_test.go and removing the flake-one-race-away
warning.
- New cross-task guard tests: TestRename_RejectsCrossTask,
TestPark_RejectsCrossTask, TestDestroy_RejectsCrossTask, plus a
TestRename_EmptyTaskIDSkipsOwnership for internal callers.
Frontend:
- updateUserShell patch type narrowed to Partial<Omit<UserShellInfo,
"terminalId">> so the row key can't be silently mutated.
- destroyUserShell now throws on a missing WS client instead of silently
resolving — handleCloseTab's .then() no longer fires prematurely.
- rename/park/resume/destroy API client + useTerminals hook plumb the
active task id through every mutating call.
E2E:
- New "cross-task mutating ops are rejected" spec creates a terminal
under task A and verifies task B can't rename/park/destroy it by raw
id.
- Existing specs updated to send task_id with their mutating calls.
* fix(terminal): require task_id on all mutators + wire taskID into hooks
Round-3 review feedback (cubic P1, claude blocker, coderabbit):
Backend:
- requireOwnership no longer skips the ownership check when taskID is
empty. The previous carve-out was reachable from the WS layer (where
task_id is user-controlled), so an attacker who guessed a terminal id
could omit task_id and mutate any row. CleanupTask walks the repo
directly and never touched the bypass.
- TestRename_EmptyTaskIDRejected pins the new contract.
Frontend (the real bug claude caught):
- useUserShells now accepts a taskId parameter and forwards it on the
user_shell.list WS call. Without it the backend's DB-backed ordinary-
shell path was skipped and the Zustand store wiped the SSR-hydrated
panel strip on the first WS fetch.
- Response mapping switched to a discriminated union: id (new field)
falls back to terminal_id (legacy), and kind/seq/state/pty_status/
display_name/custom_name flow into the store so the parked-terminals
submenu actually populates.
- useTerminals + useMobileTerminals read the active task id from the
store and pass it through to useUserShells.
- destroyUserShell now retries with user_shell.stop when the server
returns an unknown-action error — keeps the rollout-window
compatibility intact for older backends that only know the legacy
action name.
* fix(terminal): list parked, rollout-safe kind default, helper extraction
Round-4 review feedback (claude + greptile P1):
- useUserShells now sends include_parked: true so the "Parked terminals"
submenu populates after a page reload. Previously the WS list returned
only state=open rows; parked terminals were invisible until the user
parked something in the same session. The main strip still hides
parked entries via buildTerminalsFromShells.
- useAddTerminal stops blindly defaulting kind to "ordinary" — that
misclassified legacy passthrough payloads (terminal_id without seq)
during a rollout where the frontend is newer than the backend. The
managed-tab path then called parkUserShell on a server that didn't
know the action. Now only tags as ordinary when kind is explicit or
the row carries a seq.
- handleCloseTab extracted into a standalone useCloseTab + helpers
hoisted into use-terminals-build.ts / use-terminals-types.ts. Brings
use-terminals.ts back under the 600-line file limit and the
useTerminalActions wrapper inside the 100-line function limit.
* fix(terminal): scope-keyed shell cache + null-task guard + tighter rollout default
Round-5 review feedback (cubic P1, coderabbit x4):
- useUserShells now keys its fetch cache on env+task (`${env}:${task}`),
not just env. An earlier load without a task id (e.g. before the
active task hydrated) no longer pins the store on a legacy-only shell
list and blocks the task-scoped refetch.
- useMobileTerminals delays auto-create until a real taskID is
available. Previously a null taskID could bootstrap a legacy
non-persistent shell, mark the env as auto-created, and starve the
real task of its first-class terminal. Guard key is now env:task, and
releaseAutoCreatedEnvironment clears every scope on that env.
- useAddTerminal tightens the rollout check: a row is "ordinary" only
when `seq !== undefined`. A bare `kind: "ordinary"` without seq from
an older backend stays legacy so close → destroy on the first click.
- useRemoveTerminal reads the current activeTab from a ref instead of
closing over the value captured when handleCloseTab first ran. Async
park/destroy resolves after a user tab-switch no longer clobber the
newer selection with the stale fallback shift.
Tests:
- New "waits for taskID before auto-creating — null taskID is a no-op"
case in use-mobile-terminals.test.ts confirms the null-task guard.
- All existing mobile-terminal + slice + E2E tests pass.
* fix(terminal): live activeTab lookup + selectable-tab guard
Round-6 review feedback (cubic P2 x2):
- getActiveTab now reads the active tab from the Zustand store via
storeApi.getState() instead of a useRef/useEffect pair. Closes the
post-render race where an async close handler could resolve between
the new render's commit and the effect-driven ref sync.
- computeTerminalTabValue requires activeTab to be selectable
(present in `terminals` or === "commands") before honoring it. A
stale id left over from a parked/destroyed terminal no longer pins
the panel on a nonexistent tab and blocks the fallback shift to a
real shell.
* chore(terminal): inline savedTabFromStorage to drop file under line cap
Round-7 review feedback (claude suggestion): the useMemo around the
synchronous getSessionStorage call was zero-value memoisation that
pushed use-terminals.ts three lines over the 600-line max-lines limit.
Inlined; file now at 600 lines.
* chore(terminal): drop savedTabExists local + comment to land under 600 lines
Round-8 follow-up (claude): the previous commit's two-line comment block
held the file at 603 lines (still over the 600-line cap). Inline
savedTabExists at the only call site and remove the redundant comment —
the round-7 PR thread already explains why the useMemo was removed. File
now at 599 lines.
* fix(terminal): plumb task_id through dockview + tolerate legacy destroys
Manual-test feedback: clicking X on a dockview terminal panel surfaced
"destroy terminal: terminal does not belong to the supplied task" because
the dockview path was creating + destroying terminals without sending
task_id at all. The new requireOwnership guard then correctly rejected
the empty task_id against a non-empty term.TaskID.
Frontend:
- addTerminalPanel grows task_id + title parameters; stamps both into
panel params at creation time so the destroy cleanup can pass them
back to user_shell.destroy / .stop.
- Every dockview-side createUserShell call site now passes the active
task_id, so the backend creates a DB-backed ordinary terminal (with
seq + displayName) instead of a legacy passthrough shell.
- destroyUserShell + stopUserShell already accepted an optional task_id;
dockview-layout-setup.onDidRemovePanel now reads the stamped task id
from panel params and forwards it.
Backend:
- wsUserShellStop now requires both terminalSvc and a non-empty
req.TaskID before routing through the managed service. An empty
task_id falls through to the legacy passthrough StopUserShell so older
clients (and dockview panels persisted before task_id was stamped)
keep working.
Known follow-ups (separate task): the #N badge, rename context menu,
and parked-terminals dropdown still only show on the mobile/tablet
right-panel strip. Desktop dockview shows the seq inside the panel
title text but doesn't expose the badge/rename/parked-list UI yet.
* feat(terminal): dockview tab badges + rename context menu + existing-terminals list
Manual-test feedback round 2:
- Custom dockview tab component (TerminalTab) renders the #N pill
only when the active env has more than one ordinary terminal —
mirrors the session-tab badge semantics. Right-click on any ordinary
terminal tab now exposes Rename / Park / Destroy.
- Dockview + menu picks up a new "Terminals" section listing every
ordinary terminal for the active task (open and parked). Clicking a
row focuses the existing dockview panel if one is mounted, otherwise
re-opens it; parked rows are auto-resumed via user_shell.resume
before the panel is added. The base "Terminal" action was renamed
to "New Terminal" so the existing-terminals list is distinguishable
in the menu.
- Reopen menu uses a stable EMPTY_SHELLS sentinel for its store
selector to avoid Zustand infinite re-renders when the env has no
shells.
E2E:
- terminal-new-tab.spec uses getByRole("menuitem", { name: "New
Terminal", exact: true }) to disambiguate from the new "Terminals"
submenu label.
- Full terminal suite still passes (26/26).
* fix(terminal): tab title shows "Terminal" with seq as badge, populate store on session page
Re-reading the user feedback: the title text was rendering as
"Terminal 1" (backend's displayName), and the dockview "+" menu's
Terminals section was empty because nothing on desktop was calling
useUserShells to populate the store.
- TerminalTab.pickDisplayName now returns the literal "Terminal" for
ordinary terminals (custom names still override). The seq lives only
in the sibling badge — same split as session-tab where the agent
name is the title and the seq is a pill before it. Reading "Terminal
1" out of "[1] Terminal" instead of "[1] Terminal 1".
- LeftHeaderActions (dockview "+") now calls useUserShells on mount.
Without this the desktop layout never triggered the WS list fetch,
so the Terminals submenu and the badge "count > 1" check both saw
an empty store and silently disappeared.
- TerminalReopenMenuItems also calls useUserShells defensively (cheap
thanks to scope-keyed cache) so it works when mounted in isolation.
* feat(terminal): "New X" rows live inside their section labels
User feedback: move "New Agent" under the Agents section header and
"New Terminal" under the Terminals section header so the dockview "+"
menu reads top-to-bottom as Agents → New Agent → existing sessions →
Terminals → New Terminal → existing terminals → other panels.
- SessionReopenMenuItems accepts an optional onNewSession prop and
renders the "New Agent" row as the first item under the "Agents"
label. Render gate now keeps the section visible whenever the create
handler is supplied, even with zero existing sessions.
- TerminalReopenMenuItems gets a matching onNewTerminal prop with
the same behaviour ("New Terminal" row under the "Terminals" label).
- dockview-add-panel-items drops its standalone "New Agent" and "New
Terminal" rows since both now live inside their sections.
* feat(terminal): auto-migrate default panel to DB-backed ordinary terminal
The legacy `terminal-default` dockview panel was hard-wired to a
non-managed `shell-default` PTY so it never picked up an ordinary
seq/customName/parked state — the new "Terminal N" badge UX could
only show a badge once the user had explicitly created a second
terminal. With useEnsureDefaultTerminalOrdinary the default panel is
upgraded into a real `shell-<uuid>` row on session-page mount, so
every visible tab is now a first-class ordinary terminal.
Plus a UI-level e2e spec asserting the multi-terminal title + badge
contract, and an expectation bump on the existing rename+park spec
since visiting the session page now mints 1 extra ordinary terminal.
* fix(terminal): park ordinary terminals on tab close so they survive refresh
Dockview's tab X close button routed straight to stopUserShell (an
alias for destroyUserShell), wiping the DB row on close — after a
refresh the user had no way to resume a still-running PTY they had
just tucked away.
The X is now a "hide for now" affordance: ordinary terminals get
parked (PTY survives, row stays in DB, reappears in the "+" →
Terminals menu with a `parked` pill), while scripts / bottom-panel /
legacy passthrough shells stay on the destroy path because they have
no resume story.
Permanent delete still lives on the right-click Destroy menu item
and (in the next commit) on each reopen-menu row.
* fix(terminal): preserve DB-backed terminal panel ids across layout restore
The serializer's normalizePanel rewrote every user-created terminal
panel id to `terminal-saved-N` and dropped environmentId/taskID from
params, so after a reload that panel couldn't be matched back to its
live PTY or its store record — the tab rendered with no badge and a
stale fallback title.
Treat `shell-<uuid>` panel ids the same way we already treat
`terminal-default`: preserve id and params on restore. Legacy
ephemeral `terminal-saved-*` ids still mint a fresh PTY on restore.
Also force tabComponent=terminalTab and title="Terminal" for any
restored terminal panel so layouts persisted before the badge UX
land on the new presentation.
* feat(terminal): destroy button on reopen-menu rows for permanent delete
Destroy used to live only on the tab's right-click menu — useless once
the tab was already closed. Each ordinary terminal row in the dockview
"+" → Terminals submenu now ends with an × button that confirms-then
calls destroyUserShell, killing the PTY and dropping the DB row so the
terminal does not reappear after reload.
Pairs with the previous close-to-park change: closing the tab parks
(soft, resumable), the × button on the menu row destroys (hard,
permanent).
* fix(terminal): reopen-menu focuses existing tab, drops parked pill, says Terminate
Three small fixes to the dockview "+" → Terminals submenu:
1. Clicking a row whose terminal is already mounted now FOCUSES the
existing panel instead of duplicating it. The default-migrated
panel keeps its registry id `terminal-default` even after the
migration to a `shell-<uuid>` PTY, so a direct getPanel(uuid) miss
would fall through to addTerminalPanel and create a second tab for
the same PTY. Fall back to scanning `params.terminalId` so the
lookup catches that case.
2. Drop the "parked" pill from the row — parking is now an internal
detail (close = park) that the user doesn't need to read off the
menu. Clicking the row brings the terminal back into view either
way, so distinguishing it visually is noise.
3. Rename the row × tooltip + confirm copy from "Destroy" / "Permanently
delete" to "Terminate" to match the new tab-context-menu label.
Spec: add a test that asserts no duplicate tab when reopening an
already-open terminal, and drop the parked-pill assertion from the
existing close+reload test.
* feat(terminal): empty-group watermark uses the rich + menu
When a user splits a group (split-right / split-down) the new half is
empty and dockview renders the watermark. Before this, the watermark
showed a hard-coded Agent/Plan/Terminal/Browser row and clicking
"Terminal" called createUserShell without a taskId — so it spawned a
legacy passthrough shell that never appeared in the user-shell list,
and clicking "Agent" only ever focused the singleton chat panel
(invisible if the task had multiple sessions).
The watermark now renders the same AddPanelMenuItems content the
header "+" uses, so the user gets:
- The Agents list (existing sessions, greyed when already open)
- The Terminals list (existing terminals, greyed when already open)
- The Browser/Plan/VS Code/Changes/Files/PR options
…served behind a single "Add panel" dropdown trigger centered on the
empty group.
* feat(terminal): inline rename on the tab; drop Park, rename Destroy → Terminate
The tab title is now editable in place — picking Rename from the
right-click context menu swaps the title for an input. Enter commits,
Escape / blur-with-no-change cancels, and the new name is pushed
through renameUserShell + the user-shell store so the API and badge
list stay in sync. Replaces the previous window.prompt which felt
out of place in the dockview chrome.
While here:
- Drop the "Park (hide, keep PTY)" context-menu item. Tab close
already parks ordinary terminals, so the explicit Park action was
redundant and broke the "one obvious affordance per outcome" rule.
- Rename "Destroy" → "Terminate" in the context menu. Matches the
reopen-menu × tooltip and reads more like a system action than a
destructive UI primitive.
Spec adds an inline-rename test asserting the input appears, accepts
typed text, and commits the new title to the tab.
* fix(terminal): right-click Terminate closes the tab; drop displayName DOM warning
Two unrelated fixes both landing in terminal-tab.tsx:
- Terminate used to fire the WS destroy and update the store, but left
the dockview panel mounted with a dead PTY underneath. Now it
optimistically drops the shell row from the store, closes the panel
via api.close(), then sends the destroy — same effect as the X close
but routed to the hard-destroy path (the onDidRemovePanel cleanup
sees no shell entry and skips its park branch).
- TerminalTabBody typed `displayName` as a prop but never destructured
it, so {...props} spread it into DockviewDefaultTab and on into a DOM
div, triggering "React does not recognize the `displayName` prop on
a DOM element". Drop it from the spread.
* feat(terminal): polish + menu — badge-style indices, flush-left New rows, terminate closes panel
Polish pass on the dockview "+" → Agents / Terminals submenus:
- Replace the plain `#N` text with the same pill-style badge the tabs
use (small bg, rounded, 11px). Agents and terminals both pick this
up — so the menu row reads "[N] label" matching the tab style.
- Drop the empty index-column placeholder span on the "New X" rows so
the icon + label sit flush-left under the section header instead of
awkwardly indented.
- Strip the backend "Terminal {seq}" suffix from row labels — the
badge already carries the number, so showing "Terminal 1" next to
the "1" pill duplicated it. Custom-renamed terminals still show
their name.
- The row × button now also closes the dockview panel if the terminal
is currently open, mirroring the right-click → Terminate flow.
Spec: switch the row-by-seq lookups from `hasText: "#N"` (now stale
since the `#` prefix is gone) to a `has:` filter against the new
`reopen-terminal-seq-N` badge testid, and add a regression test for
right-click → Terminate closing the tab and removing the row in one
gesture.
* fix: address terminal review feedback
* fix: resolve terminal review suggestions
* fix: catch terminal close stop failures
* fix: harden terminal terminate failures
* fix: use pinned pnpm docker arg
* fix: satisfy backend lint after main merge
* fix: stabilize e2e after layout merge
|
||
|
|
f3daeaf416 |
feat(cli-mode): forward Preview chat input to PTY in passthrough sessions (#1042)
* feat(cli-mode): route Preview chat input through PTY stdin in passthrough sessions
Branch Executor.Prompt on lifecycle.Manager.IsPassthroughSession so that
follow-up prompts from any UI surface (the kanban Preview screen, mobile
session view, etc.) reach the running CLI agent via PTY stdin instead of
silently falling through to the ACP path. WritePassthroughStdin failures
propagate so Service.handlePromptError reverts session state and surfaces
the error; MarkPassthroughRunning failures are non-fatal because the data
is already in the PTY.
Closes the backend half of #989; the Preview-side UI compose box ships
in a follow-up commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(cli-mode): add Preview-screen compose box for passthrough sessions (#989)
In the kanban Preview tab, render a small kandev-controlled composer below
the PassthroughTerminal when the session is is_passthrough. Submitting (Enter
or click) posts message.add over WS — the backend's Executor.Prompt routes
passthrough sessions to PTY stdin so the CLI agent actually receives it.
Shift+Enter inserts a newline; empty/whitespace input is disabled.
This closes #989 together with the previous backend commit.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli-mode): warn when dropping attachments on passthrough prompts
Code review found that a passthrough prompt carrying non-empty text and
attachments silently dropped the attachments — surprising for any UI
surface (e.g. the full-page TaskChatPanel) that lets users attach images
to a passthrough task. The plan flagged this exact "silent drop" risk.
Pass attachments into promptPassthrough and log a warning with the
count when any are dropped; empty-text-with-attachments still errors
out as before. Adds a regression-guard test.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* test(cli-mode): cover passthrough composer routing end-to-end
QA pass added integration-level tests at the Service.PromptTask layer
that exercise the full wiring (real Service + real Executor, with a
mocked agent manager) for the three issue-#989 acceptance paths:
prompt-routes-to-PTY, empty-prompt-surfaces-error, and write-failure-
reverts-session. Also adds a unicode/multiline preservation guard at
the Executor.Prompt unit layer. Lives in a new file to avoid pushing
task_operations_test.go over the file-length-limit threshold.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli-mode): preserve composer text on send failure + clarify dispatchOnly
Addresses two pieces of PR #1042 review feedback.
1. PassthroughComposer used to clear the textarea unconditionally after
onSubmit resolved, but the WS handler swallowed transport errors and
resolved normally — so a network blip silently wiped the user's typed
message with no way to retry. Add a passthrough-specific send handler
that rethrows after toasting, and gate the setValue("") in the composer
on success. Preserves the ACP handler's swallow-and-resolve contract
to avoid leaking unhandled rejections into the TaskChatPanel chain.
New regression test: "preserves the typed text when onSubmit rejects".
2. Add a one-line comment at the passthrough branch in Executor.Prompt
explaining why dispatchOnly is not forwarded — PTY writes are inherently
fire-and-forget, so the flag has no analogue.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(cli-mode): toast disconnect + auto-grow passthrough composer textarea
Two follow-ups from PR #1042 review on commit
|
||
|
|
c9b6fe4b8a | fix(worktree): support fork PR worktree creation via pull refspec (#990) | ||
|
|
f39e7b06ca |
fix(agent): honor session passthrough snapshot when starting agent process (#1003)
* fix(agent): honor session passthrough snapshot when starting agent process Tasks created in agent (ACP) mode got stuck in "starting" after toggling the profile's cli_passthrough flag and restarting the server. StartAgentProcess re-resolved the live profile each launch, so the new flag rerouted existing ACP sessions into the passthrough path (and vice versa), where the workspace plumbing didn't match. Snapshot session.IsPassthrough onto LaunchAgentRequest / lifecycle.LaunchRequest / AgentExecution and let StartAgentProcess use that as the source of truth. Sessionless launches (legacy controller.LaunchAgent) still fall back to live profile resolution. Fixes #981. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(agent): make passthrough snapshot the sole routing signal in StartAgentProcess Bot review feedback follow-up (#1003): - Route on the snapshot alone (new routePassthrough helper). A transient ResolveProfile error no longer silently routes a passthrough session to ACP — the fresh path surfaces an explicit error, the resume path surfaces the existing resolvePassthroughAgent error. - Add TestStartAgentProcess_PassthroughResume_SurvivesProfileResolveError and TestStartAgentProcess_FreshPassthrough_SurfacesProfileResolveError to lock the no-silent-ACP-fallback invariant. - Add TestLaunchPreparedSession_PropagatesIsPassthrough mirroring the resume-path propagation test so both build paths are independently covered. - Strip issue-number references from source/test comments per project convention. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(agent): avoid double ResolveProfile in sessionless passthrough path routePassthrough now returns (bool, *AgentProfileInfo). When the sessionless fallback path already resolved the profile, it threads it back so StartAgentProcess can reuse it for command building instead of calling ResolveProfile a second time. Snapshot-driven routing still returns nil so the snapshot path resolves fresh — the snapshot deliberately outlives any particular live profile state. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * fix(agent): extract startPassthroughExecution to satisfy nestif CI's golangci-lint flagged the nested-if complexity of the passthrough dispatch block in StartAgentProcess after the previous round of fixes pushed it past nestif's threshold of 5. Move the resume-vs-fresh branching plus the lazy profile resolution into a startPassthroughExecution helper so StartAgentProcess becomes a single short routing decision. No behavior change. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com> * docs: drop caller reference from startPassthroughExecution comment Per project convention, source comments shouldn't name the calling function — the reason it's its own method is captured by its contract (profileInfo nullability + routePassthrough handoff), not by the caller's name. 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> |
||
|
|
b39f256e84 | fix(orchestrator): propagate RepositoryID so worktree setup script runs (#969) | ||
|
|
2ca48fc7df | feat(repositories): guard delete behind active-session check + UI fixes (#968) | ||
|
|
569a0d77bd |
fix(office): unbreak new-task dialog "Create Task" button (#976)
* fix(office): unbreak new-task dialog "Create Task" button
Office task creation silently failed: the button click sent a malformed
payload, the backend dropped it, and no toast surfaced because Sonner's
Toaster was never mounted. Three causes, all fixed here.
- web: send priority as a string id ("medium") instead of a number; the
backend Priority field is a string and ShouldBindJSON was rejecting
the body with "invalid payload".
- web/backend: add project_id as a top-level field on createTask. The
dialog was burying it inside metadata where the HTTP handler never
read it, so isOfficeRequest() returned false and validation tripped
on "workflow_id is required for non-ephemeral tasks".
- web: mount the Sonner Toaster in the root layout. All sonner-based
toasts across the app (not just this dialog) were silently dropped
because no Toaster was rendered anywhere.
* fix(office): address PR #976 review feedback
- new-task-dialog: drop unreachable `|| undefined` guard on priority
(EMPTY_DRAFT.priority is always "medium" and the picker only sets
non-empty strings, so the fallback can never fire).
- layout: move sonner Toaster to top-right so it stops visually
overlapping with the custom ToastProvider's bottom-right stack.
- task_http_handlers_test: add regression test asserting that a
create-task body with project_id (and no workflow_id) reaches the
office code path and the office workflow auto-resolves. Guards the
exact wiring that caused the original silent failure.
---------
Co-authored-by: Kandev Agent <agent@kandev.dev>
|
||
|
|
9fd789ba04 | fix(executor): persist worktree subdir as workspace_path (#958) | ||
|
|
adcde7c89e | fix(backend): persist kandev-system wrap on first task prompt (#940) | ||
|
|
f6bd83da33 | refactor: tighten type system across backend + frontend (#920) | ||
|
|
0b2085688b | feat(office): autonomous agent management layer (#914) | ||
|
|
a5108de926 | fix(windows): make conpty Close idempotent and kill orphan agentctl (#900) | ||
|
|
f3397b4ad7 |
feat: docker and sprites executor improvements (#738)
* feat(orchestrator): generalize task environment reuse across executor types
Worktree, container, and sandbox IDs now flow uniformly from a persisted
TaskEnvironment back into LaunchAgentRequest through a single helper. This
gives Sprites session-to-session reuse the same first-class treatment that
Docker already gained from the GitHub-token-injection PR, and lets a fallback
launch refresh the environment row instead of leaving stale IDs in the DB.
- applyExistingEnvironment propagates WorktreeID, ContainerID (via metadata),
and SandboxID (as sprite_name metadata) onto the launch request
- persistTaskEnvironment refreshes those IDs when the executor ends up
creating new resources (e.g. the previous container was pruned)
- extractSandboxID lifts the sprite identifier out of executor response
metadata so it can be persisted on the TaskEnvironment row
* feat(backend): add task-level reset environment action
Users sometimes need a clean slate — broken packages, stale caches, a
container that drifted into a bad state. Reset Environment is the explicit
escape hatch: tear down the recorded container, sandbox, and/or worktree
for a task and delete the TaskEnvironment row so the next session starts
fresh.
- service.ResetTaskEnvironment(taskID, ResetOptions) is the entry point.
ResetOptions.PushBranch optionally git-pushes the workspace branch
before teardown so committed work isn't dropped on the floor; a failed
push aborts the reset and leaves the environment intact.
- A new EnvironmentDestroyer interface in the task service hides the
per-resource teardown behind a single seam, implemented in cmd/kandev
by delegating to lifecycle.Manager (containers, sprites) and
worktree.Manager (worktrees, branch preserved).
- lifecycle.Manager.DestroyContainer / DestroySandbox dispatch to the
Docker and Sprites executor backends without leaking model types into
the lifecycle package.
- Reset is blocked while any session on the task still has an
ExecutorRunning row (returns ErrSessionRunning → HTTP 409). Per-resource
teardown is best-effort but the DB row is only removed once everything
is confirmed gone or already missing, so a partial failure is retryable
by clicking Reset again.
- Endpoint: POST /api/v1/tasks/:id/environment/reset.
* feat(web): add executor settings popover with reset environment action
A new button on the task top bar opens a popover showing the active
TaskEnvironment — executor type, status, container/sprite/worktree
identifiers, and a copy-ready 'docker exec' command for local Docker.
The popover hosts the destructive Reset Environment action so users have
one obvious place to inspect and manage the environment without
cluttering the New Session dialog.
The reset confirmation dialog requires explicit acknowledgement that
uncommitted changes will be lost. When the environment has a host-side
worktree it offers an opt-in 'push branch before resetting' checkbox so
committed work can be pushed first; the backend aborts the reset on
push failure.
- task-environment-api.ts: fetchTaskEnvironment + resetTaskEnvironment
- executor-settings-button.tsx: popover trigger + identity panel
- task-reset-env-confirm-dialog.tsx: destructive confirm with push opt-in
- task-top-bar.tsx: wire the new button into the right-side controls
- e2e: replace fresh-env-toggle test with reset-from-popover flow
* fix(docker): expand {workspace} placeholder in container WorkingDir
The agent runtime config carries `WorkingDir: "{workspace}"` for most ACP
agents. Mount sources were already expanded via expandMountSource, but
WorkingDir was passed through verbatim, so Docker rejected new containers
with:
Error response from daemon: the working directory '{workspace}' is
invalid, it needs to be an absolute path
Run the same expansion on WorkingDir, defaulting to /workspace when no
host WorkspacePath is set (the clone-inside-container case introduced by
PR #654).
* fix(docker-profile): persist Dockerfile/image_tag and apply tag override at runtime
Two bugs:
1. The Dockerfile editor and Image Tag input on the executor profile page
stored their values in component-local useState inside DockerSections,
not in the form state. Saving the profile never wrote them back, so
"Use defaults" / typing in the editor / changing the tag never
persisted — and the Build button was the only thing that consumed
them. The same gap existed in the new-executor flow's
buildProfileConfig.
Lift `dockerfile` and `image_tag` into useProfileFormState (edit) and
useCreateProfileFormState (new), and emit them in buildSaveConfig /
buildProfileConfig so they round-trip through profile.config.
2. Even when an image_tag is set on the profile, the container builder
ignored it. imageName was always derived from the agent runtime's
hardcoded `kandev/multi-agent`, so a user who built a custom image
like `kandev/agent:latest` would still see Docker fail with
"No such image: kandev/multi-agent:latest".
Propagate `profile.config.image_tag` → metadata
(`image_tag_override`) → ExecutorCreateRequest → ContainerConfig,
and let it override `rt.Image` in buildContainerConfig when set.
Also align the frontend default image tag (kandev/multi-agent:latest)
with the backend's hardcoded runtime default so users who don't override
end up with a working build.
* test(docker): cover container config build + Docker profile persistence
Adds two regression tests for the recent Docker fixes:
- container_config_test.go (Go unit): asserts buildContainerConfig
expands `{workspace}` in WorkingDir (defaulting to /workspace when no
host WorkspacePath is set) and that ContainerConfig.ImageTagOverride
takes precedence over the agent runtime's hardcoded `rt.Image`. No
Docker daemon required.
- docker-profile-persistence.spec.ts (Playwright E2E): creates a
local_docker executor + profile, opens the profile edit page, clicks
"Use defaults" to populate the Image Tag input + Dockerfile editor,
hits Save, then refetches the profile via API and reloads the page to
verify both fields round-trip through `profile.config`. Catches the
prior regression where DockerSections owned the form state locally
and never wrote it back. Runs against the existing E2E backend that
has KANDEV_DOCKER_ENABLED=false — we only test the persistence path,
not container creation.
Adds an api-client helper (`getExecutorProfile`) for the round-trip
assertion.
* feat(docker): self-contained Dockerfile + mount agentctl + capabilities adapter
Make the Docker executor work with a Dockerfile users can actually build,
without depending on a maintainer-shipped image:
- The container now launches a small bootstrap (`sh -c "$KANDEV_PREPARE_SCRIPT
; exec /usr/local/bin/agentctl"`) regardless of the image's ENTRYPOINT.
The agent CLI is no longer wired through container Cmd; agentctl receives
it later via its CreateInstance HTTP API.
- Host agentctl-linux binary is mounted into the container at
/usr/local/bin/agentctl via the existing AgentctlResolver. User-built
images don't have to bake it in.
- Default Dockerfile in the executor profile UI replaced with a working
node:22-slim + git + curl image — runs npx-based ACP agents (Claude,
Codex, OpenCode, Auggie) out of the box.
- lifecycleAdapter now exposes RequiresCloneURL / ShouldApplyPreferredShell
so the orchestrator can correctly identify containerized executors and
resolve the clone URL. Without this, applyRepositoryConfig short-circuits
on `e.capabilities == nil`, the cloneURL is never resolved, and the
prepare script fails with `repository '{{repository.clone_url}}' does
not exist`.
- applyRepositoryConfig also surfaces the resolved clone URL to script
metadata so the prepare script can interpolate {{repository.clone_url}}.
- docker.ContainerConfig gains an Entrypoint field, plumbed to the docker
client.
Verified end-to-end on a local stack with the new minimal image: the
container starts, bootstrap clones the GitHub repo, agentctl launches and
hands off to claude-acp inside the container.
* fix(docker): patient health check + resilient bootstrap
Two follow-ups to the Docker bootstrap work:
- The 30 retries × 500ms = 15s budget for agentctl health was too tight.
In the previous flow agentctl was a separate exec from the prepare
script, so it came up immediately. Now it runs after the prepare
script (which does git clone, optional installs), so first launch can
easily exceed 15s and produce a misleading "agentctl not healthy after
30 retries" error. Bumped to 240 retries (120s) and surfaced the last
underlying error in the message so failures point to the real cause.
- Prepare scripts commonly start with `set -e`/`set -eu`; running them
via `eval` propagated the option to the outer bootstrap shell, so any
failure killed the bootstrap before `exec /usr/local/bin/agentctl`.
Run prepare in a subshell so its `set -e` is contained, and start
agentctl regardless. If prepare fails, agentctl still comes up — the
host can connect, surface the failure, and let the user debug from
the Executor Settings popover.
* fix(executor-profile): persist remote auth secrets/credentials for Docker profiles
The Remote Credentials card (per-agent secret picker, plus the GitHub
credential checkbox) is rendered for any remote executor — Sprites,
local_docker, or remote_docker — but the save path in both the create
and edit pages only emitted `remote_auth_secrets` and `remote_credentials`
when `isSprites` was true. Selecting a secret for the Claude / Codex /
Auggie ANTHROPIC_API_KEY-style entry on a Docker profile updated form
state, the dropdown showed it as picked, but Save Changes silently
dropped the value before posting to the API. On reload the agent showed
as "Not configured", and any session start failed with
`{"code":-32000,"message":"Authentication required"}`.
Switch the guards to `isRemote` so the same data persists for all remote
profile types. The backend orchestrator already propagates
`profile.config.remote_auth_secrets` and `remote_credentials` through to
metadata for any containerized executor and resolves them via
`applyContainerCredentials` → `resolveAuthSecrets`, so no backend change
is needed once the values actually land in the saved profile.
* fix(executor-profile): live Configured badge + session/load resilience + live container status
Three independent fixes that all surface together while iterating on the
Docker executor flow:
- Remote Credentials card: the per-agent "Configured" / "Not Configured"
badge was driven by useState that only seeded once on mount. Picking a
secret in the dropdown updated the parent's `agentEnvVars` map but the
local `choice` never moved to `"env"`, so the badge stayed grey until
the page was reloaded. Derive `choice` straight from props every render
so the badge tracks live state.
- ACP session/load: when the agent process restarted between turns
(common with claude-agent-acp because its session map is in-memory),
the host's session/load call failed with JSON-RPC -32002 "Resource not
found" and the entire launch errored out. Treat that as the same kind
of "session is gone" signal as -32601 / capability=false and fall back
to session/new. Worst case the agent loses prior context; best case
the user just keeps typing.
- Executor Settings popover: dropped Execution ID and Environment ID
rows (debug-only noise users don't need), kept Container ID + the
copyable `docker exec -it … bash` line, added a live status badge
with tones for running / exited / paused / dead / error / missing,
and a 3-second polling interval so the badge follows the real
container state. New `GET /api/v1/tasks/:id/environment/live`
endpoint plumbs Docker `inspect` data through the lifecycle manager
→ task service → handler. ContainerLiveStatus mirror type at the
service layer keeps lifecycle types out of the public surface.
* feat(web): gate agent profiles by executor profile credentials
Filter the agent profile dropdown in both the Create Task dialog and the
New Session dialog to only show agents that are actually configured for
the selected executor profile.
For local/worktree executors, no gating (agents need no extra creds).
For Docker/Sprites profiles, an agent is "configured" iff one of its
remote-auth methods has a value in `remote_credentials` or a non-null
secret in `remote_auth_secrets`. Agents without an entry in the remote
auth catalog (Copilot, Amp, OpenCode, TUI, mock) cannot carry creds on
remote executors and are blocked.
Spec IDs match `AgentProfileOption.agent_name` (the registry-type string
like "claude-acp"); `agent_id` is a DB UUID and unrelated to the catalog.
The Create Task footer surfaces a "No agent profile is configured for X"
tooltip and disables submit when the selected executor profile has no
compatible agents. The New Session dialog auto-swaps to the first
compatible agent and shows the same banner inline.
`useRemoteAuthSpecs` returns a `loaded` flag so callers don't filter
during the initial fetch (would otherwise hide everything).
* feat(docker): make mock-agent runnable inside Docker for E2E
Lay the backend groundwork so the existing mock-agent can run inside
local_docker containers — needed for real-Docker E2E tests that don't
require Anthropic/OpenAI API keys.
Container builder bind-mounts a host mock-agent binary at
/usr/local/bin/mock-agent when KANDEV_MOCK_AGENT_LINUX_BINARY is set
(or build/mock-agent-linux-amd64 sits next to the kandev binary), the
same pattern already used for agentctl. MockAgent.BuildCommand always
resolves via PATH so the same command works on host (E2E PATH includes
bin/) and in containers (mounted at /usr/local/bin/mock-agent).
Mock now declares non-nil empty RemoteAuth so it counts as a
remote-capable agent. The remoteauth catalog includes specs even with
zero methods, and the frontend gating helper treats an empty-method
spec as "no credentials needed" — letting mock-agent pass the
configure-on-executor check that hides uncatalogued agents on remote
executors.
repositoryCloneURL falls back to the local origin remote URL when the
repo has no provider info, so local-only repos with a real remote
(including file:// remotes used in E2E) work on remote executors that
clone inside the container.
Makefile: build-mock-agent-linux target mirrors build-agentctl-linux.
* test(docker-e2e): add real-Docker Playwright project + first spec
A new Playwright project — \`docker\` — runs container-bound E2E specs
against a backend with KANDEV_E2E_DOCKER=1. The fixture probes for a
Docker daemon and skips the worker entirely when none is reachable, so
contributors without Docker can still run the rest of the suite.
The fixture builds a tiny kandev-agent:e2e image once per machine
(node:22-slim + git/curl), pre-seeds a local_docker executor profile,
and seeds a local repo with a file:// origin so the executor's
clone-inside-container path resolves without network access. A small
WS-based session.launch helper is added to the api-client for tests
that need to launch additional sessions on an existing task.
First spec — docker-launch.spec.ts — covers the smoke path: a session
launches in a real container, the task environment records its
container_id, and \`docker inspect\` confirms the container exists.
Two follow-up specs (multi-session env reuse + recovery from external
container removal) are wired up but marked test.fixme with comments
pinpointing the backend gaps that block them: persistTaskEnvironment
isn't called for workflow-prepared sessions (so task_environment_id
stays empty on those rows), and DockerExecutor.reconnectToContainer
loses the agentctl handshake auth token across launches (so reconnect
401s and falls back to a fresh container every time).
Run locally with: \`pnpm --filter @kandev/web exec playwright test
--config e2e/playwright.config.ts --project=docker\`.
* ci(e2e): add dedicated Docker E2E job
The new \`docker\` Playwright project needs a Docker daemon and the
linux/amd64 agentctl + mock-agent binaries bind-mounted into containers.
Pin the existing sharded job to chromium + mobile-chrome so the docker
project no longer leaks into every shard (where it would fail seeding
without KANDEV_DOCKER_ENABLED), and add an \`e2e-docker\` job that:
- Verifies the Docker daemon is reachable on the runner.
- Downloads the same backend artifact (now including the linux helper
binaries, built via \`make build-backend-linux-helpers\`).
- Runs \`playwright test --project=docker\` with KANDEV_E2E_DOCKER=1.
- Cleans up any kandev-managed containers at teardown so leaks don't
poison the next run.
* ci(e2e): include docker job in merged HTML report
Upload the docker job's blob report under the \`blob-report-*\` glob the
merge job already consumes, and add e2e-docker to e2e-report's needs so
the final HTML report covers both the sharded chromium runs and the
docker run.
* ci(e2e): add workflow_dispatch for ad-hoc runs and draft-PR verification
* fix(build): always produce linux/amd64 agentctl as part of \`make build\`
Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl binary into the container. On non-Linux hosts (e.g.
macOS dev installs) running plain \`make build\` produced only the host
binary, so the first Docker session would fail with:
failed to build container config: agentctl linux binary not found
The top-level \`make dev\` already special-cased non-Linux hosts to chain
\`build-agentctl-linux\`, but \`make build\` and \`make start\` skipped it.
Add it as a dependency of the standard backend build so every install
ships a usable linux variant. Cross-compiling agentctl is fast (~1-2s)
and the binary is ~20 MB, so the cost is negligible.
Users who hit the error can run \`make build-backend\` (or \`make build\`
in apps/backend) to regenerate; setting KANDEV_AGENTCTL_LINUX_BINARY
remains a manual override.
* fix(release): ship linux/amd64 agentctl in every release artifact
Remote executors (local Docker, remote Docker, Sprites) bind-mount the
linux/amd64 agentctl into the container. The release pipeline only
shipped a host-arch agentctl, so users who installed via the bundled
binaries (npm/Homebrew/etc.) hit the same "agentctl linux binary not
found" error the first time they tried a Docker executor — even on
Linux, since the resolver expects the -linux-amd64 suffix.
Changes:
- .github/workflows/release.yml: cross-compile agentctl-linux-amd64 in
the build-bundles matrix step (CGO off so it works from every host).
Bundle ships kandev + host agentctl + agentctl-linux-amd64 in
dist/kandev/bin/. The AgentctlResolver finds it next to the kandev
binary automatically.
- Dockerfile: same idea for the container image — produce both variants
in the Go builder stage and copy the linux-amd64 helper into
/app/apps/backend/bin/ so the resolver picks it up inside the image.
A previous commit added the same dependency to \`make build\` for local
dev installs; this commit closes the gap for end users.
* fix: address PR review feedback (CI, CodeRabbit, Greptile, Claude)
Backend:
- service_task_environments: fail closed when checkAnySessionRunning
errors so a transient DB hiccup can't bypass the running-session
guard and tear down a live container/sandbox; teardown is now truly
best-effort with errors.Join across container/sandbox/worktree, so a
failed Docker call doesn't permanently orphan the worktree on retry.
Adds tests for the running-check error path and the multi-resource
best-effort path.
- executor_execute: propagate GetTaskEnvironmentByTaskID errors instead
of silently launching a fresh environment that orphans existing
containers/worktrees; gate environment reuse on matching ExecutorType
so a profile switch doesn't pass stale PreviousExecutionID/container
IDs to the wrong backend.
- task_environment_handlers: reject malformed JSON on POST
/tasks/:id/environment/reset (still allow empty body) so a destructive
endpoint doesn't silently proceed with a bad payload.
- worktree (PushEnvironmentBranch): bound git push with a 30s context
timeout and disable interactive prompts via GIT_TERMINAL_PROMPT=0
so a stalled remote can't hang the request thread.
- manager_environment: explicitly call ensureClient() before
ContainerMgr() in DestroyContainer/GetContainerLiveStatus so a missing
Docker daemon surfaces as "docker unavailable" rather than the
generic "container manager not initialized".
- container: WorkingDir always uses the in-container /workspace path
rather than the host workspace path (Docker would happily start in an
unrelated directory in host bind-mount mode); bootstrap now logs the
prepare-script exit code so failures show up in container logs.
- docker/client: CreateContainerInteractive honors ContainerConfig.Entrypoint
so the same config struct produces consistent behavior on both creation
paths.
Frontend:
- executor-settings-button: extract useTaskEnvironment hook (component
shrinks below the 100-line limit); only treat 404 as empty env (other
errors no longer wipe a valid environment); track "loaded once" via a
ref so loadEnv doesn't depend on env state — that dep was creating a
new function identity on every successful poll, cancelling the
3-second interval and turning the popover into a tight loop hammering
/environment/live; copy-shell command uses sh instead of bash so it
works on user-built minimal images.
- task-reset-env-confirm-dialog: AlertDialogAction onClick calls
e.preventDefault() so Radix's auto-close doesn't dismiss the dialog
before the async reset completes (loading spinner now visible; close
is parent-controlled via onOpenChange).
- remote-credentials-card: track env method in selectedIds the same way
files/gh_cli_token are tracked, and let initialChoice match either
selectedIds OR envSecretId. First-time env-credential setup for an
agent that exposes both files+env was broken: clicking the env radio
never updated state, choice re-derived to "none", and the secret
picker disappeared before the user could pick anything.
- e2e/executor-reuse: select the acknowledge checkbox by label, not by
positional last(), so adding another checkbox to the dialog can't
silently target the wrong control.
Format pass on prettier-flagged files; gofmt fixed two whitespace
nits in lifecycle files.
* test(executor): cover applyExistingEnvironment executor-type mismatch guard
* fix(docker): connect agentctl through published ports
* fix(web): keep remote auth selection stable
* fix(docker): reuse task containers across sessions
* fix(docker): resume task environment after refresh
* fix(docker): track executor containers for cleanup
* fix(docker): explain missing agent credentials
* fix(docker): surface container launch progress
* fix(docker): stabilize container reuse and feedback
* fix(docker): show task titles in container lists
* fix(docker): use container icon for docker tasks
* fix(orchestrator): align environment reuse after rebase
* fix(sprites): reconnect sessions on existing sandboxes
* fix(docker): use container icon for local docker executor too
Local docker tasks were rendering the Docker brand "chip" mark while
remote docker uses the container glyph. Map both variants to
IconContainer so the executor surfaces look consistent.
* feat(sprites): provision a fresh sandbox when the persisted one is gone
Resuming a Sprites task whose sandbox was destroyed upstream returned
"sprite not found" and dead-ended the user. Detect the upstream 404 via
a typed ErrSpriteNotFound sentinel, mark the reconnect step skipped with
a user-facing warning explaining what happened, swap the prepare plan
in place to the fresh-provision shape, and continue the launch under a
new sandbox name on the same branch.
The progress plan is now mutable so the reporter can stream the fresh
steps live after the reconnect-then-fresh transition. Only the explicit
not-found case triggers fallback; other reconnect failures still
surface as before, and the fallback fires at most once per launch.
* feat(web): friendlier header for sandbox-recovered prepare runs
When the only warning on a completed prepare is the missing-sandbox
fallback notice, render "Environment prepared on a fresh sandbox" in
the header instead of the generic "Environment prepared with warnings".
The fallback row still carries its own amber warning callout so the
user understands why a fresh sandbox was provisioned.
* feat(web): unify executor surfaces in the right-side popover
The desktop task top bar carried a small cloud icon (RemoteCloudTooltip)
in the breadcrumb cluster while the right-side Executor popover showed
the same kind of info — two surfaces for one concept. Remove the cloud
icon from the desktop top bar; kanban cards, task list rows, and the
mobile top bar keep theirs since they have no popover.
The popover button now picks its icon by executor type (cloud for
sprites, container for docker, server for local PC) using a shared
getExecutorStatusIcon helper. While the prepare progress is preparing
the icon swaps to a spinner so the user can see at a glance that work
is in flight.
The popover content gains a Preparing-environment section that streams
"Step N of M: <step>", an amber inline note when the missing-sandbox
fallback is in progress, an Environment-ready row with duration on
success, and a failed-step summary on failure. Polling promotes to the
active 3s cadence while preparing so the icon flips to ready without
the user opening the popover.
* fix(docker): swap container glyph for a plain box icon
IconContainer's vertical ribbing reads as a microchip on small sizes.
IconBox is an unambiguous outlined cube. Use it for both docker
variants so the executor surfaces look like a box, not a chip.
* fix(orchestrator): preserve base branch on resume for clone-based executors
Sprites and Docker resumes were sending the launch request with an empty
BaseBranch, so the in-sandbox prepare script's `git clone --branch <X>`
shifted positional args and failed with "repository '/workspace' does
not exist". applyResumeRepoConfig only set BaseBranch inside the
worktree-specific block; widen that to all executors that require a
clone URL.
Local executors are intentionally left untouched: LocalPreparer reads
BaseBranch and forces a `git fetch && git checkout` against the user's
own workspace, which would clobber the "use current state" path.
* feat(sprites,docker): generate a kandev feature branch for clone-based executors
Sprites tasks committed straight onto the chosen base branch because
SpritesPreparer didn't populate WorktreeBranch and the orchestrator's
"forward env.WorktreeBranch on reuse" check only fired for Docker.
Mirror the Docker preparer's TaskBranchName generation in Sprites,
extract a shared `nonWorktreeTaskBranch` helper that uses a
deterministic suffix derived from the task ID (so legacy env rows that
predate this change resolve to the same branch on every resume), and
let the orchestrator forward env.WorktreeBranch for any clone-based
executor — Docker or Sprites.
* fix(sprites,docker): prefer existing remote feature branch on resume
When a sandbox is destroyed and we provision a fresh one for the same
task, the agent may have previously committed and pushed onto the
feature branch. The previous prepare scripts only ran `git checkout -b
{{worktree.branch}}` which would overwrite that progress with a fresh
branch off the chosen base.
Try `git fetch origin <feature>` first; on success, `git checkout -B`
against the remote tip so the agent picks up where it left off. Falls
back to a fresh local branch when origin has nothing.
Also add the same checkout block to the Sprites prepare script — it was
missing entirely, so every Sprites session was committing onto main.
* test(sprites): lock in informative branch name in fallback warning
Now that BaseBranch is preserved on resume for clone-based executors,
the missing-sandbox fallback warning detail names the actual branch
("Cloning … on the same branch main") instead of the "(unknown)"
placeholder. Add a regression test so a future change to the resume
metadata propagation can't silently regress that copy.
* fix(sprites): preserve sandbox on plain agent stop so resume is fast
StopInstance was calling sprite.Destroy() unconditionally, so every
session stop tore down the cloud sandbox. Resume then always fell into
the missing-sandbox fallback, re-cloned the repo, re-ran the prepare
script, and lost the agent's working tree. That's a 30s+ round-trip
for what should be "stop the process, then re-attach later".
Limit Destroy to terminal lifecycle reasons (task/session
deleted/archived), the same set runTerminalCleanupScript already uses.
Plain stops just close the proxy and leave the sandbox alive.
Also update the existing Docker e2e regex: the feature-branch suffix is
now a deterministic 6-hex-char prefix of the task UUID rather than a
random 3-char tail, so resume on legacy env rows lands on the same
branch.
* test(docker): cover stop + resume → same container, branch preserved
Adds a Docker e2e test that exercises the user-visible stop-then-resume
flow: launch session, stop it via WS session.stop, launch a fresh
session on the same task, and assert the same task environment +
container ID are reused and the kandev feature branch is preserved.
Also nudge the executor popover padding: when the prepare-status
section is rendered above the Sprites/Docker info block, the cumulative
top+bottom paddings looked too generous around "Sprites sandbox" /
"Local Docker". Tighten both sections vertically without affecting the
horizontal rhythm or the bottom action row.
* feat(web): show "Resuming session" feedback in popover during reconnect
The bottom Resume button on the chat panel was leaving users staring at
nothing for the 5-30s reconnect window: prepare-progress events are
intentionally suppressed on resume (the workspace is already there), so
the popover sat in the idle phase even while session.state was STARTING.
Synthesize a "resuming" phase from session state when no prepare events
are flowing, rendered as a spinner row in the popover with copy
"Resuming session — Reconnecting to the existing environment…". The
button icon also flips to the spinner so the toolbar advertises the
in-flight work without the user having to open the popover.
Extract the prepare-status reducer + section component into their own
file so the main popover module stays under the 600-line ESLint cap.
* fix(codex): align with the standard SessionDirTemplate pattern
Codex was the only agent that bind-mounted its host home into the
container via an explicit `Mounts: [{home}/.codex → /root/.codex]`
entry, while every other ACP agent uses `SessionConfig.SessionDirTemplate`
+ `SessionDirTarget`. Move codex onto the same surface so the
host-state isolation work in the next commit covers it uniformly.
This change alone does not stop the host-state leak — the bind mount
still resolves to the host's ~/.codex for now — but it removes the
last agent-specific branch in the runtime config so the docker
container manager can apply one rule for all agents.
* fix(docker): isolate per-container agent state from the host home
Every ACP agent's session dir was bind-mounted from ~/.<agent> on the
host into /root/.<agent> in the container. That exposed host state DBs,
session caches, and lock files to the container — codex's rollout DB
caches absolute /Users/... paths, which don't resolve inside Linux and
the agent silently loses prior thread context on resume.
Replace the host bind mount with a kandev-managed per-container session
dir under <kandev-home>/agent-sessions/<instance_id>/. Before the
container starts, selectively copy the auth-relevant files (the same
files Sprites already copies via RemoteAuth.SourceFiles) into that
dir; everything else — state DBs, sessions/, lock files — stays on the
host and the agent rebuilds them inside the container with
container-local Linux paths.
The change is agent-agnostic: any agent that has SessionDirTemplate
+ SessionDirTarget gets the new behavior automatically. Cleanup runs
on destructive stops only (task/session deleted/archived), mirroring
the Sprites preserve-on-stop rule so plain stops let resume re-attach
to the same agent state.
* fix(executor): make the kandev feature-branch checkout an invariant postlude
Profiles created in the UI snapshot the *then-current* DefaultPrepareScript
into their stored prepare_script field. Updates to the default — like
shipping the worktree-branch checkout block — never reach those older
profiles, so users keep landing the agent on the chosen base branch
(main) even though the env row carries a feature branch name.
Append a kandev-managed postlude to whatever prepare_script ran (custom
or default), wrapped in a subshell + `|| true` so it's idempotent and
never blocks agentctl from starting. The postlude prefers the remote tip
when origin already has the branch (resume after a destroyed sandbox),
falls back to checking out the existing local branch, and finally
creates one off the current HEAD.
Sprites needed the WorktreeProvider in resolvePrepareScript so the
postlude's `{{worktree.branch}}` actually expands; Docker already had
it.
The Docker e2e gains a regression case that explicitly stores an older
default-style script (clone, no checkout) and asserts the agent still
ends up on the feature branch — locking the contract in regardless of
which default the user happens to have stored.
* fix(web): tighten executor popover padding under the env header
The Sprites/Docker info block under the Environment-ready row had too
much vertical breathing room — the header-to-fields gap and the
inter-row spacing piled up and the popover felt airy. Trim the section
to match the snug rhythm of the rest of the popover (px-3 py-2,
1.5-unit gap before the field list, 1-unit gap between rows). Also
unify the Environment-ready row from p-3 to px-3 py-2 so the dividers
between sections sit at the same vertical cadence.
* fix(web): tighten executor popover bottom padding too
The previous tighten only fixed the top half — pb under the field rows
plus the Reset-row's full p-2 still left a generous gap below the last
"Sprite ..." line. Same fix applies whether the popover is showing
Docker or Sprites.
EnvironmentInfo: pt-2 pb-1.5 (was py-2) so the bottom field hugs the
divider, and the Reset-row goes from p-2 to px-2 py-1.5 so the button
sits closer to the text above.
* fix(web): drop the redundant Environment-ready row + tighten popover top
The READY badge next to the executor name already conveys ready-state;
the dedicated "Environment ready · 12s" row above the executor info
just added vertical noise. Skip rendering for the ready phase (other
phases that surface non-redundant info — preparing, resuming, failed —
still render). Drop top padding on the executor info section now that
nothing sits above it on the happy path.
* fix(web): move executor info to the left top bar, hover-to-open, no border
The executor section is informational, not a button — readiness state +
sandbox/container/branch fields. Move it to the left top bar (next to
the integrations menu) where it sits alongside the other contextual
info, drop the outline-button styling, and switch from click-to-open to
hover-to-open via HoverCard with 150ms open / 250ms close so the cursor
can travel to the destructive Reset button without flickering.
The trigger is a borderless icon-with-status-dot span; tabIndex keeps
it keyboard-accessible. Reset still works — it opens the existing
confirmation dialog independently of the hover card.
Tests: pointerEnter instead of click for the hover-to-open path.
* refactor(cleanup): drop dead code, dedupe predicates, unify token injection
Tier 1 of the post-review cleanup. Each item is independently safe; bundling
because they're all small, mechanical, and lint-passing.
- fix(clone): unify injectTokenIntoURL across Docker and Sprites — Sprites
used to ignore GH_TOKEN entirely (a user with only GH_TOKEN set got 401
on Sprites clone but worked on Docker). Promote one
`injectGitHubTokenIntoCloneURL` helper, accept both env vars
(GITHUB_TOKEN wins, matching Docker), and use the documented
`https://x-access-token:T@github.com/` form on both surfaces.
- fix(executor-popover): swap the <span tabIndex role="button"> trigger
for <Button variant="ghost"> so Enter/Space activation, focus rings on
Safari, and disabled semantics come for free.
- test(docker): rewrite TestDockerStopInstance_PreservesSessionDirOnPlainStop
to drive the real DockerExecutor.StopInstance path with ContainerID=""
(skips the docker daemon, exercises the kandev-side cleanup branch).
Previous version only iterated shouldRunExecutorCleanup strings — would
pass even if StopInstance always cleaned up. Now table-driven over
preserve+destructive reasons with on-disk asserts.
- refactor(orchestrator): drop dead `_ = arg` parameters from persist*
helpers (persistResumeState, persistModelSwitchState, persistLaunchState).
Brings signatures from 9→6 args, under the funlen lint cap.
- refactor(orchestrator): collapse byte-identical isContainerizedExecutor
≡ isCloneBasedExecutorType predicate into one.
- refactor(lifecycle): drop dead getMetadataBool, FileUploader.RunCommand,
expandMountSource {home} arm (no production agent ships {home} in
Mounts; codex_acp_test asserts it), dockerTaskBranch one-line wrapper.
* refactor(scripts): postlude owns worktree-branch checkout
Strip the inline `git checkout -B` block from the Docker and Sprites
default prepare scripts. The kandev-managed postlude
(KandevBranchCheckoutPostlude) is appended unconditionally after every
user prepare script, so keeping the same logic inline made the default
and the postlude two sources of truth that could drift.
* refactor(lifecycle): drop dead instance_port persistence entry
`instance_port` is only used as a zap log field — it's never written to
the executor metadata map, so carrying it across resume in
`persistentMetadataKeys` was a no-op.
* refactor(lifecycle): drop legacy {home} session-dir fallback
ExpandSessionDir kept a `{home}/<dotdir>` fallback for the case where
kandevHomeDir was unresolved. In production, ResolvedHomeDir() always
returns a non-empty path, so the fallback only ever fired in tests
that hadn't been updated to pass an instance ID. Returning the empty
string in that case makes container.go skip the bind mount — the
correct behaviour, since without a kandev home there's no session dir
to mount.
* refactor(orchestrator): collapse env-reuse helpers into reuseExistingEnvironment
The two env-reuse helpers (`applyExistingEnvironment` and
`applyExistingEnvironmentRuntimeMetadata`) were always called together
and shared the same intent: carry the previous TaskEnvironment forward
into the launch request. Merge them into a single
`reuseExistingEnvironment(ctx, req, env)` so the executor-type gate
runs once for both env-level fields and runtime metadata, and callers
have a single entry point.
Document the layered metadata rule (running wins for container_id;
env wins for sprite_name and other stable IDs) inline so the next
reader doesn't have to reverse-engineer it from two functions.
* refactor(web): lift prepare-summary reducer + isFallbackNoticeStep into lib/prepare
Move the popover's prepare-summary reducer (and the
`isFallbackNoticeStep` helper duplicated word-for-word in
`prepare-progress.tsx`) into `apps/web/lib/prepare/summarize.ts`. The
hook moves to `hooks/domains/session/use-prepare-summary.ts` to match
the project's domain-organized hook layout.
The popover's PrepareSummary type and the chat panel's EffectiveStatus
serve different UIs, so the panel keeps its own `deriveStatus` — only
the literal duplicate (`isFallbackNoticeStep`) is shared.
* refactor(web): split executor-settings-button by concern
Slice the 471-line button file into three modules with single
concerns: the popover shell stays in `executor-settings-button.tsx`
(168 lines, just trigger + popover layout + icon/status dot),
`executor-environment-info.tsx` owns the env detail rows + status
badge, and `hooks/domains/session/use-task-environment.ts` owns the
fetch/poll/reset lifecycle alongside other session hooks.
* refactor(e2e): extract docker + session helpers from docker-launch.spec
Pull the inline docker CLI helpers (`dockerInspectExists`, `dockerStop`,
`dockerCurrentBranch`, etc.) and the generic session-state pollers
(`waitForLatestSessionDone`, `waitForSessionDone`,
`waitForSessionEnvironment`) into `e2e/helpers/docker.ts` and
`e2e/helpers/session.ts`. The spec drops from 763 to 647 lines and the
helpers can be reused by future docker / session tests.
* test(scripts): execute postlude under bash -e against a real git repo
Pair the static-content assertion with a behaviour test that runs the
postlude as a real shell script. Three scenarios exercise the three
branches inside the snippet: remote tip exists (track origin), no
remote (fresh local branch), and already on the target branch
(idempotency). This catches regressions where the snippet still parses
as bash but no longer does the right thing — wrong refspec, missing
-B, swallowed exit code, etc.
* test(sprites): integration check for stop preserve + resume reconnect path
Each piece of the resume-fast path is unit-tested separately
(StopInstance keeps the token cache, spritesShouldReconnect reads
sprite_name, the reconnect plan has 3 steps). This new test wires them
together end to end so a future change that accidentally strips
sprite_name on stop or forgets to look at metadata on launch fails
loudly here, not at runtime.
* test(sprites): assert plan ordering instead of absolute indexes
Replace `plan.index(spriteStepWaitHealthy) == 1` with an
`assertPlanOrder` helper that checks "X runs before Y". The contract
the test guards is the order of create-sprite → wait-healthy →
agent-instance, not their absolute slot numbers — adding a new step
shouldn't break the test if the order is preserved.
* test(web): hoist test constants above vi.mock factories
Move SESSION_ID and friends above the vi.mock factories so the mock
closures don't depend on vitest hoisting magic capturing TDZ-undefined
locals. The previous layout worked because factories aren't invoked
until the module under test is imported (after const initialization),
but a refactor that imports earlier would break the closure silently.
* fix(seeder): contain localFileUploader writes inside the session root
CodeQL flagged go/path-injection on the localFileUploader.WriteFile
sink: instanceID flows into the write path, and a malformed value
would let writes escape the kandev session root and clobber arbitrary
host files. Bind the uploader to its session root and reject any path
that filepath.Rel resolves to a "../" escape. Production callers
always pass an internally generated instanceID; the check is defense
in depth against a future caller plumbing in user input.
* refactor(docker): split reconnectToContainer + cover reconnect helpers with unit tests
Address recurring review feedback on the docker reconnect path:
- reconnectToContainer was 98 lines (over the 80-line repo limit). Split
into ensureContainerRunning (inspect + start + re-inspect) and
bringupAgentctl (health-check, find-or-create instance, transparent
re-handshake). The orchestration body now reads top-down at 49 lines.
- resolveDockerEndpoint and waitForAgentctlHealth grew real branches in
this PR (host-port lookup with container-IP fallback; 240×500ms
retry loop) but had no direct tests. Introduce narrow hostPortLookup
/ healthChecker interfaces, satisfied by *docker.Client and
*agentctl.ControlClient, so both functions are now driven by stubs
that exercise happy-path, fallback, retry-until-healthy, exhaustion,
and context-cancellation branches.
- Add direct unit tests for the docker package's pure helpers
(normalizeDockerHostIP, buildDockerPortBindings) and for
dockerAgentctlPortBindings — a regression in the published port
range or host IP would silently break container reconnect.
* test(handlers): cover ensureShellExecution stale-execution recovery
The recovery branch (Failed execution → CleanupStaleExecutionBySessionID
→ relaunch) is the load-bearing piece of shell session resilience and
had no direct test. Extract the body into a free function that takes a
narrow shellLifecycle interface so the four branches are testable
without standing up a real lifecycle.Manager:
- happy path (running execution, no cleanup)
- stale recovery (Failed → cleanup → fresh)
- initial GetOrEnsureExecution failure
- cleanup failure must NOT trigger a re-launch loop
- relaunch failure surfaces a clear "recover execution" error
* fix(backend): clear lint blockers from CI lint job
CI's only-new-issues lint surfaced three real items left over from
recent additions:
- container.go bootstrap snippet had two `fi` shell tokens flagged by
dupword. Tagged with the same nolint directive used by the
KandevBranchCheckoutPostlude (two distinct shell blocks closing).
- DockerExecutor.CreateInstance was 89 lines (over funlen 80). Split
the body into helpers: reportCreateInstanceProgress (deferred
progress reporter), tryReconnect (gates the resume path),
seedSessionDir (per-container auth seed), buildContainerLaunchConfig,
and buildCreatedInstance. CreateInstance is now a 30-line top-down
orchestrator.
- executor_execute.go was 887 effective lines (over revive 800). Move
reuseExistingEnvironment + its helpers (latestExecutorRunningForEnv,
executorRunningMatchesEnvironment, applyExecutorRunningMetadata,
ensureLaunchMetadata) into a sibling executor_environment_reuse.go.
* fix(web): only block submit during real env prepare, not every STARTING
The "agent still being set up" submit gate was triggering on every
session that briefly transitions through STARTING — including local
quick-chat, which has no prepare phase and has its agent live within
~100ms. The eager-init quick-chat e2e tests (chat/quick-chat.spec.ts
and chat/message-queue.spec.ts) wait for the editor to become
editable, then press Cmd+Enter. The race made wrappedSubmit a no-op
exactly when the test was firing — message dropped, agent never saw
it, downstream assertions failed.
Split the merged isStarting flag in useSessionState: keep the broad
flag (still used for the workspace-prep loading UI), and expose
isPreparingEnvironment as a separate signal that's only true while
prepareProgress.status === "preparing". Thread it through chat-input-
area → chat-input-container → useChatInputContainer and gate the
submit-disabled tooltip on it instead of the broad isStarting.
Net: Docker / Sprites bootstrap still surfaces the "agent still being
set up" message and disables submit; quick-chat / local sessions stop
silently dropping in-flight messages.
* fix(web): keep editor disabled during STARTING so quick-chat sends don't race
My previous fix made submit no longer block on isStarting, with the
goal of keeping local quick-chat sessions from silently dropping
messages. The trace from CI showed it traded one failure for another:
the editor became contenteditable while the session was still
STARTING, the e2e suite pressed Cmd+Enter immediately, and the
backend rejected with "Failed to send message to agent" because the
agent process wasn't ready yet.
Restore the original semantics — STARTING blocks both editor and
submit — but keep `isPreparingEnvironment` as the narrower trigger for
the "agent still being set up" tooltip. Net behaviour:
- STARTING transition: editor disabled, no tooltip (signal is the
disabled state itself)
- container/sandbox prepare: editor disabled + tooltip explaining why
- quick-chat sessions: editor stays disabled until RUNNING, no race
* docs(web): correct isPreparingEnvironment JSDoc after STARTING re-block
The
|
||
|
|
45a46f9899 |
feat: support tasks without a repository (#850)
* feat: support tasks without a repository Lets users create a task with no repository attached — useful for research, brainstorming, design discussions, or pointing the agent at an existing non-git folder. The create-task dialog gains a 3-mode segmented control (Repo / URL / None); "None" mode replaces the chip row with a folder picker that browses the host filesystem via a new GET /api/v1/fs/list-dir endpoint and either commits an absolute path or falls back to a kandev- managed scratch workspace at <homeDir>/tasks/<workspaceID>/<taskID>/. The worktree executor is hidden from the picker in no-repo mode (it inherently needs a repo); the lifecycle manager also short-circuits worktree mode server-side as a belt-and-suspenders guard. The Changes panel auto-closes for repo-less tasks since it has nothing to show, and the cumulative-diff handler returns null instead of erroring when no base commit exists. Spec + plan added at docs/specs/tasks-without-repositories/. * fix(backend): anchor folder picker to discovery roots (CodeQL path traversal) CodeQL flagged the new GET /api/v1/fs/list-dir handler for piping a user-controlled path straight into os.Stat / os.ReadDir. Restrict the listing to paths inside the configured discovery roots (defaults to $HOME, same allowlist as repository discovery), reject anything outside with ErrPathNotAllowed → HTTP 403, and clip the breadcrumb's "up" button at the root so the picker can't be used to walk above the allowed prefix. Tests cover: rejection of paths outside roots, traversal escapes (../), default-to-first-root, and parent exposure for nested paths. * fix(backend): use os.Root for folder picker (recognized CodeQL sanitizer) Previous attempt anchored to discovery roots via isPathAllowed, but CodeQL's data flow doesn't model that helper as a sanitizer and kept flagging os.Stat / os.ReadDir. Switch to os.Root (Go 1.24+): open the matching discovery root once and run Stat/Open/ReadDir against the relative path within it. The OS itself enforces containment — symlinks and traversal cannot escape — and CodeQL recognizes os.Root as safe. * fix: address PR review feedback - Changes panel auto-close now distinguishes "task loading" from "task has no repos" (returns boolean | null), so the panel isn't permanently removed during the first-render window before task data lands in the store. - Reset noRepository / workspacePath in resetDiscoveryState so reopening the dialog after a None-mode task doesn't carry the flag forward. - Folder-picker breadcrumb: dropped dead `i > 0 && i > 1` condition (always equivalent to `i > 1`); chevrons now render between every segment as expected. - list-dir HTTP handler returns a generic error message instead of forwarding raw OS errors that leak host paths. - Document the dataDir → kandevHome inversion convention next to the scratch-path computation so future readers don't mistake the filepath.Dir call for a fragile derivation. * fix: address CodeRabbit review nitpicks - Trim whitespace on workspace_path before persisting to task metadata so a value of " " doesn't get stored as a non-empty path. - Extract the workspace_path metadata key into models.MetaKeyWorkspacePath alongside the existing MetaKeyAgentProfileID / MetaKeyExecutorProfileID so the set/read sites can't drift apart. - Folder picker: reset cached listing when the popover closes, so reopening reloads from `value` instead of showing the last browsed directory the user navigated away from without committing. - Submit payload: send `workspace_path: undefined` instead of an empty string when the trimmed value is empty, matching the noRepository=false case and keeping the API contract clear. * fix: scratch path lands in kandev home, not its parent Claude caught a real bug — `m.dataDir` is set from `cfg.ResolvedHomeDir()` in cmd/kandev/agents.go, not `ResolvedDataDir()` (the field name is misleading and the comment was wrong). My previous use of `filepath.Dir(m.dataDir)` therefore walked one level above the kandev home, so scratch dirs landed at e.g. `~/tasks/<ws>/<task>` instead of `~/.kandev/tasks/<ws>/<task>` — and at the project root in dev mode where `KANDEV_HOME_DIR=<project>/.kandev-dev`, which is what the user saw on their machine. Use `m.dataDir` directly (it's already the kandev home), document the field's misnamed-but-correct semantics, and tighten the test fixtures. Also: - folder-picker trigger gets `cursor-pointer` (CodeRabbit nitpick). * fix: pass workspacePath through handleCreateWithoutAgent too The "create without agent" path called buildCreateTaskPayload without the new workspacePath field, so picking a folder + clicking "Create without starting" silently dropped the path. Now matches the performCreate (with-agent) path. * fix: satisfy DialogFormState in new-subtask form stub PR #852 (merged after this branch was created) introduced new-subtask-form-state.ts which stubs out a DialogFormState for the subtask dialog. Adding noRepository / setNoRepository / workspacePath / setWorkspacePath to the type made that stub incomplete, breaking the Next.js production build (caught by CI, missed by tsc --noEmit). Add the four inert fields to the stub, alongside the existing fresh- branch inert fields, and pull both groups into named constants to keep the useMemo body under the per-function line cap. * fix: keep toggle-github-url testid as alias on URL-mode button The new source-mode segmented control replaced the previous "or paste a GitHub URL" text link, which had data-testid="toggle-github-url". The existing e2e suite (create-task-github-url.spec.ts and the new subtask.spec.ts from #852) still selects the URL toggle by that testid. Keep "toggle-github-url" as the testid on the URL-mode button so both suites pass without a sweep across all e2e files. Click semantics are unchanged — single click enters URL mode. * test(e2e): update toggle assertion for new source-mode segmented control create-task-github-url.spec.ts:892 was checking the old text-link toggle's two-way behavior with text labels ("or paste a GitHub URL" / "use a workspace repository"). The new segmented control has explicit mode buttons; assert aria-checked instead of label text. The other 9 clicks on `toggle-github-url` in this file still work via the legacy testid alias on the URL-mode button. * refactor: extract useActiveTaskHasRepos to its own hook file dockview-desktop-layout.tsx grew to 609 lines with the new hook + the Changes panel guard, over the 600-line cap documented in CLAUDE.md (lint passed but the convention is real). Move the hook to hooks/domains/kanban/use-active-task-has-repos.ts. File drops to 590 lines. * chore: gitignore .claude/scheduled_tasks.lock runtime file * fix(web): lock chip-row min-height so modal doesn't shift on mode toggle The chip row uses flex-wrap; Repo mode (chip + branch + add button + segmented control) sometimes occupies a different vertical extent than None mode (single folder picker + segmented control), causing the create-task modal to jump a few pixels when toggling between sources. Set min-h-9 so the row always reserves at least one chip-row of height regardless of the active mode. * feat: let folder picker browse anywhere, drop allowed-roots gate The picker is for choosing a starting folder for a repo-less task — users legitimately want /tmp, /var/log/foo, or any directory they have read access to. Coupling it to the repo-discover endpoint's discoveryRoots ($HOME by default) was too tight: paths like /tmp returned 403 "path is not within allowed roots". Decouple ListDirectory from discoveryRoots. The repo-discover endpoint (DiscoverLocalRepositories, ValidateLocalRepositoryPath, LocalRepositoryStatus, ListBranches) stays anchored to the allowlist since picking a repo to import is a workspace-scoped operation; the folder picker trusts the local-process boundary instead. CodeQL is kept happy by routing the FS operations through os.Root opened at "/" — the Go stdlib sanitizer for path injection still recognises it (containment to / is trivially satisfied for every absolute path on the host). --------- Co-authored-by: Kandev Agent <agent@kandev.dev> |
||
|
|
00fed4661e | fix(orchestrator): resolve subtask base_branch correctly across repos (#870) | ||
|
|
bce9fd266a |
feat: multi-question support for ask_user_question_kandev (#828)
* feat(backend): multi-question support for ask_user_question_kandev
Reshape the MCP tool from a single prompt+options pair to a questions[]
array (1-4 entries, single-choice each, custom text fallback). The
clarification HTTP handler gates the response until every question has an
answer, fans out one chat message per question (sharing pending_id, with
question_index/question_total in metadata), and returns a JSON map keyed
by question id back to the agent. The orchestrator resume prompt now
formats multi-line Q1/A1, Q2/A2 summaries when the bundle has more than
one question. Mock-agent gets a clarification-multi scenario and
ask_user_question_test.go covers the new schema + map response. No
back-compat shim — local-first tool, single-PR breaking change.
* feat(web): refresh clarification overlay with multi-question + modern UI
Each per-question chat message gets its own card with a "Question N of M"
progress chip, a ✓ Answered badge once committed, and shadcn-aligned
option buttons with kbd hints. A new useClarificationGroup hook coordinates
the per-question commits (decision A) and posts every answer in a single
batch once the user finishes the bundle; X rejects the whole group. The
single-question case keeps the 1-9 / Esc keyboard shortcuts. Playwright
e2e adds 6 multi-question scenarios (rendering, full happy path, partial
state, mixed option/custom, skip-rejects-bundle, sibling-state) on top of
the existing single-question regression suite.
* fix(web): clarification carousel UX with stepper navigation + race fix
Replaces the vertically stacked card layout with a horizontal stepper:
numbered circle dots up top show step state (active/answered/pending),
the single active question card sits in the middle, and Back / Next /
Submit buttons sit at the bottom. Picking an option auto-advances to the
next step (single-question case auto-submits, no explicit Submit button).
The stepper steps are clickable so users can revise prior answers.
Keyboard shortcuts: 1-9 for option pick, ArrowLeft/Right for navigation,
Esc for skip.
Also fixes a race that surfaced as a 400 BadRequest on the
/api/v1/clarification/:id/respond endpoint when the user clicked an
option before all N messages of a multi-question bundle had streamed in:
findPendingClarificationGroup now gates on metadata.question_total and
returns an empty array until the full bundle has arrived.
Playwright suite expanded from 6 to 12 multi-question scenarios:
stepper rendering, auto-advance, Back, stepper jump, Submit gating,
happy path, mixed custom-text, revise-via-jump, skip-from-mid-step,
number-key shortcuts, Esc skip, first-step Back disabled.
* fix(clarification): address CodeRabbit/Greptile/Claude review comments
Backend:
- Single source of truth for question validation: rename/export
NormalizeAndValidateQuestions and call it from both the HTTP and the
WebSocket-side MCP handlers, so duplicate question ids and bad option
counts can't slip through either path.
- httpRespond now validates that every answer references a real question
id from the bundle and that no id is answered twice (regression tests
added).
- questionsFromMessages reorders by metadata.question_index so the
reconstructed bundle preserves the original question order.
- adapters.CreateClarificationRequestMessages rolls back already-created
messages when a later write in the bundle fails.
- extractQuestionAnswers always returns a JSON map (even on rejection,
with an envelope { rejected, reject_reason }) and simplifyAnswer
preserves both selected_option and custom_text when both are present.
- MCP tool description: rewrite the response example to match the actual
output shape; add minItems / maxItems to the questions and options
arrays in the JSON Schema.
- Test helper: drop the local contains() shim, use strings.Contains.
Frontend:
- useMemo([props]) was a no-op (props is always a fresh ref); split into
[message, messages] so it actually memoizes.
- Drop the dead "expired" SubmitState branch — the backend never returns
410 from this endpoint.
- Remove the asymmetric option-list dimming: the agent now receives both
selected_option and custom_text together, so a half-grey state was
misleading.
- New tests: pending-clarification.test.ts covers
findPendingClarificationGroup including the question_total gate, and
use-clarification-group.test.ts covers the hook's record/submit/skip
flows and the override-merge path used by the single-question case.
* fix(web): treat clarification 409 as success so duplicate submit doesn't stick the overlay
* fix(clarification): unblock agent on persistence failure + nil-guard rejected branch
- Add Store.CancelRequest(pendingID) and call it from both the WebSocket
MCP handler and the HTTP create-request handler when message creation
fails. Without it, the agent's WaitForResponse parked for the full
2-hour MCP timeout while no clarification cards ever reached the user.
Both handlers now return an explicit error response on failure.
- applyAnswersToMessages now guards h.repo == nil in the rejected branch
(parity with sibling expectedQuestionIDs).
- extractQuestionAnswers stubs for unanswered questions in a rejected
bundle now carry { answered: false, rejected: true } so agents reading
per-question payloads see the rejection without inspecting the
envelope.
- Hook test: added 409-as-ok regression coverage for both submit and
skip paths so the previous fix can't drift.
- Store test: covers CancelRequest unblocking a parked WaitForResponse.
* fix: drop unused e2e helper + use channel sync in CancelRequest test
* refactor(web): drop dead 'message' prop from clarification overlay
Both call sites pass 'messages' (the bundle group), so the legacy
single-message prop was unused. Tightening the prop type also lets the
useMemo depend on a single value.
* fix(web): ignore modifier-keyed events in clarification carousel shortcuts
Bare 1..9 / ArrowLeft / ArrowRight / Esc still drive the carousel, but
Cmd/Ctrl+1..9 (browser tab switch) and Alt+ArrowLeft (browser back) now
pass through as the user expects.
|
||
|
|
39b9369ae1 | fix: suppress spurious task-failed toasts on resume and reconnect (#814) | ||
|
|
96c68a0af9 |
fix(backend): return mcp message_task once dispatched, not after target turn ends (#817)
* fix(backend): return mcp message_task once dispatched, not after target turn ends The MCP message_task_kandev tool was synchronously waiting for the target session's entire turn to complete before returning, even though its description advertises a fast dispatch status. For idle target sessions, the call would hang for the full duration of the agent's response (with no upper bound — waitForPromptDone has no timeout). Threads a dispatchOnly flag from the MCP handler down through PromptTask → executor.Prompt → PromptAgent → SendPrompt. When set, SendPrompt returns once agentctl.Prompt accepts the prompt instead of blocking on the agent's complete event. Stale completion signals from prior dispatch-only prompts are drained at the start of SendPrompt so they don't poison the next waiting call. * fix: address PR review feedback - Replace time.Sleep with channel-based wait for WS connection in new SendPrompt tests, per CLAUDE.md (channel sync over time.Sleep for non-subprocess tests). - Drop redundant Warn that logged the remapped sentinel after the original error was already logged by the preceding Warn. --------- Co-authored-by: Kandev Agent <agent@kandev.dev> |
||
|
|
cc934adb85 | fix: make executors_running the single source of truth for agent_execution_id (#799) | ||
|
|
a510b46e4e |
feat(multi-repo): support tasks spanning multiple repositories (#767)
* feat(task): add task_environment_repos schema and model for multi-repo support
Phase 0 of multi-repository task support: introduces the per-repository
child table and model alongside legacy single-repo fields (dual-write,
no behavior change). Lays the foundation for the orchestrator/lifecycle
refactor in subsequent phases.
- New task_environment_repos table with FK CASCADE on task_environments.
- New task_dir_name column on task_environments (idempotent ALTER).
- TaskEnvironmentRepo model + Repos slice + RepoFor helper on TaskEnvironment.
- CRUD methods + interface extension; env getters preload Repos.
- Idempotent backfill from legacy single-repo task_environments rows.
- Tests cover round-trip, CRUD, preload ordering, embedded create, cascade
delete, and backfill from a legacy-shaped row.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(lifecycle): add Repositories list to LaunchRequest and EnvPrepareRequest
Phase 1.1 of multi-repository task support: extends the lifecycle types
with explicit per-repo lists, while keeping the legacy single-repo
top-level fields as the synthesized fallback. No caller behavior changes
yet — preparers still see length-1 specs for single-repo launches.
- LaunchRequest.Repositories []RepoLaunchSpec + RepoSpecs() helper.
- EnvPrepareRequest.Repositories []RepoPrepareSpec + RepoSpecs() helper.
- EnvPrepareResult.Worktrees []RepoWorktreeResult for per-repo outcomes.
- runEnvironmentPreparer forwards req.Repositories into the prepare
request, inheriting the request-level RepoSetupScript when each
per-repo entry has none.
- Tests cover explicit-list precedence, synthesized fallback, and
repo-less requests.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(worktree,lifecycle): support multiple worktrees per session
Phases 1.2 and 1.3 of multi-repository task support. The pieces are
intertwined so they ship together — the preparer's per-repo loop
depends on the manager being able to hold one worktree per
(session, repository) pair instead of one per session.
Worktree manager:
- In-memory cache key changes from sessionID to (sessionID|repositoryID).
- New MultiRepoStore optional capability + SQLite implementation:
GetWorktreesBySessionID and GetWorktreeBySessionAndRepository.
- New Manager methods: GetAllBySessionID, GetBySessionAndRepo. Existing
GetBySessionID returns the first match (single-repo callers unchanged).
- Manager.Create's "already exists" check is now repo-scoped so the
second repo's create no longer returns the first repo's worktree.
- All cache write/delete sites updated to the composite key.
WorktreePreparer:
- New prepareMultiRepo path activated when EnvPrepareRequest.Repositories
has >=2 entries; single-repo and repo-less behavior is byte-identical.
- prepareOneRepo runs validate -> sync -> create -> fetch-PR -> per-repo
setup-script for each spec; rolls back already-created worktrees on
any failure.
- Workspace path becomes the task root (parent of any repo subdir);
legacy WorktreeID/WorktreeBranch/MainRepoGitDir mirror Worktrees[0]
for downstream code that hasn't been migrated yet.
- Per-repo setup script runs verbatim (no script-engine resolution
yet — the per-repo placeholder namespace lands in a follow-up).
Tests:
- Manager: GetAllBySessionID, GetBySessionAndRepo (hit + miss),
GetBySessionID single-repo compat, cache key separation.
- Preparer: multi-repo creates one worktree per repo; rollback removes
active worktrees on partial failure; per-repo setup script runs in
each worktree directory and emits its own step.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(orchestrator): resolve all task repos and persist per-repo rows on launch
Phase 1.4 of multi-repository task support: the orchestrator now reads
every repository linked to a task, forwards the full list through the
adapter into the lifecycle layer, and persists one TaskEnvironmentRepo
plus one TaskSessionWorktree row per repo when the launch is multi-repo.
Single-repo and repo-less tasks keep their previous code path verbatim
(dual-write — legacy single-repo fields stay populated from the primary).
- New resolveAllRepoInfo / resolveTaskRepoInfo on the executor; primary
is the first by Position.
- buildLaunchAgentRequest takes both the primary repoInfo and the full
list, populating req.Repositories []RepoSpec only when length > 1.
- New RepoSpec / RepoWorktreeResult on the orchestrator's executor
package mirror the lifecycle types so the adapter can translate
between them without leaking lifecycle types into the orchestrator
public API.
- Adapter (cmd/kandev/adapters.go) forwards req.Repositories into
lifecycle.LaunchRequest and surfaces per-repo prepare results back
on LaunchAgentResponse.Worktrees.
- persistTaskEnvironment writes env.Repos in the same create
transaction; persistTaskEnvironmentRepos handles the existing-env
reuse path. TaskEnvironment.TaskDirName is now persisted.
- persistWorktreeAssociation loops over resp.Worktrees in multi-repo,
inserting one row per repo with its own RepositoryID.
- ResumeSession now also populates req.Repositories from task repos
and stamps the existing TaskDirName so the preparer reuses the same
task root.
- repoStore / executorStore interfaces extended with the new repo and
env-repo CRUD methods; mocks updated accordingly.
Tests:
- LaunchPreparedSession with two task repositories populates
req.Repositories in order and keeps legacy single-repo top-level
fields filled from the primary.
- The same launch persists one TaskEnvironment + 2 TaskEnvironmentRepo
+ 2 TaskSessionWorktree rows.
- Single-repo launch still produces zero entries in req.Repositories
and exercises only the legacy fields.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(agentctl): git endpoints accept optional repo subpath for multi-repo
Phase 2 of multi-repository task support: every per-call git endpoint
gains an optional `repo` field (form param for GET, JSON for POST) that
selects a sub-directory of the workspace. Empty `repo` = workspace root,
preserving single-repo behavior verbatim.
- Manager.GitOperatorFor(subpath) returns a per-subpath GitOperator
(cached). Validates the subpath against path traversal, absolute
paths, and missing directories.
- New api server helper gitOpForRepo() emits a 400 with a per-operation
GitOperationResult on invalid subpaths.
- Repo field added to: pull, push, rebase, merge, abort, commit,
rename-branch, stage, unstage, discard, create-pr, revert-commit,
show-commit, reset, log, cumulative-diff. (git/status still reads
from the workspace tracker — that endpoint will gain multi-repo
support when the tracker itself is split per repo.)
- Tests cover empty subpath, valid subpath caching, traversal
rejection, and missing-directory rejection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web/comments): scope diff comments by repository for multi-repo tasks
Phase 3 of multi-repository task support: comments now carry an
optional repositoryId so two repos with the same file path render
their own comment threads instead of bleeding together.
- CommentBase.repositoryId (optional, backwards compatible).
- getCommentsForFile gains an optional repositoryId arg; legacy
comments without a repositoryId match any repo filter so persisted
data keeps showing.
- Diff store interface in lib/diff/types.ts mirrors the new arg.
- Tests cover per-repo filter, omitted-arg fallback, and legacy data.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(github): per-repo PR association on task PRs and watches
Phase 4 of multi-repository task support: github_task_prs and
github_pr_watches gain a repository_id column so multi-repo tasks can
have one PR per repo without colliding on (task_id) uniqueness.
- Idempotent ALTER TABLE adds repository_id to both tables.
- TaskPR.RepositoryID and PRWatch.RepositoryID model fields.
- New GetTaskPRByRepository and ListTaskPRsByTask lookups; existing
GetTaskPR keeps single-PR semantics for callers not yet migrated.
- ReplaceTaskPR's delete is scoped by repository_id when set so a
multi-repo task replacing one repo's PR no longer wipes the other.
- Tests cover per-repo storage and scoped replace.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web): foundations for multi-repo task UI
Phase 5 of multi-repository task support: primitives the upcoming UI
work needs without changing any visible behavior yet.
- TaskRepository.checkout_branch surfaces the optional PR head branch
field that the backend already exposes.
- primaryTaskRepository(repos) picks by lowest Position so consumers
match the backend's "primary" semantics instead of `[0]`.
- New useTaskRepositories(taskId) / useTaskPrimaryRepository(taskId)
hooks read the live kanban task and return its repos sorted by
position. Future kanban/diff/UI consumers should use these instead
of poking task.repositories directly.
- Tests cover lowest-position selection and empty/single-repo edge cases.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web): kanban card shows multi-repo "+N" chip and hydrates full repo list
Phase 6 (subset) of multi-repository task support. The kanban now
exposes the per-task repository list to the UI and renders a compact
"+N" badge on cards whose tasks span more than one repository.
- KanbanState.tasks[].repositories slim shape (id, repository_id,
base_branch, checkout_branch, position).
- SSR mapper hydrates the full list and uses primaryTaskRepository()
to pick the position-aware primary instead of [0].
- useKanbanData passes repositories through to the card props.
- KanbanCard renders the primary repo name with a small "+N" chip
when len(repositories) > 1; tooltip names the count.
- useTaskRepositories / useTaskPrimaryRepository hooks return the
slim KanbanTaskRepository shape so multi-repo consumers share one
read path.
Larger UI surfaces (task-create dialog, diff view, file browser,
mobile sheets) still target the primary repo only — they will be
ported as the multi-repo task creation flow lands. No visible change
for single-repo tasks.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(task): reject duplicate repositories on multi-repo task create
Phase 8 of multi-repository task support: surface duplicate-repository
input as a clean validation error instead of letting the unique
(task_id, repository_id) constraint surface mid-loop with some rows
already inserted.
- createTaskRepositories tracks seen repository IDs and returns
"repository <id> listed more than once for the task" before any
CreateTaskRepository call.
- Tests cover the rejection path and confirm two distinct repos still
succeed with both rows persisted in Position order.
Repository discovery and per-repo cloning were already covered by
Phase 1.4 (resolveAllRepoInfo loops ensureRepoCloned) and the
existing repository_discovery service is repo-list aware by design.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web/task-create): repeatable repository rows for multi-repo task creation
Users can now actually create a multi-repo task: a "+ Add repository"
button below the primary repo + branch selectors expands the form with
additional (repository, base-branch) rows. Each extra row picks from a
filtered repository list that excludes the primary and any other already-
chosen extras, so duplicates are impossible to enter.
- ExtraRepositoryRow type + form-state hook (extraRepositories, add /
remove / update / reset). Lives in its own file so the main state hook
stays under the file-length lint cap.
- ExtraRepositoryRows component renders the rows and the "+ Add" button;
per-row branches are loaded on demand via the existing
useRepositoryBranches hook.
- buildRepositoriesPayload accepts the extras and appends them after the
primary, dropping rows with empty repository_id silently so an
unfinished row does not block submit. The backend's duplicate-repo
validation (Phase 8) catches anything that slips through.
- DialogFormBody is split: CreateModeSelectors (selectors + extra rows)
and useDialogHandlers move to dedicated files, keeping touched files
under the lint thresholds.
- Tests cover the multi-repo payload (ordering, drop-empty, no-primary
recovery, single-repo unchanged) and the existing
store-multi-repo.test.ts gets named constants to satisfy the
no-duplicate-string rule.
Verified: pnpm exec tsc --noEmit clean, vitest 608/608 green,
eslint --max-warnings 0 clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(agentctl): per-repo workspace tracker for multi-repo git/status
The git/status endpoint now accepts an optional ?repo=<subpath> query
param that selects a sub-directory of the workspace, completing the
per-repo plumbing started in the multi-repo phase. Single-repo callers
keep the workspace-root behavior unchanged.
- Manager.GetWorkspaceTrackerFor(subpath) returns a per-subpath
WorkspaceTracker (lazy + cached). Validation reuses a new
resolveSubpath helper shared with GitOperatorFor, so both code paths
reject path traversal / absolute paths / missing dirs the same way.
- Per-subpath trackers are NOT started (no polling goroutines); the
multi-repo path uses them only for synchronous GetCurrentGitStatus
queries which fall through to getGitStatus when no cache is present.
This keeps per-repo cost cheap.
- handleGitStatus reads c.Query("repo") and routes through the new
resolver, returning a 400 with an error payload on invalid input.
- Tests cover empty subpath (root), valid subpath caching, traversal
rejection.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web/review): group file tree by repository for multi-repo task changes
The review file tree now renders per-repo top-level nodes when a task's
changes span multiple repositories, so the same file path in two repos
no longer collapses into a single ambiguous entry.
- ReviewFile.repository_id and ReviewFile.repository_name (optional)
identify which repo each file belongs to. Single-repo callers leave
them unset and the tree falls back to the existing flat grouping.
- buildFileTree splits into buildMultiRepoTree (when 2+ distinct
repository_names are present) and buildFlatTree (the original logic).
Repo roots have isRepoRoot=true and a repositoryId for downstream
filters; their children are full path subtrees per repo.
- collapse() skips repo roots so a repo with a single changed file
still shows its repo header.
- ReviewFileTree renders repo roots with a stronger label, a top
border, and an "N files" count badge; data-testid="repo-root-node"
for E2E coverage. File-level rendering is unchanged.
- Tests cover the flat-tree fallback (no repo info, single repo),
multi-repo grouping, repo-root pinning, and same-filename-different-
repos isolation.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web/task-top-bar): "+N" repo chip on multi-repo task header
The task page top-bar breadcrumb now shows a small "+N" chip next to the
primary repository name when the task spans more than one repository,
matching the kanban-card treatment introduced earlier. Hover surfaces the
exact count via tooltip ("frontend (+2 more repos)").
- resolveTaskProps populates repositoryCount from task.repositories.length.
- TaskTopBar threads repositoryCount through to the new
RepositoryBreadcrumb subcomponent.
- The chip carries data-testid="topbar-extra-repo-count" for E2E
selectors.
- TopBarLeft was nearing the function-length / complexity caps; the
breadcrumb extraction restores headroom for future additions.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(web/task-create): disable "Add repository" when no other repos available
When the user only has the primary repository in their workspace,
clicking "+ Add repository" used to insert a row whose dropdown was
empty — looking like a bug ("no other repos show up"). The button now
disables itself when there are no remaining workspace repositories to
choose from, with a tooltip explaining why ("All workspace repositories
are already added"). The previous "primary not selected" guard is also
surfaced via tooltip.
- ExtraRepositoryRows computes remainingCount = repos − used (primary
+ already-added extras) and gates the Add button on it.
- New computeAddButtonHint helper returns a per-state title string for
the disabled cases.
- Tests cover: dropdown shows the unused workspace repos when opened
(the click path the user reported wasn't actually broken — the gate
was just missing); Add button disabled when only the primary exists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(agentctl): workspace tracker falls back to first repo subdir for multi-repo task roots
The "Changes" panel was empty for multi-repo tasks because the
agent's workspace path is the task root — a plain holder directory
that holds one git worktree per repository as siblings. The workspace
tracker tried to run git commands against that bare root and silently
failed, so no git status updates ever reached the UI.
The tracker now detects this at construction: when the configured
workDir isn't itself a git repo, it falls back to the first immediate
child directory that is. The user gets the primary repo's changes in
the panel; cross-repo aggregation is a follow-up.
- preferGitRepoChildIfRootIsBare scans os.ReadDir entries (skipping
hidden / non-directory entries) and returns the first that has a
valid git index. Logs an info line when the rewrite happens so it's
visible in the agentctl log.
- Repo-less tasks (e.g. quick chat) and single-repo tasks keep their
previous behavior verbatim — the fallback only fires when the root
itself fails resolveGitIndexPath.
- Tests cover: fallback when the workdir is a multi-repo holder,
no-fallback for a real git repo, no-fallback when no git child
exists.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(multi-repo): per-repo workspace tracking — Changes panel shows all repos
The agent's workspace tracker is now split per-repository for multi-repo
task roots. Each repo gets its own tracker that polls git independently
and stamps its repository name onto every emitted event. The Changes
panel aggregates files from every repo and groups them under per-repo
headers using the file-tree machinery from Phase 6.
Backend (agentctl):
- GitStatusUpdate / FileListUpdate gain a RepositoryName field. Empty
for single-repo workspaces (legacy clients see no change).
- WorkspaceTracker carries repositoryName and stamps it onto every
emitted status/file-list. Two constructors: NewWorkspaceTracker for
the legacy single-repo path (with the bare-root fallback) and
NewWorkspaceTrackerForRepo for explicit per-repo trackers.
- Manager scans direct child directories for git repos at construction.
When 2+ repos are found, builds one tracker per repo subdir on top of
the root tracker (which stays bound to the bare task root and emits
nothing useful). Single-repo workspaces are unchanged.
- Manager.SubscribeWorkspaceStream / UnsubscribeWorkspaceStream fan out
one client subscription across the root + all per-repo trackers via
AttachWorkspaceStreamSubscriber / DetachWorkspaceStreamSubscriber on
the tracker. The WS handler switched from the single-tracker subscribe
to the manager fan-out.
- Tests cover: per-repo trackers spawn for multi-repo task roots,
single-repo doesn't, both repos emit GitStatusUpdates tagged with
RepositoryName.
Frontend:
- GitStatusEntry / GitStatusData carry repository_name (optional).
- session-runtime gitStatus.byEnvironmentRepo new map keyed by env id
then repository name; setGitStatus routes per-repo updates into it
while still updating the legacy byEnvironmentId for single-repo
consumers. clearGitStatus drops both maps.
- migrateEnvKeyedData migrates the new per-repo map alongside the
existing keys when sessionId resolves to an environmentId.
- WS git-status handler forwards repository_name from event.status.
- New useSessionGitStatusByRepo hook returns sorted [{repo, status}]
pairs for the active session.
- task-changes-panel mergeReviewFiles iterates statusByRepo when
available, stamping repository_name onto every uncommitted file so
the Changes panel groups them under per-repo headers (using the
ReviewFile.repository_name plumbing from Phase 6).
- Tests: per-repo routing, no overwrite on sibling repo update,
legacy single-repo fallback, clear-on-cleanup.
Verified: pnpm exec vitest run 623/623, eslint --max-warnings 0 clean,
tsc --noEmit clean, make test (backend) clean, make lint 0 issues.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web/changes-panel): per-repo group headers above each repo's files
The Changes panel now renders a sticky "frontend / N files" header above
the diffs from each repository so the user can visually distinguish
where each change lives. Single-repo tasks render unchanged (no header
shown when there's only one unnamed group).
- ReviewDiffList groups its file list by repository_name (preserving the
caller's order within each group), then renders one RepoGroupHeader
per group followed by the existing FileDiffSection list. Header gating
rule: show when len(groups) > 1 OR the single group has a non-empty
repository name.
- Group key uses "repo:path" so a file with the same path in two repos
no longer collides on React's reconciliation.
- groupFilesByRepository + RepoGroupHeader live in a sibling
review-diff-list-groups.tsx file to keep review-diff-list.tsx under
the 600-line lint cap.
- Tests cover: no header for plain (no repo_name) files, one header per
repo for 2+ groups, header shown even for a single named repo, file
paths land under the right group's section.
Verified: vitest 627/627, eslint clean, tsc clean.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(lifecycle): propagate RepositoryName through PublishGitStatus
The agentctl WorkspaceTracker stamps every per-repo GitStatusUpdate
with RepositoryName, but the lifecycle PublishGitStatus translation
into GitEventPayload was dropping that field. The orchestrator then
forwarded a status with empty repository_name, the frontend store
routed everything into the empty-key bucket, and the Changes panel's
multi-repo grouping never triggered — no header, no per-repo split.
- GitStatusData gains RepositoryName (omitempty so single-repo
payloads stay byte-identical on the wire).
- PublishGitStatus copies update.RepositoryName onto the payload.
- Test asserts the field survives the publish → subscribe round trip
via the in-memory event bus on the GitEvent.<sessionID> subject.
Restart the agentctl process for any running multi-repo task to pick
up the new field; new launches get it automatically.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(web/changes-panel): per-repo group headers in the dockview Changes tab
The Changes tab in the dockview panel (the Files / Changes (N) tab the
user actually interacts with — not the standalone Review dialog) was
still rendering a flat file list, so multi-repo task changes either
collided onto the legacy single-status (showing only one repo's files)
or rendered without grouping.
- FileInfo gains repository_name (optional, single-repo unchanged).
- useSessionGit aggregates files from useSessionGitStatusByRepo when
multi-repo data is present, stamping each FileInfo with its
repository_name. Falls back to the legacy single status for
single-repo workspaces. Aggregation extracted into
aggregateFilesAcrossRepos to keep useSessionGit under the 100-line
function lint cap.
- ChangedFile (changes-panel.tsx + changes-panel-timeline.tsx) carries
repositoryName through to the renderer.
- FileListSection delegates to a new FileListBody that groups rows by
repositoryName; renders a small per-repo subheader when 2+ repos OR
any single named repo is present, otherwise falls back to the
pre-multi-repo flat list (same DOM shape).
- Tests cover: no header for plain (no repo_name) files, headers
per repo for 2+ groups, single named repo still gets a label.
Restart the backend so the per-repo trackers spawn for in-flight
multi-repo tasks; new launches are unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(multi-repo): per-repo git ops, unified branch endpoint, chip-style task create
Bundles a string of multi-repo fixes and refactors that came up while
testing the feature end-to-end:
- workspace_path stored on multi-repo TaskEnvironment was one level too
high, so agentctl booted with the wrong WorkDir on cold start; fixed
in resolveTaskEnvWorkspacePath + GetWorkspaceInfoForSession.
- Stage/Unstage/Discard/Commit/Revert/Reset/Amend now thread `repo`
through agentctl client → WS handler → frontend hook → SessionGit
fan-out, so the right git repo is targeted (was running at the task
root, which isn't a git repo for multi-repo tasks).
- agentctl GitLog is multi-repo aware: when no `repo` is specified and
per-repo trackers exist, fans out one log per repo and merges sorted
newest-first with RepositoryName stamped on each commit.
- Per-repo grouping for files (Changes panel) and commits, with
collapsible group headers; commits section defaults to collapsed.
Algorithm extracted into one generic groupByRepositoryName helper
(was copy-pasted three times).
- Task creation dialog rewritten around a unified `fs.repositories[]`
list — no more "primary vs extras" split. Chip-style row with one
pill per repo + branch, "+" to add, x to remove, GitHub URL toggle
inline. Discovered (on-machine) repos appear in any chip's dropdown.
Removes ~6 fields and 3 helpers from the dialog state.
- Branch listing endpoints collapsed to one:
GET /workspaces/:id/branches?repository_id=X | path=Y
Frontend collapses to a single useBranches hook with a tagged-union
source. Drops two HTTP routes, two service methods, two API client
funcs, and one whole hook file.
Tests added across the chain: workspace_path resolver, sort-commits-
by-date, manager poll-mode propagation, RepoSubpaths, chip grouping
+ collapse + per-repo isLatest, group-by-repo helper, dialog payload
builder + chip wiring + discovered-repo flow.
* feat(multi-repo): unified per-repo git controls for all VCS operations
Changes panel and top-bar VCS button render per-repo Stage all, Commit,
Unstage all, Push, Pull, Rebase, Merge, and Create PR uniformly across
single-repo and multi-repo workspaces. Operations route to the correct
git repo subpath; fan-outs collect per-repo results and surface partial
success in the toast layer.
Backend:
- Stamp RepositoryName on Reset/BranchSwitch/FileChange notifications
and forward through orchestrator handlers
- Surface per-repo errors from handleGitLogMultiRepo via PerRepoErrors
Frontend:
- pull/push/rebase/merge/abort/commit fan out via PerRepoOperationResult
- Per-repo group headers always render with action buttons
- Top-bar dropdown lists per-repo Pull/Rebase/Merge entries
- pendingStageFiles keyed per-repo to prevent cross-repo clears
- useRepoDisplayName distinguishes loading from multi-repo
- Commit dialog file summary respects stageAll
- New helpers: changes-panel-helpers.ts, changes-panel-repo-groups.tsx,
use-repo-display-name.ts
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* feat(multi-repo): plumb repo through file/commit/diff endpoints; per-repo PRs and review UI
- backend: thread `repo` subpath through pull/push/rebase/merge/abort, file
content/update, cumulative-diff, and commit-show endpoints; per-repo
fan-out for log + cumulative-diff with each repo's own merge-base; emit
full `repositories` array on task.updated WS payload
- github: store/list TaskPRs as []*TaskPR (per-repo); orchestrator/web store
rekey to `Record<taskId, TaskPR[]>`
- web: aggregate PR icon (count badge + worst-of color, multi-PR tooltip);
branch hover card renders one row per repo with each repo's task base
branch; review dialog merges per-repo file maps with composite keys and
propagates repository_name through diff/expand/revert; commit-detail
panel scopes to the owning repo
- kanban card: chip row of repo names (up to 2 + "+N" overflow, tooltip
lists all)
- task-create dialog: gate non-worktree executors to multi-repo only; move
fresh-branch toggle inline next to the repo chips (icon-only, with
title/aria-label)
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* fix(multi-repo): scope PR watches/associations and review UI per repository
Schema: add repository_id to UNIQUE constraints on github_pr_watches and
github_task_prs (with idempotent copy-and-rename migration) so one task
spanning N repos can hold one watch/PR per repo without collisions.
Plumb repository_id through CreatePRWatch/EnsurePRWatch/AssociatePRWithTask
/AssociatePRByURL, fan TriggerPRSync across all per-repo watches, and
disambiguate status sync by (task, owner, repo, pr_number) instead of
"first match". Add Repo to wsCreateFile/Delete/Rename and wsCreatePR/
RenameBranch so file and git ops target the right working tree.
Frontend review dialog: switch reviewedFiles, staleFiles, fileRefs,
selectedFile and comment counts to NUL-separated `${repo}\x00${path}`
keys so same-named files in different repos no longer share state.
useTaskPR consumes the new {prs: TaskPR[]} envelope.
Also: snapshot repoTrackers slice before background refresh in
SetWorkspacePollMode to prevent races with Stop(), and key
mergeCumulativeFiles by NUL-separated (repo, path) to match.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
* chore: apply prettier formatting
Unblock CI prettier check on files modified earlier on this branch
that pre-date the current prettier config.
* fix: address PR review feedback and unblock E2E
Backend:
- ListWorkspaceTaskPRs background sweep now uses TriggerPRSyncAll so
multi-repo tasks refresh every per-repo PR (was only the most recently
updated watch). Caught by claude review on #767.
Frontend (no behaviour change):
- Replace literal NUL byte (0x00) in FILE_KEY_SEP and the inline key
builder in use-review-dialog with the explicit escape so the
source is plain ASCII. The runtime separator is unchanged; this just
removes a footgun that GitHub's diff renders as a visible space.
- Show the auto-resolved branch as a small read-only pill next to the
GitHub URL input so the URL-mode dialog surfaces the branch the task
will run on (previously only the URL was visible).
E2E:
- changes-panel-focus / pr-detection / pr-switcher-changes: expand the
commits-section before asserting on commit text -- the section starts
collapsed since the chip-style refactor.
- repository-selector-scroll: open the popover via repo-chip-trigger
instead of the legacy single repository-selector testid.
* test(e2e): align task-create dialog tests with chip refactor
Updates three more tests that broke when the dialog migrated from a
single repository/branch combobox to per-row repo/branch chips and the
URL-mode toggle relabel:
- create-task.spec.ts (pre-select + remembers-selections): switch
repository-selector / branch-selector lookups to repo-chip-trigger /
branch-chip-trigger.
- create-task-github-url.spec.ts (toggle test): the inverse-state label
is now "use a workspace repository", not the legacy
"or select a repository".
No code changes — purely test alignment with the existing UI.
* fix(web): re-apply workbench-chrome topbar + restore branch-selector behaviour
Two regressions surfaced by the multi-repo merge into main:
1. task-top-bar.tsx had been resolved in our favour at the workbench-chrome
merge (#761), losing the TopbarActionOverflow wiring that the new test
asserts on. Re-applies main's topbar structure (TopbarCluster groups,
TopbarActionOverflow, IntegrationsMenu, MoreToolsMenu, SettingsButton)
on top of our multi-repo additions: repositoryCount + RepositoryBreadcrumb
"+N" chip, gitStatus + isMultiRepo plumbing, and the GitAheadBehindBadges
suppression for multi-repo. GitAheadBehindBadges moved to its own file to
stay under the file-length cap.
2. The chip-style task-create dialog dropped the legacy branch-selector
semantics. Restored:
- Local-executor branch lock: the per-row branch chip is now disabled
when isLocalExecutor and fresh-branch is off. The user's checkout
dictates the branch for local execution; fresh-branch unlocks it.
- Branch override: when locked, the chip shows currentLocalBranch
instead of the value the user picked under a different executor
(so worktree -> local doesn't display a stale "develop" pick).
- Branch refresh: useBranches now exposes a refresh() callback for
id-based sources (calls listRepositoryBranches with refresh=true),
and Pill renders BranchRefreshButton in its popover header when
onRefresh is wired.
- URL-mode branch picker: GitHubUrlSection now renders a real
branch-chip-trigger Pill backed by fs.githubBranches instead of a
read-only span, restoring the "branch selectable in URL mode"
behaviour (URL mode is always remote-clone, so the local-executor
lock does not apply).
E2E spec updated: bulk-rename legacy branch-selector / repository-selector
testids to branch-chip-trigger / repo-chip-trigger.first(). All 13
create-task-branch-selector tests pass locally.
* ci: force workflow re-run
* fix(web): unblock E2E shard 2
Three issues showed up only after the merge with main, which brought back
the E2E shard 2 tests touching git/changes-panel and improve-kandev:
1. Wire the TaskCreateDialog slot props (extraFormSlot, aboveDescriptionSlot,
bottomSlot, descriptionPlaceholder) through to DialogPromptSection. They
were declared on the public API but dropped on the floor before this
change, so Improve Kandev's contributor banner ("@octocat" /
"@kandev-maint") never rendered. Also gate the "or paste a GitHub URL"
toggle on lockedFields.repository so the locked-repo dialog hides it
(matches main's behaviour and the existing test expectation).
2. Revert "commits section starts collapsed" (introduced in
|
||
|
|
c0ce55ff9d |
fix: unblock terminals stuck on Connecting and heal task_environments (#769)
* fix(gateway): snapshot agentctl status and env id on session subscribe Late-subscribing clients (page reload, task switch, WS reconnect) miss the agentctl_ready and task_environment_id events fired during the original launch. Without them, the env-routed shell terminal gate stays stuck and the panel hangs forever on "Connecting terminal...". - appendSessionStateMessage now carries task_environment_id so the frontend can seed environmentIdBySessionId for the active session. - New appendAgentctlStatusMessage snapshots current agentctl readiness via a bounded Health probe and emits session.agentctl_ready / _starting to flip the client gate out of `undefined`. - GetOrEnsureExecutionForEnvironment now publishes AgentctlStarting to mirror EnsureWorkspaceExecutionForSession and keep gate transitions monotonic. * fix(web): drop shell-mode agentctl gate to unblock env-routed terminals The shell terminal gate required agentctlStatus.isReady to flip to ready before opening the WS, but that event may have fired before the client subscribed (page reload, task switch, local-executor task switch). The lazy create-on-terminal-connect path on the backend never fires either, because the WS never opens — chicken-egg. Backend env handler already waits for remote workspace readiness server-side (waitForRemoteExecutionReadyWithTimeout), so the client just needs the routing keys (env id + session id). Agent-mode terminals still gate on readiness — the agent process must be wired before passthrough I/O is meaningful. Extract computeCanConnect helper to keep PassthroughTerminal under the complexity ceiling. * test: cover terminal-hang on subscribe snapshot and env-routed gating Adds a backend unit test asserting appendSessionStateMessage carries task_environment_id (the deterministic RED for the env-map seed bug), plus E2E scenarios for the user-facing repros: cold load, kanban switch, hard reload, sidebar switch, and a local-executor task switch that used to poison the terminal for every subsequent task. Adds an expectTerminalConnected helper to SessionPage to detect the "Connecting terminal..." overlay. * fix(repo): heal task_environments on boot and enforce one env per task Live DB audit on the user's machine surfaced two corrupt-data classes that manifest as the user-facing "terminal doesn't appear / Failed to fetch terminals: 503" symptoms: - 15 worktree-mode envs with worktree_path set but workspace_path empty. GetOrEnsureExecutionForEnvironment returns ErrSessionWorkspaceNotReady forever, the env terminal handler 503s. - 3 tasks with two task_environments rows (race in lazy create — pairs 4-6 s apart, both ready, distinct workspace_path). healTaskEnvironmentWorkspacePaths backfills workspace_path = worktree_path on the affected rows, and healDuplicateTaskEnvironments collapses each duplicated task to its most recently updated env (re-pointing any sessions still on the loser, then dropping the loser row). ensureTaskEnvironmentTaskUniqueIndex adds a UNIQUE index on task_environments(task_id) so a future race fails loud. Order matters: heal must run first to drop duplicates before the unique index is created. * fix(repo): reject empty workspace_path on worktree envs The orchestrator's persistTaskEnvironment update branch previously left workspace_path untouched, so an env created with empty workspace_path would stay corrupt forever — terminals 503 with ErrSessionWorkspaceNotReady. Tighten the repository boundary: CreateTaskEnvironment refuses worktree-mode inserts without workspace_path, and UpdateTaskEnvironment refuses to clear it back to empty. A future writer regression now fails loud at the boundary instead of producing a row that breaks the env terminal handler. * fix(executor): refresh paths on env update and dedupe by task The persistTaskEnvironment update branch only mutated AgentExecutionID + Status, so envs created earlier with an empty workspace_path stayed permanently broken across launches. Refresh worktree + workspace fields on update via a shared computeWorkspacePath helper used by both branches. Concurrent launches for the same task previously raced inside this function — each saw existingEnv == nil and each called CreateTaskEnvironment, producing duplicate rows ~4-6 s apart in the live DB. Hold a per-task mutex (mirroring sessionLocks) and re-fetch the env inside the critical section so siblings reuse the row the first launch persisted. The repo's unique index on task_environments(task_id) is the durable guarantee; the lock just closes the window before the constraint trips. * test(repo): cover task_environments heal, validation, unique index Repository unit tests for the new heal steps and writer guards: - HealTaskEnvironmentWorkspacePaths_BackfillsEmpty / LeavesPopulatedAlone / Idempotent - HealDuplicateTaskEnvironments_KeepsMostRecent (asserts session re-link to winner) / NoOpWhenSingle - EnsureTaskEnvironmentTaskUniqueIndex_BlocksFutureDuplicates - CreateTaskEnvironment_RejectsEmptyWorkspaceForWorktree / AllowsNonWorktreeWithEmptyWorkspace - UpdateTaskEnvironment_RejectsClearingWorkspaceForWorktree Tests live in the sqlite package so they can exercise the unexported heal* helpers directly, mirroring how the boot path calls them. * fix: address PR review feedback - helpers.go: drop the 2 s blocking Health probe in appendAgentctlStatusMessage. sendSessionData runs in the WS read loop, so the probe was delaying every subscribe/focus ACK by up to 2 s. Use GetWorkspaceStream() != nil as the cached readiness signal — the stream manager only attaches it after Health succeeds, so its presence is a free, non-blocking proxy. Subsequent waitForAgentctlReady events still correct the snapshot if it picks the wrong one. - manager_execution.go: publish AgentctlStarting INSIDE createExecution, before spawning waitForAgentctlReady. The previous order published it after createExecution returned, which let the goroutine race past on fast Health and deliver Ready before Starting. Drop the now-redundant publishes in EnsureWorkspaceExecutionForSession and the env lazy-create path. - task_environment.go: replace the local executorTypeWorktree constant with models.ExecutorTypeWorktree (canonical) so a future rename of the type is caught by the compiler. - base.go: heal_workspace_path now respects the kandev path layout — task-dir-mode envs (.../.kandev/tasks/<name>/<repo>) get Dir(worktree_path) as workspace_path, mirroring computeWorkspacePath in the orchestrator. Plain worktree mode unchanged. - heal_test.go: tighten unique-constraint assertion to require a UNIQUE error string so the test fails loudly if a different DB error masks the constraint violation. Add task-dir-mode heal coverage. |
||
|
|
1eae869054 | feat: add improve kandev in-app contribution flow (#740) | ||
|
|
45ee2ff48f |
feat: key terminals + dockview layout to TaskEnvironment (#755)
* feat(backend): key user shells by TaskEnvironmentID Sessions in the same task share a TaskEnvironment. Re-key the user shell runner so terminals are scoped per env, not per session — switching between sessions of the same task now sees the same shell list. - Add TaskEnvironmentID to AgentExecution / LaunchRequest / ExecutorCreateRequest / executor.LaunchAgentRequest, propagated through ToAgentExecution and the orchestrator launch path. - Add Manager.ResolveScopeKey(sessionID) — returns env when execution carries it, falls back to sessionID for legacy paths. - Rename runner param sessionID -> scopeID in interactive_shells.go; map keys are now scopeID:terminalID. - Shell handlers + WS terminal handler resolve sid -> scopeID via ResolveScopeKey before calling the runner. * feat(web): key dockview layout + portals to TaskEnvironmentID Switching between sessions of the same task no longer rebuilds the dockview layout — terminals and panels persist because layouts are env-scoped, not session-scoped. The bottom terminal also reuses a stable sessionId across same-env switches so it doesn't reconnect to a different shell. - Rename layout persistence: setSessionLayout/getSessionLayout/*SessionMaximizeState -> setEnvLayout/getEnvLayout/*EnvMaximizeState (kandev.dockview.env-layout. prefix). - Rename dockview-session-switch.ts -> dockview-env-switch.ts; performSessionSwitch -> performEnvSwitch with oldEnvId/newEnvId/activeSessionId. - dockview-store.ts: currentLayoutSessionId -> currentLayoutEnvId; switchSessionLayout -> switchEnvLayout(oldEnvId, newEnvId, activeSessionId). Same-env switches no-op. Drop _skipLayoutSwitchForSession + consumeSkipFlag — env-keyed action handles it structurally. - panel-portal-manager: PortalEntry.envId; releaseBySession -> releaseByEnv. ENV_SCOPED_COMPONENTS in dockview-desktop-layout (renamed from SESSION_SCOPED). - useEnvSwitchCleanup replaces useSessionSwitchCleanup; tracks env changes only. - All performLayoutSwitch callers (sidebar, dropdown, header, dialog) resolve sid -> envId via environmentIdBySessionId before switching. - bottom-terminal-panel: drop sessionId prop, use useEnvironmentSessionId(). - cleanupTaskStorage now takes envIds[] alongside sessionIds[] for env-scoped layout/maximize cleanup. * test: add coverage for env-keyed terminals + layout Playwright e2e (terminal-env-keyed.spec.ts) — switching between sessions of the same task keeps terminal scrollback intact and asserts both sessions share one task_environment_id. Update the comment in sessionless-task-switch.spec.ts to reference performEnvSwitch. * fix: address env layout PR feedback * test: fix flaky handshake auth check * fix: address env scope review findings |
||
|
|
3d93d9e93c | fix(mcp): improve create_task_kandev repository and workspace resolution (#733) | ||
|
|
75f1732421 |
feat: add GitHub token injection for remote executors and Docker session resume (#654)
* feat: add GitHub token injection for remote executors and Docker session resume - Add global GitHub access token configuration (backend + frontend) - Inject GitHub token into Docker and Sprites executors via env vars and Git credential helper - Transition Docker executor from host mounting to repository cloning - Implement Docker session resume (reconnect to existing containers after backend restart) - Add shell validation using exec.LookPath in agentctl - Add scriptengine providers for agent install and agentctl placeholders * fix: format github-status.tsx with prettier * fix: resolve lint issues (goconst, errcheck, file-length) * fix: address code review findings - Fix resolveGHCLIToken to check both GITHUB_TOKEN and GH_TOKEN guards - Fix credential helper to use \\ fallback - Change /bin/bash to /bin/sh in credential helper for Alpine compat - Strip embedded token from .git/config after clone in prepare scripts - Extract buildLaunchAgentRequest helpers to stay under 80-line limit - Add tests for GitHubAuthProvider, injectTokenIntoURL, reconnect validation - Add GH_TOKEN-only test case for resolveGHCLIToken * refactor(executor): extract GitHub credential resolution to separate file Move injectGitHubToken, injectGitHubTokenFromCLI, resolveRemoteCredentials, resolveAuthSecrets, methodIDToEnvVar, resolveGHCLIToken, and detectGHToken out of executor_execute.go (was 803 logical lines, over the 800-line limit after merging main) into a new executor_credentials.go file. Also adds the nil-guard on secretStore in resolveAuthSecrets that was flagged by 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> |
||
|
|
046ac5c1b6 |
feat(web): add Ctrl+F search to session, plan, and terminal panels (#686)
* feat(backend): add session-scoped message search endpoint
Adds SearchMessages across repository/service/handler layers and a new
WebSocket action "message.search". Returns lightweight hits with a
60-char snippet around the first match. Session-scoped only: results
never leak across sessions. Uses dialect-aware LIKE/ILIKE so it works
on SQLite today and Postgres later.
* feat(web): add shared panel search bar and Ctrl+F hook
Introduces the reusable PanelSearchBar overlay and usePanelSearch hook
used by the per-panel search features. The hook listens on the window
and fires only when focus is within the panel root, preventDefaults the
browser find-in-page, and wires Esc to close. PanelRoot becomes a
forwardRef so panel components can expose their root element to the
hook. Adds SHORTCUTS.FIND_IN_PANEL plus shared highlight/flash styles.
* feat(web): add Ctrl+F search to terminal panels
Mounts the xterm addon-search on both passthrough and shell terminals,
exposing next/prev plus case-sensitivity and regex toggles. Invalid
regex surfaces an inline error without firing the search. Because xterm
consumes keystrokes before React, the Ctrl/Cmd+F intercept is added to
attachCustomKeyEventHandler so the shortcut opens the bar from inside
the terminal. Results counter comes from onDidChangeResults; decorations
clear on close.
* feat(web): add Ctrl+F search to plan panel
Adds a custom TipTap extension that builds a ProseMirror DecorationSet
over the document and exposes setPlanSearchQuery / planSearchNext /
planSearchPrev / clearPlanSearch commands. The current match gets an
extra class and scrolls into view. Match count is read from the plugin
state via transaction subscription, so edits to the document update the
counter automatically. Find-only — no replace.
* feat(web): add Ctrl+F search to session chat panel
Wires the new backend message.search action into a debounced client
hook and renders a hits dropdown under the search bar, with the query
highlighted in each snippet. Clicking a hit scrolls the message into
view and flashes it. When the target message is older than what is
currently loaded, the hook loops through loadOlder pages until the
message renders, making search work across lazy-loaded history without
needing to scroll-and-wait manually.
* fix(web): lazy-load xterm addon-search to avoid SSR crash
addon-search is published as a UMD bundle that references `self` at
module load. Statically importing it breaks the Next.js static
prerender of /. Deferring the import to an effect keeps the module out
of the SSR graph without changing runtime behaviour.
* fix(web): propagate plan editor via state so plan-search hook re-runs
The plan-search hook subscribed to editor transactions inside a
useEffect that depended on a ref object. Because ref.current changes do
not trigger effect re-runs, if the Editor instance was not yet ready
when the hook first mounted, the transaction handler never attached and
the match counter stayed at 0 / 0. Tracking the editor via state (in
addition to the ref) causes the deps to change when the Editor becomes
available, so the handler attaches and the counter updates.
* feat(web): allow overriding message list renderer via ?renderer= query
Exposes the "native" / "virtuoso" choice at runtime so E2E coverage can
exercise both paths without a redeploy or code change. Defaults to the
existing "native" strategy when no override is present.
* test(web): add Playwright coverage for panel-scoped Ctrl+F search
Adds an e2e/tests/search/ suite with five specs:
- panel-search-shared: cross-panel contract (Ctrl+F opens, autofocus,
Esc closes, close button, state reset on reopen) parametrised over
session, plan, and terminal.
- terminal-search: xterm addon-search integration — key intercept
without leaking "f" to the PTY, case + regex toggles (incl. invalid
regex error), Esc does not reach the shell, backspace adjusts count.
- plan-search: TipTap plugin — highlight count, exactly one current
match, counter, Enter / Shift+Enter wrapping, close clears
decorations, no case/regex toggles in plan bar.
- session-search: WebSocket-backed hits list, "No matches" state,
click-to-flash, cross-session isolation, close, and virtuoso renderer
coverage via the new ?renderer= override.
- panel-focus-routing: bar mounts inside the focused panel only.
Three tests capture GIFs for the PR description via prCapture when
CAPTURE_PR_ASSETS is set (one per panel). Helpers live in
e2e/helpers/panel-search.ts and e2e/helpers/seed-session-messages.ts;
SessionPage gains search-bar locators.
* fix(web): prevent plan-search update loop on spurious editor transactions
TipTap fires `transaction` on every cursor move/click. The match-info
handler allocated a fresh `{current, total}` object each time, so React
always saw new identity, re-rendered, and could cascade with downstream
effects (e.g. `rehydrateCommentMarks`) into a "Maximum update depth
exceeded" loop. Use a functional setState with equality bail-out so the
handler only schedules a render when the counter actually changes.
* fix(web): retry terminal search once xterm addon-search finishes loading
The SearchAddon is dynamic-imported and was stored in a ref. The
query→findNext effect called `addonRef.current?.findNext(...)` — a
silent no-op if the addon wasn't loaded yet — and its deps didn't
include addon readiness, so a query typed before the import resolved
was dropped forever. Track the addon via state so the search effect
re-runs when it becomes available (and re-binds cleanly if the terminal
is ever re-created). Also deduplicate match-info updates via functional
setState + equality check.
* fix(web): route Ctrl+F to the last-clicked panel when focus is ambient
The chat panel's message list has no focus-capturing child, so clicking
on a message left `document.activeElement === <body>` and the existing
`isFocusWithin` gate silently fell through to the browser's native find.
Fix with two complementary mechanisms:
- `usePanelSearch`: track the most-recently-clicked panel via a
window-level pointerdown capture listener. When focus is ambient
(body/html), that panel claims Ctrl+F. Window-level capture dodges
any descendant `stopPropagation` that would skip a container-level
listener.
- Chat panel root: tabIndex=-1 + onMouseDown that focuses the root when
the click target isn't already focusable. Normal path matches how
plan/terminal already work (TipTap/xterm capture focus on click).
* feat(web): show agent profile name and logo in session search hits
The hit list showed uppercase "AGENT"/"USER" with vivid blue/green
colors that clashed with the app's muted metadata aesthetic. Replace
with the actual author identity:
- Agent hits render the profile name (falling back to "Agent") with
the profile's <AgentLogo> inline — same mechanism session tabs use.
- User hits render "You" with a subtle `text-primary/80` tint that
echoes the user bubble's `bg-primary/30`, keeping a light color
differentiator without reintroducing loud blue/green.
Added `useSessionAgentIdentity` (plus a `useSessionAgentProfile` helper)
that reads from the store via two primitive selectors — the id, then
the profile — and composes the final object inside `useMemo`. A naive
single selector returning `{label, name}` triggers the "getSnapshot
should be cached" infinite loop in `useSyncExternalStore`.
* style: apply prettier formatting to search files
* fix(web): inline resolveStrategy in useMemo to satisfy react-hooks/use-memo
* fix(backend): escape LIKE metacharacters in message search
User query of `%` previously built LIKE pattern `%%` which matched every
message in the session; `file_name` also matched `filename`, `file name`,
etc. Escape the three LIKE metacharacters (`\`, `%`, `_`) before wrapping
the pattern, and declare `ESCAPE '\'` on the clause. Add regression tests
covering wildcard literals and the literal-percent happy path.
* fix(backend): slice search snippet in rune space to keep UTF-8 valid
`buildSnippet` indexed and sliced on byte offsets, so content containing
multi-byte characters (emoji, CJK, accented letters) could be cut
mid-rune — `encoding/json` then replaced the invalid bytes with `\ufffd`
in the websocket response. Switch to rune slicing and per-rune
case-folding via `unicode.ToLower`; this also avoids the
`strings.ToLower` byte-length-change pitfall (e.g. Turkish İ, German ß).
Add a handler test covering emoji, CJK, and large multi-byte content.
* fix(web): cancel backfill loop when user clicks a newer session hit
`setActiveHit` awaits up to 40 `loadOlder()` iterations when the target
message isn't in the DOM. If the user clicked a second hit while those
awaits were in flight, the in-progress loop kept running and eventually
scrolled to the original target, overriding the user's intent. Gate the
loop on a generation counter so only the most recent call's backfill
drives the scroll.
* fix(web): gate plan-search scroll side-effect on dispatch
TipTap invokes commands with `dispatch === undefined` during `can()`
checks to test applicability (e.g. before enabling a menu item). The
next/prev commands called `scrollToMatch` outside the `if (dispatch)`
guard, so a dry-run invocation would scroll the editor to a match the
user didn't navigate to. Move the scroll inside the dispatch block.
* fix(web): address review nits on search UX
- formatTime now checks Number.isNaN(d.getTime()) before formatting;
Invalid Date doesn't throw, so the catch branch was unreachable and
hits with a malformed created_at rendered the literal "Invalid Date".
- prefers-reduced-motion now silences the .search-flash animation
alongside the other custom keyframe animations.
- planScript escapes `title` symmetrically with `content` so callers
passing arbitrary strings don't produce malformed JSON.
* fix(web): fold description into opts to keep seedTask under param limit
`seedTask` had 6 parameters, violating the repo's 5-parameter cap and
failing CI lint. Fold `description` into the existing `opts` object and
update all callsites.
* test(web): add unit tests for useSessionSearch + remove redundant ternary
Cover the non-trivial hook logic called out in review: debounced fetch
coalescing, request-ID cancellation for superseded queries, null
sessionId short-circuit, and close() resets. Also simplify the
`matches.length ? 0 : 0` ternary flagged as a dead branch — both sides
were already 0.
* fix(web): exclude PanelRoot (tabindex=-1) from the focus short-circuit
`target.closest("[tabindex]")` was matching PanelRoot itself (which has
`tabIndex={-1}` so it can receive programmatic focus), causing the guard
to early-return and the `panelRef.focus()` call to never fire — defeating
the Ctrl+F focus-routing that this handler was added for.
Narrow the selector to `[tabindex]:not([tabindex='-1'])` so it only
short-circuits on genuinely interactive elements.
* fix(web): read isReadOnlyMode from live ref in shell-terminal key handler
`attachCustomKeyEventHandler` is called once and has no dispose hook, so
the inner handler keeps its initial closure forever. Other values
(sessionId, send, onFindInPanel) are read via `stateRef` so they stay
current, but `isReadOnlyMode` was captured by closure — if the prop ever
toggled mid-session, the Cmd+Arrow→Home/End path would continue firing
against a read-only terminal. Include it in the state ref for
consistency.
* fix(web): flatten doubly-nested seedTask opts in virtuoso spec; drop unused MODIFIER
The regex-based transform in
|
||
|
|
72451e6f58 | fix: recover from stale execution ID when auto-starting agent on prepared workspace (#690) | ||
|
|
86b3099631 | fix(backend): unblock Resume for FAILED/CANCELLED task sessions (#670) | ||
|
|
1f7e4dbb5e | fix: stream setup script output and keep prepare panel on failure (#607) | ||
|
|
5b04c9c675 | perf: focus-gated git polling to reduce CPU on retained worktrees (#610) | ||
|
|
73e58729ba | fix: apply display filters in list view (#612) | ||
|
|
28f5beb4e1 | fix(orchestrator): stop killing live agents on resume race (#619) | ||
|
|
b3aaa804f1 |
fix: use dynamic merge-base for git commits to filter main branch commits (#504)
* fix: use dynamic merge-base for git commits to filter main branch commits When a feature branch is rebased on main, the stored base_commit_sha becomes outdated. Using git log base_commit..HEAD would include main branch commits that were merged after the stored base commit. This fix adds dynamic merge-base calculation using the session's base_branch (target branch like origin/main). When fetching commits: - The backend computes git merge-base HEAD target_branch - This returns only commits not yet merged into the target branch - Works correctly even after rebases Changes: - Extended SessionReader interface with GetSessionBaseBranch method - Added GetMergeBase to GitOperator for computing merge-base - Updated git log API to accept target_branch parameter - Modified wsGitCommits handler to pass target branch for filtering * fix: update executor_test mock to match new GetGitLog signature * fix: update remaining mocks to match new GetGitLog signature * fix: update integration test mock for GetGitLog signature * fix: address code review feedback - Use GetOrEnsureExecution in wsGitCommits to recover workspaces after backend restarts - Fix archive snapshot to use baseBranch for merge-base when baseCommit is empty - Extended ExecutionLookup interface with GetOrEnsureExecution method * fix: distinguish not-ready errors from unexpected errors in wsGitCommits Address CodeRabbit review feedback: - Use errors.Is() to detect ErrSessionWorkspaceNotReady sentinel error - Return ready:false gracefully for workspace-not-ready and session-not-ready errors - Return actual errors for unexpected failures (database errors, etc.) - Add comprehensive tests for error handling paths |
||
|
|
9d816e7a63 | fix(backend): reject session resume when task is archived (#593) |