Skip to content

fix(watcher): self-heal orphaned watchers when agent profile is soft-deleted#1094

Open
nlenepveu wants to merge 3 commits into
kdlbs:mainfrom
blackfuel-ai:feature/fix-linear-watcher-f-hc7
Open

fix(watcher): self-heal orphaned watchers when agent profile is soft-deleted#1094
nlenepveu wants to merge 3 commits into
kdlbs:mainfrom
blackfuel-ai:feature/fix-linear-watcher-f-hc7

Conversation

@nlenepveu
Copy link
Copy Markdown
Contributor

Summary

When the reconciler soft-deletes an agent profile because its agent type left the registry (CLI uninstalled, feature flag toggled off, host-utility probe failed), watchers that reference that profile loop forever on "profile not found: sql: no rows in result set". Each poll creates a dead-on-arrival task; the watcher never disables itself; the user has to manually clean up.

This PR makes the dispatch pipeline self-healing.

Behaviour change

  • Coordinator pre-flight (WatcherDispatchCoordinator.Dispatch) checks the watcher's bound profile before Reserve. If the profile is soft-deleted, the source's SelfHeal runs (sets enabled=0 + stamps last_error / last_error_at) and the dispatch short-circuits — no task created, no dedup reservation taken. Linear and Jira flow through this seam.
  • GitHub issue/review watchers bypass the coordinator (legacy createIssueTask / createReviewTask). The same check fires at the top of each function via a shared helper.
  • Resolver disambiguation: ProfileResolver.ResolveProfile now returns a typed *DeletedProfileError (wrapping store.ErrAgentProfileDeleted) when the row is found but soft-deleted, distinguishing it from a genuinely missing row. Backed by a new GetAgentProfileIncludingDeleted method on the settings store.
  • Settings UI: ErrProfileInUseDetail carries a new Watchers []WatcherReference field. The 409 response now serializes it alongside active_sessions so the delete confirmation dialog can say "this will also disable N watchers — continue?". force=true bypasses both checks; the dispatch pre-flight handles the cleanup on the next poll.

Architecture

Bus event (NewLinearIssueEvent | NewJiraIssueEvent)
  → WatcherDispatchCoordinator.Dispatch
       ├─ preflightDeletedProfile ── NEW ──
       │    profileLookup.LookupProfile(src.AgentProfileID(evt))
       │    ├─ deleted? → src.SelfHeal → store.Disable*WithError → STOP
       │    ├─ lookup error → log + fall through (fail-open on transient DB hiccups)
       │    └─ healthy / empty-id → fall through
       └─ Reserve → BuildTaskRequest → CreateTask → AttachTaskID → maybe Start

GitHub bypasses the coordinator but uses the same ProfileLookup via Service.preflightDeletedProfileForGitHub*.

Schema

Four watcher tables (linear_issue_watches, jira_issue_watches, github_issue_watches, github_review_watches) gained last_error TEXT NOT NULL DEFAULT '' and last_error_at DATETIME columns via idempotent ALTER TABLE ... ADD COLUMN migrations. No FK changes.

Test plan

  • make fmt clean
  • go vet ./... clean
  • go test -race -count=1 ./internal/... passes
  • golangci-lint run ./... — 0 issues
  • Resolver-level test: *DeletedProfileError returned on soft-deleted lookup; ErrAgentProfileDeleted matches via errors.Is
  • Per-store tests: Disable*WithError flips enabled, stamps last_error + last_error_at
  • Coordinator-level tests (4 cases): deleted profile self-heals, healthy passes preflight, lookup error fails open, empty profile id skips lookup
  • GitHub path tests: createIssueTask and createReviewTask self-heal on deleted profile and skip preflight for legacy empty-id events
  • Delete-path tests: ErrProfileInUseDetail blocks on watchers, force=true bypasses, no-watchers preserves happy path

Out of scope / follow-ups

  • Adjacent surfaces (automations, office_routines, office_channels, office_agent_memory) also store agent_profile_id and could be orphaned by the same reconciler. Their dispatch paths differ enough to warrant separate design — filing as a follow-up.
  • Frontend dialog rendering of the new watchers array in the 409 response. The backend wire format is in place; the React component update is a separate PR.

@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented May 26, 2026

Review Change Stack

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Detects soft-deleted agent profiles, stamps and disables referencing watches across GitHub/Jira/Linear during dispatch pre-flight or forced profile deletion, persists error metadata in watch rows, enumerates watcher dependencies for the deletion UI, and wires these adapters at orchestrator startup.

Changes

Watcher Self-Heal on Soft-Deleted Profiles

Layer / File(s) Summary
Soft-delete sentinel, store API & resolver
apps/backend/internal/agent/settings/store/errors.go, apps/backend/internal/agent/settings/store/store.go, apps/backend/internal/agent/settings/store/sqlite.go, apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go, apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go
Adds ErrAgentProfileDeleted, GetAgentProfileIncludingDeleted, DeletedProfileError, and updates resolver to return typed error when profile exists but is soft-deleted.
SQLite soft-delete tests & resolver tests
apps/backend/internal/agent/settings/store/sqlite_soft_delete_test.go, apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go
Repository- and resolver-level tests verify hidden soft-deleted rows, including typed error surface and DeletedAt propagation.
Watch model fields and DB migrations
apps/backend/internal/github/models.go, apps/backend/internal/jira/models.go, apps/backend/internal/linear/models.go, apps/backend/internal/github/store.go, apps/backend/internal/jira/store.go, apps/backend/internal/linear/store.go
Add LastError/LastErrorAt to watch models, update table SQL and init migrations to add columns idempotently.
Store Disable*WithError and Service methods
apps/backend/internal/github/store.go, apps/backend/internal/jira/store.go, apps/backend/internal/linear/store_issue_watch.go, apps/backend/internal/github/service.go
Implement DisableIssueWatchWithError / DisableReviewWatchWithError store methods to set enabled = 0 and stamp error metadata; add service-layer forwarders.
Store disable tests
apps/backend/internal/github/store_watch_disable_test.go, apps/backend/internal/jira/store_issue_watch_disable_test.go, apps/backend/internal/linear/store_issue_watch_disable_test.go
Tests assert watches are disabled and error cause + timestamp are persisted within expected windows.
Controller deletion dependency checks
apps/backend/internal/agent/settings/controller/controller.go, apps/backend/internal/agent/settings/controller/profile_crud.go, apps/backend/internal/agent/settings/controller/profile_crud_watcher_deps_test.go, apps/backend/internal/agent/settings/handlers/handlers.go
Adds WatcherDependencyChecker and WatcherReference, expands ErrProfileInUseDetail to include watchers, prepareProfileDeletion consults watcher refs (unless forced), and handler includes watchers in 409 response.
Orchestrator pre-flight & ProfileLookup
apps/backend/internal/orchestrator/watcher_dispatch.go, apps/backend/internal/orchestrator/watcher_dispatch_selfheal_test.go, apps/backend/internal/orchestrator/watcher_dispatch_test.go
Add ProfileLookup interface, coordinator wiring SetProfileLookup, extend WatcherSource with AgentProfileID/SelfHeal, and short-circuit dispatch to call SelfHeal when profile is soft-deleted. Tests cover deleted/healthy/error/empty cases and cause formatting/truncation.
Integration source implementations & event handlers
apps/backend/internal/orchestrator/source_jira.go, apps/backend/internal/orchestrator/source_linear.go, apps/backend/internal/orchestrator/event_handlers_github.go, apps/backend/internal/orchestrator/event_handlers_jira.go, apps/backend/internal/orchestrator/event_handlers_linear.go, tests/mocks under internal/orchestrator
Add AgentProfileID extraction and SelfHeal implementations that call integration Disable*WithError (nil-safe). Update event handlers to run preflight and return early when self-healed. Mocks capture disable calls for assertions.
Orchestrator wiring and startup adapters
apps/backend/internal/orchestrator/service.go, apps/backend/internal/orchestrator/watcher_dispatch_wiring.go, apps/backend/cmd/kandev/orchestrator.go, apps/backend/cmd/kandev/main.go
Service gains profileLookup and SetProfileLookup propagation; add profileLookupAdapter and watcherDepsAdapter and wire them at startup to support dispatch pre-flight and deletion UI watcher enumeration.
Web UI: conflict payload, dialog, page, types
apps/web/app/actions/agents.ts, apps/web/components/settings/agent-profile-delete-dialog.tsx, apps/web/components/settings/agent-profile-delete-dialog.test.tsx, apps/web/components/settings/agent-profile-page.tsx, apps/web/lib/types/agent-profile-errors.ts
Frontend DeleteProfileResult and dialog now include watchers; dialog groups and displays watcher refs; page state updated to use combined conflict object; WatcherReference type added.

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

  • kdlbs/kandev#596: Related — earlier changes to the profile-delete dialog and conflict handling.
  • kdlbs/kandev#1070: Related — work touching WatcherDispatchCoordinator/WatcherSource pipeline extended here.
  • kdlbs/kandev#672: Related — introduced createIssueTask flow that this PR preflights/short-circuits.

Suggested labels: codex

"I nibble logs and patch with carrot cheer,
Soft-deleted profiles make watchers disappear,
Errors get stamped and disabled with care,
No orphaned tasks left hanging in the air.
Hop, hop — the rabbit approves this repair!"

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 54.35% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Title check ✅ Passed The PR title accurately summarizes the main change: self-healing orphaned watchers when an agent profile is soft-deleted, which is the core objective of the changeset.
Description check ✅ Passed The PR description is comprehensive and well-structured, covering the summary, behaviour changes, architecture explanation with diagrams, test plan with execution results, and follow-ups. All required template sections are present and complete.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 26, 2026

Greptile Summary

This PR makes the watcher dispatch pipeline self-healing when an agent profile is soft-deleted: the coordinator pre-flight (preflightDeletedProfile) now short-circuits before task creation and calls SelfHeal on the bound watcher source, and the force-delete path eagerly disables referencing watchers. A new GetAgentProfileIncludingDeleted store method and typed *DeletedProfileError disambiguate "soft-deleted" from "never existed". The 409 profile-delete response now carries a watchers array alongside active_sessions so the confirmation dialog can enumerate affected watchers.

  • Backend: WatcherDispatchCoordinator gains a ProfileLookup pre-flight (Linear/Jira path); legacy GitHub createIssueTask/createReviewTask paths get their own equivalent pre-flight helpers. Four watcher tables gain last_error/last_error_at columns via idempotent migrations; DisableXWithError methods stamp both on self-heal.
  • Settings delete flow: prepareProfileDeletion enumerates enabled referencing watchers via the new WatcherDependencyChecker interface; force-delete eagerly calls DisableWatchersByAgentProfile after the row is gone so the UI reflects the change immediately without waiting for a poll.
  • Frontend: AgentProfileDeleteConflictDialog is refactored to accept a unified AgentProfileDeleteConflict shape and renders a grouped watchers section, but the static footer text needs to be made conditional (watcher-only conflicts show "These sessions will no longer…" with no sessions listed).

Confidence Score: 4/5

Safe to merge with one fix: the confirmation dialog for force-delete shows misleading text in the new watcher-only conflict case introduced by this PR.

The backend self-heal pipeline is well-structured: the coordinator pre-flight correctly fails open on lookup errors, the GitHub legacy paths get the same check via their own helpers, migrations are idempotent, and the locking issues flagged in the previous review round are addressed. The one active defect is in the confirmation dialog — a watcher-only conflict (no active sessions) renders "These sessions will no longer be able to use this profile" with no sessions listed in the dialog, and a session-only conflict redundantly says "the listed watchers will be disabled." Both directions of the mismatch appear for the first time in this PR since watcher-only conflicts are a new code path.

apps/web/components/settings/agent-profile-delete-dialog.tsx — the static footer text needs to be made conditional on which conflict types are actually present.

Important Files Changed

Filename Overview
apps/backend/internal/orchestrator/watcher_dispatch.go Adds ProfileLookup interface, preflightDeletedProfile, SelfHeal to WatcherSource, and the formatDeletedProfileCause helper — the core self-heal pipeline. Minor: formatDeletedProfileCause uses string concatenation instead of fmt.Sprintf %q, producing malformed output for profile names with embedded double quotes.
apps/backend/internal/orchestrator/watcher_dispatch_wiring.go Renames initWatcherCoordinator to initWatcherCoordinatorLocked and holds s.mu across SetIssueTaskCreator, fixing the previously flagged unsynchronised access. Adds SetProfileLookup, getProfileLookup, and GitHub-specific preflight helpers with correct lock ordering (s.mu → c.mu, never nested in reverse).
apps/web/components/settings/agent-profile-delete-dialog.tsx Expands AgentProfileDeleteConflictDialog to accept the new AgentProfileDeleteConflict shape and renders a watchers section. The static footer text is always shown even in watcher-only or session-only conflict scenarios.
apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go Adds DeletedProfileError and checkSoftDeleted helper; ResolveProfile now disambiguates soft-deleted from never-existed via a secondary GetAgentProfileIncludingDeleted lookup. Logic is sound and correctly fail-opens on non-ErrNoRows errors.
apps/backend/internal/agent/settings/controller/profile_crud.go Extends prepareProfileDeletion to enumerate referencing watchers and returns ErrProfileInUseDetail with both ActiveSessions and Watchers; adds disableReferencingWatchers for the force-delete eager path.
apps/backend/cmd/kandev/orchestrator.go Adds profileLookupAdapter, watcherDepsAdapter (lists/disables watchers across all four integrations), and wiring for WatcherDependencyChecker. All integration fields are nil-safe; DisableWatchersByAgentProfile is best-effort across integrations and logs per-failure.
apps/backend/internal/github/store.go Adds addWatchSelfHealColumns (column-precheck ALTERs for both watch tables), tableColumns helper, and DisableIssueWatchWithError / DisableReviewWatchWithError. Uses fail-loud errors for self-heal columns as documented.
apps/backend/internal/linear/store_issue_watch.go Splits issueWatchColumns into insert/select variants (select uses COALESCE for last_error), adds LastError/LastErrorAt to issueWatchRow and IssueWatch, and implements DisableIssueWatchWithError.
apps/backend/internal/jira/store.go Mirrors linear store changes: column-precheck migrations, split insert/select columns with COALESCE, and DisableIssueWatchWithError. Consistent with the established Jira store migration idiom.
apps/backend/internal/agent/settings/store/sqlite.go Extracts agentProfileSelectColumns constant shared across all read paths, adds GetAgentProfileIncludingDeleted alongside GetAgentProfile.
apps/web/app/actions/agents.ts Expands DeleteProfileResult conflict shape with watchers array; 409 detection now triggers on either active_sessions or watchers being present in the response body.

Sequence Diagram

sequenceDiagram
    participant Bus
    participant Coord as WatcherDispatchCoordinator
    participant Lookup as ProfileLookup
    participant Src as WatcherSource (Linear/Jira)
    participant Store as Watcher Store
    participant GH as Service (GitHub legacy)

    Bus->>Coord: Dispatch(evt)
    Coord->>Lookup: LookupProfile(agentProfileID)
    alt profile soft-deleted
        Lookup-->>Coord: (true, name, nil)
        Coord->>Src: SelfHeal(evt, cause)
        Src->>Store: DisableIssueWatchWithError(id, cause)
        Store-->>Src: ok
        Coord-->>Bus: return (no task created)
    else profile live or lookup error
        Lookup-->>Coord: (false, _, nil) or (_, _, err)
        Coord->>Src: Reserve → BuildTaskRequest → CreateTask → AttachTaskID
    end

    Bus->>GH: createIssueTask(evt)
    GH->>Lookup: LookupProfile(evt.AgentProfileID)
    alt profile soft-deleted
        Lookup-->>GH: (true, name, nil)
        GH->>Store: DisableIssueWatchWithError(watchID, cause)
        GH-->>Bus: return
    else live
        GH->>Src: reserveIssueWatch → createTask → …
    end
Loading

Reviews (10): Last reviewed commit: "fix(e2e): unwrap {watches} envelope in l..." | Re-trigger Greptile

Comment thread apps/backend/internal/agent/settings/store/store.go Outdated
Comment thread apps/backend/internal/orchestrator/watcher_dispatch_wiring.go Outdated
Comment thread apps/backend/internal/github/store.go Outdated
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@apps/backend/internal/agent/settings/controller/reconciler_test.go`:
- Around line 120-121: The test fake fakeStore currently returns (nil, nil) from
GetAgentProfileIncludingDeleted which hides broken call paths; update
fakeStore.GetAgentProfileIncludingDeleted to return realistic semantics by
returning a valid *models.AgentProfile (with minimal required fields) and nil
error for known/test IDs and returning a not-found error (or
repository-equivalent error like sql.ErrNoRows or a declared ErrNotFound) for
unknown IDs so callers can exercise both success and failure paths.

In `@apps/backend/internal/linear/store_issue_watch_disable_test.go`:
- Around line 30-34: The assertion that watch.LastErrorAt lies between before :=
time.Now().UTC() and after := time.Now().UTC() is flaky due to DB timestamp
precision; modify the test around DisableIssueWatchWithError (and the similar
check at lines 52-53) to normalize precision or allow a small tolerance: either
truncate before/after and watch.LastErrorAt to time.Second (or the DB precision)
before comparing, or assert LastErrorAt is within ±1 second of the expected
window so the check is stable across different DB timestamp resolutions.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro

Run ID: 1175cea5-70b4-4488-af3c-7a8daaec2506

📥 Commits

Reviewing files that changed from the base of the PR and between 23dab31 and 586736c.

📒 Files selected for processing (39)
  • apps/backend/cmd/kandev/main.go
  • apps/backend/cmd/kandev/orchestrator.go
  • apps/backend/internal/agent/runtime/lifecycle/profile_resolver.go
  • apps/backend/internal/agent/runtime/lifecycle/profile_resolver_test.go
  • apps/backend/internal/agent/settings/controller/controller.go
  • apps/backend/internal/agent/settings/controller/profile_crud.go
  • apps/backend/internal/agent/settings/controller/profile_crud_watcher_deps_test.go
  • apps/backend/internal/agent/settings/controller/reconciler_test.go
  • apps/backend/internal/agent/settings/handlers/handlers.go
  • apps/backend/internal/agent/settings/store/errors.go
  • apps/backend/internal/agent/settings/store/sqlite.go
  • apps/backend/internal/agent/settings/store/sqlite_soft_delete_test.go
  • apps/backend/internal/agent/settings/store/store.go
  • apps/backend/internal/github/models.go
  • apps/backend/internal/github/service.go
  • apps/backend/internal/github/store.go
  • apps/backend/internal/github/store_watch_disable_test.go
  • apps/backend/internal/jira/models.go
  • apps/backend/internal/jira/store.go
  • apps/backend/internal/jira/store_issue_watch_disable_test.go
  • apps/backend/internal/linear/models.go
  • apps/backend/internal/linear/store.go
  • apps/backend/internal/linear/store_issue_watch.go
  • apps/backend/internal/linear/store_issue_watch_disable_test.go
  • apps/backend/internal/orchestrator/event_handlers_github.go
  • apps/backend/internal/orchestrator/event_handlers_github_selfheal_test.go
  • apps/backend/internal/orchestrator/event_handlers_github_test.go
  • apps/backend/internal/orchestrator/event_handlers_jira.go
  • apps/backend/internal/orchestrator/event_handlers_jira_test.go
  • apps/backend/internal/orchestrator/event_handlers_linear.go
  • apps/backend/internal/orchestrator/service.go
  • apps/backend/internal/orchestrator/source_jira.go
  • apps/backend/internal/orchestrator/source_jira_test.go
  • apps/backend/internal/orchestrator/source_linear.go
  • apps/backend/internal/orchestrator/source_linear_test.go
  • apps/backend/internal/orchestrator/watcher_dispatch.go
  • apps/backend/internal/orchestrator/watcher_dispatch_selfheal_test.go
  • apps/backend/internal/orchestrator/watcher_dispatch_test.go
  • apps/backend/internal/orchestrator/watcher_dispatch_wiring.go

Comment thread apps/backend/internal/agent/settings/controller/reconciler_test.go Outdated
Comment thread apps/backend/internal/linear/store_issue_watch_disable_test.go Outdated
Copy link
Copy Markdown

@cubic-dev-ai cubic-dev-ai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

5 issues found across 39 files

Reply with feedback, questions, or to request a fix.

Re-trigger cubic

Comment thread apps/backend/internal/agent/settings/store/sqlite.go
Comment thread apps/backend/internal/github/store.go Outdated
Comment thread apps/backend/internal/agent/settings/store/store.go Outdated
Comment thread apps/backend/internal/orchestrator/watcher_dispatch_wiring.go Outdated
Comment thread apps/backend/internal/github/store_watch_disable_test.go Outdated
nlenepveu added a commit to blackfuel-ai/kandev that referenced this pull request May 26, 2026
…y check

watcherDepsAdapter previously called ListAll*IssueWatches and counted every
row including already-disabled ones. After the self-heal pre-flight starts
running, watchers will routinely sit in enabled=0 state — those should not
trigger a 409 confirmation prompt when the user deletes the orphaned profile
that originally bound them.

Switch to ListEnabled* in all three adapters (linear, jira, github). Adds
ListEnabledIssueWatches / ListEnabledReviewWatches shims on github.Service
to mirror the store-level methods (linear and jira already exposed them).

Spotted by Greptile on PR kdlbs#1094 cycle 4.
Copy link
Copy Markdown
Member

@carlosflorencio carlosflorencio left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Reviewed end to end. The self-heal logic is solid: preflight runs before Reserve (so a deleted-profile event never leaves a stale dedup row), the typed *DeletedProfileError is consumed via errors.Is/errors.As everywhere it matters, all three disable-watcher store tests already use a ±1-second window so the timestamp-precision flake CodeRabbit raised earlier is mitigated, and the 409 watchers field is additive on the wire. Test coverage matches the body checklist case-for-case.

Four small things worth doing before merge, none of them blockers, all left as inline comments:

  1. github/store.go swallows real migration errors with _, _ =. The new last_error / last_error_at columns are the only ones whose readers panic on scan if they end up missing, so the fail-loud guard the Jira/Linear stores use is worth borrowing.
  2. GetAgentProfileIncludingDeleted duplicates the 35-column SELECT projection from GetAgentProfile. Worth extracting as a const so the next column add can't drift between them.
  3. s.watcherCoordinator and s.issueTaskCreator are written without the mutex that SetProfileLookup carefully added in 0f4824d. Production is safe because both setters run at boot, but the asymmetry will catch out a future test under -race.
  4. The new GitHub preflight logger calls concatenate kind into the message string instead of passing it as a zap field, which fragments log aggregation.

(Skipped CodeRabbit's reconciler_test.go fakeStore finding because it's wrong: the fake delegates to GetAgentProfile and returns sql.ErrNoRows on miss, not (nil, nil).)

Nice work overall, looking forward to seeing the follow-up that covers the adjacent surfaces (automations, office_routines, etc.) you called out at the bottom.


Generated by Claude Code

Comment thread apps/backend/internal/github/store.go Outdated
Comment on lines +193 to +196
_, _ = s.db.Exec(`ALTER TABLE github_review_watches ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`)
_, _ = s.db.Exec(`ALTER TABLE github_review_watches ADD COLUMN last_error_at DATETIME`)
_, _ = s.db.Exec(`ALTER TABLE github_issue_watches ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`)
_, _ = s.db.Exec(`ALTER TABLE github_issue_watches ADD COLUMN last_error_at DATETIME`)
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

These four ALTER TABLE calls drop every error on the floor with _, _ =. I know the surrounding initSchema already does this, but the new columns are the only ones where the reader side panics if they end up missing: models.IssueWatch.LastError / LastErrorAt get scanned unconditionally, so a driver-level failure here (lock contention, FS permissions, disk pressure, etc.) turns into a confusing scan panic on the next poll instead of a clear boot error.

The sibling stores already solve this. apps/backend/internal/jira/store.go:116-132 and apps/backend/internal/linear/store.go:107 use a tableColumns() precheck so a fresh install skips the ALTER and only real failures bubble up. Worth doing the same here:

Suggested change
_, _ = s.db.Exec(`ALTER TABLE github_review_watches ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`)
_, _ = s.db.Exec(`ALTER TABLE github_review_watches ADD COLUMN last_error_at DATETIME`)
_, _ = s.db.Exec(`ALTER TABLE github_issue_watches ADD COLUMN last_error TEXT NOT NULL DEFAULT ''`)
_, _ = s.db.Exec(`ALTER TABLE github_issue_watches ADD COLUMN last_error_at DATETIME`)
if err := s.addWatchSelfHealColumns(); err != nil {
return err
}

with a helper alongside the other migration helpers in this file:

func (s *Store) addWatchSelfHealColumns() error {
	for _, table := range []string{"github_review_watches", "github_issue_watches"} {
		cols, err := s.tableColumns(table)
		if err != nil {
			return fmt.Errorf("read %s columns: %w", table, err)
		}
		if _, ok := cols["last_error"]; !ok {
			if _, err := s.db.Exec("ALTER TABLE " + table + " ADD COLUMN last_error TEXT NOT NULL DEFAULT ''"); err != nil {
				return fmt.Errorf("add %s.last_error: %w", table, err)
			}
		}
		if _, ok := cols["last_error_at"]; !ok {
			if _, err := s.db.Exec("ALTER TABLE " + table + " ADD COLUMN last_error_at DATETIME"); err != nil {
				return fmt.Errorf("add %s.last_error_at: %w", table, err)
			}
		}
	}
	return nil
}

github.Store doesn't have a tableColumns helper yet, so you'd need to copy the small one from jira/store.go:264 over. Cheap, and it pays off the moment the next ALTER lands.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 07fdf0d — added tableColumns + addWatchSelfHealColumns to github/store.go and replaced the four _, _ = ALTER calls. Idempotent precheck on github_review_watches + github_issue_watches; mirrors the jira/linear shape, with the boot now failing loud rather than silently dropping a driver error that the unconditional readers would later turn into a scan panic.

Comment on lines +761 to +785
func (r *sqliteRepository) GetAgentProfileIncludingDeleted(ctx context.Context, id string) (*models.AgentProfile, error) {
row := r.ro.QueryRowContext(ctx, r.ro.Rebind(`
SELECT id, agent_id, name, agent_display_name, model, mode, migrated_from,
auto_approve, dangerously_skip_permissions, allow_indexing,
cli_passthrough, user_modified, plan, cli_flags,
COALESCE(env_vars, '[]'),
created_at, updated_at, deleted_at,
COALESCE(workspace_id, ''), COALESCE(role, ''), COALESCE(icon, ''),
COALESCE(reports_to, ''), COALESCE(skill_ids, '[]'),
COALESCE(desired_skills, '[]'), COALESCE(custom_prompt, ''),
COALESCE(status, 'idle'), COALESCE(pause_reason, ''),
last_run_finished_at,
COALESCE(max_concurrent_sessions, 1), COALESCE(cooldown_sec, 0),
COALESCE(skip_idle_runs, 0), COALESCE(consecutive_failures, 0),
COALESCE(failure_threshold, 3), COALESCE(executor_preference, ''),
COALESCE(budget_monthly_cents, 0),
COALESCE(settings, '{}'), COALESCE(permissions, '{}')
FROM agent_profiles WHERE id = ?
`), id)
profile, err := scanAgentProfile(row)
if err != nil {
return nil, err
}
return r.applyLegacyBackfill(ctx, profile), nil
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This SELECT projection is byte-for-byte identical to GetAgentProfile at lines 732-756 except for the trailing AND deleted_at IS NULL. The next column added to agent_profiles will need to land in both spots, and if it only makes it into one, the soft-delete path will silently scan zero values for that field, which is exactly the kind of bug this PR is trying to prevent in a different layer.

The ListAgentProfiles query right below at 787 has the same shape too. A const extracted once would cover all three:

const agentProfileSelectColumns = `
	SELECT id, agent_id, name, agent_display_name, model, mode, migrated_from,
		auto_approve, dangerously_skip_permissions, allow_indexing,
		cli_passthrough, user_modified, plan, cli_flags,
		COALESCE(env_vars, '[]'),
		created_at, updated_at, deleted_at,
		COALESCE(workspace_id, ''), COALESCE(role, ''), COALESCE(icon, ''),
		COALESCE(reports_to, ''), COALESCE(skill_ids, '[]'),
		COALESCE(desired_skills, '[]'), COALESCE(custom_prompt, ''),
		COALESCE(status, 'idle'), COALESCE(pause_reason, ''),
		last_run_finished_at,
		COALESCE(max_concurrent_sessions, 1), COALESCE(cooldown_sec, 0),
		COALESCE(skip_idle_runs, 0), COALESCE(consecutive_failures, 0),
		COALESCE(failure_threshold, 3), COALESCE(executor_preference, ''),
		COALESCE(budget_monthly_cents, 0),
		COALESCE(settings, '{}'), COALESCE(permissions, '{}')
	FROM agent_profiles`

Then GetAgentProfile calls Rebind(agentProfileSelectColumns + " WHERE id = ? AND deleted_at IS NULL") and this method calls Rebind(agentProfileSelectColumns + " WHERE id = ?"). Touching ListAgentProfiles too is optional, but folding it in keeps all three in lockstep going forward.

cubic flagged this one too.


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 07fdf0d — extracted agentProfileSelectColumns const at the top of internal/agent/settings/store/sqlite.go. All three call sites (GetAgentProfile, GetAgentProfileIncludingDeleted, ListAgentProfiles) now use Rebind(agentProfileSelectColumns + " WHERE ..."). Next column add lands in one spot.

Comment on lines +95 to +122
func (s *Service) preflightDeletedProfileForGitHub(
ctx context.Context, kind, profileID, watchID string,
disable func(ctx context.Context, watchID, cause string) error,
) bool {
lookup := s.getProfileLookup()
if lookup == nil || profileID == "" {
return false
}
deleted, name, err := lookup.LookupProfile(ctx, profileID)
if err != nil {
s.logger.Warn("github "+kind+": profile lookup failed, falling through",
zap.String("profile_id", profileID), zap.Error(err))
return false
}
if !deleted {
return false
}
cause := formatDeletedProfileCause(profileID, name)
s.logger.Warn("github "+kind+": agent profile soft-deleted, self-healing watcher",
zap.String("watch_id", watchID),
zap.String("profile_id", profileID),
zap.String("profile_name", name))
if disable != nil {
if err := disable(ctx, watchID, cause); err != nil {
s.logger.Error("github "+kind+": self-heal disable failed",
zap.String("watch_id", watchID), zap.Error(err))
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Tiny one: the three log calls below concatenate kind into the message string ("github "+kind+": ..."). That gives the log aggregator two separate message strings to group on ("github issue: ..." and "github review: ...") instead of one filterable family. Most of the orchestrator passes that kind of axis as a structured zap field instead.

Suggested change
func (s *Service) preflightDeletedProfileForGitHub(
ctx context.Context, kind, profileID, watchID string,
disable func(ctx context.Context, watchID, cause string) error,
) bool {
lookup := s.getProfileLookup()
if lookup == nil || profileID == "" {
return false
}
deleted, name, err := lookup.LookupProfile(ctx, profileID)
if err != nil {
s.logger.Warn("github "+kind+": profile lookup failed, falling through",
zap.String("profile_id", profileID), zap.Error(err))
return false
}
if !deleted {
return false
}
cause := formatDeletedProfileCause(profileID, name)
s.logger.Warn("github "+kind+": agent profile soft-deleted, self-healing watcher",
zap.String("watch_id", watchID),
zap.String("profile_id", profileID),
zap.String("profile_name", name))
if disable != nil {
if err := disable(ctx, watchID, cause); err != nil {
s.logger.Error("github "+kind+": self-heal disable failed",
zap.String("watch_id", watchID), zap.Error(err))
}
}
func (s *Service) preflightDeletedProfileForGitHub(
ctx context.Context, kind, profileID, watchID string,
disable func(ctx context.Context, watchID, cause string) error,
) bool {
lookup := s.getProfileLookup()
if lookup == nil || profileID == "" {
return false
}
deleted, name, err := lookup.LookupProfile(ctx, profileID)
if err != nil {
s.logger.Warn("github watcher: profile lookup failed, falling through",
zap.String("kind", kind),
zap.String("profile_id", profileID),
zap.Error(err))
return false
}
if !deleted {
return false
}
cause := formatDeletedProfileCause(profileID, name)
s.logger.Warn("github watcher: agent profile soft-deleted, self-healing",
zap.String("kind", kind),
zap.String("watch_id", watchID),
zap.String("profile_id", profileID),
zap.String("profile_name", name))
if disable != nil {
if err := disable(ctx, watchID, cause); err != nil {
s.logger.Error("github watcher: self-heal disable failed",
zap.String("kind", kind),
zap.String("watch_id", watchID),
zap.Error(err))
}
}
return true
}

Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 07fdf0dkind is now passed as zap.String("kind", kind) in all three log calls. Message strings collapse to "github watcher: profile lookup failed, falling through" / "github watcher: agent profile soft-deleted, self-healing" / "github watcher: self-heal disable failed" so the aggregator groups them as one filterable family.

Comment on lines +43 to +46
if lookup := s.getProfileLookup(); lookup != nil {
s.watcherCoordinator.SetProfileLookup(lookup)
}
}
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A bit of an asymmetric locking situation here that's worth tightening. Commit 0f4824d correctly put s.profileLookup behind s.mu (read in getProfileLookup, write in SetProfileLookup). But s.watcherCoordinator and s.issueTaskCreator are still written without the lock, both in this initWatcherCoordinator body and in SetIssueTaskCreator over at event_handlers_github.go:133-136. Meanwhile SetProfileLookup reads s.watcherCoordinator under the lock and dispatchWatcherEvent (line 161 below) reads it with no lock at all.

In production both setters run sequentially at boot before any bus handlers fire, so today it's fine. But the comment on lines 26-31 explicitly says tests swap creators between scenarios, and that's the path -race will eventually catch.

Suggested fix: take s.mu.Lock() around the whole body here, and have SetIssueTaskCreator set s.issueTaskCreator under the same lock before calling this. Inside the locked section, read s.profileLookup directly instead of calling getProfileLookup() (which re-takes the read lock and would deadlock).


Generated by Claude Code

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 07fdf0d — renamed to initWatcherCoordinatorLocked (callers MUST hold s.mu write). SetIssueTaskCreator now takes the lock, writes s.issueTaskCreator, then calls the locked variant. Inside the locked section we read s.profileLookup directly (no getProfileLookup() re-entry deadlock). dispatchWatcherEvent reads both fields via new getIssueTaskCreator() / getWatcherCoordinator() RLock helpers. Race-detector tests still pass.

"error": "agent profile is used by active session(s)",
"error": "agent profile is used by active session(s) or watcher(s)",
"active_sessions": inUseErr.ActiveSessions,
"watchers": inUseErr.Watchers,
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The backend now returns watchers in the 409 response, but the frontend delete flow still only models active sessions. deleteAgentProfileAction only returns { status: "conflict", activeSessions }, and AgentProfileDeleteConflictDialog only renders sessions.

That means the new backend behavior does not deliver the intended confirmation UX for the main new case: “this profile is referenced by watchers, continue?” A watcher-only conflict can become a generic failed-delete path or a dialog without any watcher details, depending on the response shape.

Can we carry watchers through DeleteProfileResult, add a WatcherReference type, and render them in AgentProfileDeleteConflictDialog alongside active sessions? A small frontend test for a watcher-only 409 would cover the regression.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 07fdf0d (+ formatting in e31bfaa). Threaded watchers through: new WatcherReference type in agent-profile-errors.ts, DeleteProfileResult carries both lists, AgentProfileDeleteConflict carrier prop on AgentProfileDeleteConflictDialog renders watchers grouped by integration kind (Linear / Jira / GitHub Issues / GitHub PR Reviews). 409 trigger now fires on either active_sessions OR watchers non-empty so a watcher-only conflict pops the dialog. New agent-profile-delete-dialog.test.tsx covers all four shapes (watcher-only / sessions-only / combined / null).

// then cleans up ephemeral tasks. Returns *ErrProfileInUseDetail when force
// is false and any active session OR referencing watcher exists — the UI uses
// the breakdown to render a confirmation dialog. Force=true skips both checks
// and lets the dispatch coordinator's pre-flight handle the watcher self-heal
Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This force-delete path does not actually disable the referencing watchers. It only skips the dependency check and relies on “the dispatch coordinator’s pre-flight handle the watcher self-heal on its next poll,” but that pre-flight only runs after a watcher emits a new unseen issue/PR event.

If the watcher has no new matching external item after the profile is deleted, it stays enabled indefinitely while pointing at a soft-deleted profile. That leaves the UI and stored watcher state inconsistent with the confirmation copy.

I think the forced delete path should disable the currently referencing watchers immediately, using the same Disable*WithError methods added in this PR, and stamp the deleted-profile cause there. The self-heal preflight can remain as a fallback for profiles deleted by reconciler/other paths.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 07fdf0dWatcherDependencyChecker grew DisableWatchersByAgentProfile(ctx, profileID, cause). When prepareProfileDeletion runs with force=true, the new disableReferencingWatchers helper invokes it and logs the disable count. The cmd/kandev adapter dispatches to the per-integration Disable*WithError store methods (linear / jira / github_issue / github_review). The lazy preflight stays as the safety net for reconciler-driven deletes that don't pass through this controller. Test covers the force-path eager disable.

@carlosflorencio
Copy link
Copy Markdown
Member

Thanks for working on this! Added some comments to further improve the fix 💪

@nlenepveu
Copy link
Copy Markdown
Contributor Author

Thanks for the thorough review @carlosflorencio — your verdict checklist is exactly the maintainer bar I was hoping to hit. Folding all six findings into this PR:

  • (1) github migration precheck — adding tableColumns + addWatchSelfHealColumns to mirror the jira/linear idiom. The reader-side scan panic is exactly why these new columns deserve the fail-loud treatment even though their cleanup_policy neighbour gets the silent-swallow.
  • (2) agentProfileSelectColumns const — extracting once; covers GetAgentProfile, GetAgentProfileIncludingDeleted, and ListAgentProfiles in lockstep.
  • (3) zap field for kind — applying your suggestion verbatim. Agreed on the log-aggregation argument.
  • (4) lock symmetry — wrapping initWatcherCoordinator body under s.mu, moving the issueTaskCreator assignment under the same lock in SetIssueTaskCreator, reading profileLookup directly inside the locked section (avoids the read-lock re-entry deadlock you flagged).
  • (5) frontend dialog — pulling into this PR rather than deferring. Threading watchers through deleteAgentProfileAction + AgentProfileDeleteConflictDialog + adding the watcher-only 409 frontend test.
  • (6) force-delete eager disable — calling Disable*WithError directly in prepareProfileDeletion for any watchers we just enumerated, with the deleted-profile cause. The lazy preflight stays as the safety net for reconciler-driven deletes.

Pushing the round shortly.

nlenepveu added a commit to blackfuel-ai/kandev that referenced this pull request May 27, 2026
- github/store: replace silent _, _ = ALTER calls for the new last_error /
  last_error_at columns with a column-precheck (mirrors jira/linear). These
  are the only columns whose readers scan unconditionally — a driver-level
  failure would turn into a confusing scan panic instead of a clear boot
  error. New tableColumns helper.
- agent/settings/store: extract agentProfileSelectColumns const so
  GetAgentProfile / GetAgentProfileIncludingDeleted / ListAgentProfiles
  stay in lockstep on future column additions.
- orchestrator: pass kind as a zap.String field instead of concatenating
  into the log message; log aggregator groups "github watcher: ..." as one
  family with kind on the axis.
- orchestrator: tighten lock symmetry. SetIssueTaskCreator now holds s.mu
  while writing s.issueTaskCreator and (re)initialising the coordinator.
  initWatcherCoordinatorLocked reads s.profileLookup directly (avoids
  read-lock re-entry deadlock). dispatchWatcherEvent uses lock-aware
  reads for s.issueTaskCreator and s.watcherCoordinator.
- agent/settings/controller: force-delete now eagerly disables every
  referencing watcher with the deletion cause via new
  DisableWatchersByAgentProfile on WatcherDependencyChecker, instead of
  relying on the lazy preflight which never fires for filters that don't
  match new external events.
- web: thread watchers through the 409 conflict response. New
  WatcherReference type, AgentProfileDeleteConflict carrier, dialog renders
  watcher rows grouped by integration kind. Frontend test covers
  watcher-only, sessions-only, combined, and null-conflict shapes.
@carlosflorencio
Copy link
Copy Markdown
Member

There are some conflicts now, let me know if you want me to address them. Nice work 💪

@carlosflorencio
Copy link
Copy Markdown
Member

I went back through this carefully and checked each of the six change requests against the current head. All six are genuinely addressed:

  • the ALTER errors are now handled via addWatchSelfHealColumns with a column precheck
  • the duplicated SELECT is extracted into agentProfileSelectColumns
  • the log now uses zap.String for kind instead of string concatenation
  • the locking is fixed with initWatcherCoordinatorLocked plus the RLock helpers
  • the 409 now threads watchers through WatcherReference into the delete dialog
  • force delete disables referencing watchers via DisableWatchersByAgentProfile

Test coverage is good. The only thing left is the rebase you already mentioned, since the branch is still conflicting with main and the failing E2E shards are tied to the stale base. Once it is rebased and CI is green this is good to go.

Two minor optional notes: the eager-disable last_error string omits the profile name while the lazy path includes it, and force delete disables watchers just before the row delete, so a failed delete would leave them disabled. Both are low risk.

nlenepveu added 2 commits May 31, 2026 18:29
…deleted

When the reconciler soft-deletes an agent profile, watchers bound to it
previously looped forever on "profile not found". The dispatch pipeline now
self-heals: a coordinator/GitHub pre-flight detects the soft-deleted profile,
disables the watcher with a stamped last_error, and short-circuits — no
dead-on-arrival task, no dedup reservation. The 409 delete response carries the
referencing watchers so the UI can confirm, and force-delete disables them
eagerly.

Folds in all PR kdlbs#1094 review feedback (CodeRabbit, cubic, greptile, maintainer).
Two low-risk follow-ups from the PR kdlbs#1094 maintainer review:

- Eager force-delete cause now includes the (truncated) profile name via a
  formatDeletedProfileCause helper, matching the orchestrator preflight's
  cause shape — the settings banner shows "Kilo Profile" not a bare UUID.
- Disable referencing watchers AFTER the row delete succeeds, not before, so
  a failed delete never strands watchers disabled against a still-live
  profile. The dispatch preflight remains the fallback if the post-delete
  disable itself fails.
@nlenepveu nlenepveu force-pushed the feature/fix-linear-watcher-f-hc7 branch from b67df82 to ae22ef0 Compare May 31, 2026 16:39
@nlenepveu
Copy link
Copy Markdown
Contributor Author

Rebased onto latest main and resolved all conflicts — the branch is now MERGEABLE again. The conflicts were all in the watcher/integration files where upstream's max_inflight_tasks throttle work (#1021) had landed in the same structs/migrations/SQL this PR touches; both features are integrated side-by-side:

  • jira/linear stores + models: max_inflight_tasks and last_error/last_error_at now coexist in the struct, the insert/select column split, and the idempotent migrations (addMaxInflightTasksColumn + addIssueWatchLastErrorColumns both run).
  • github/service.go: upstream split it into service_issues.go / service_reviews.go; moved the four new wrapper methods (ListEnabled*Watches, Disable*WithError) into the right split files.
  • WatcherSource interface: now carries upstream's WatchID/MaxInflightTasks and this PR's SelfHeal; the sources and test fakes implement all three.
  • dispatchWatcherEvent: keeps upstream's per-watcher throttle (acquireWatcherSlot + defer release()) layered on top of this PR's thread-safe getWatcherCoordinator() accessor.

Also folded in your two optional notes:

  1. Eager-disable cause now includes the profile namedisableReferencingWatchers uses a formatDeletedProfileCause helper (80-rune cap, matching the preflight) so the banner shows agent profile "Kilo Profile" (id) was deleted instead of a bare UUID. Pinned with a test assertion.
  2. Force-delete disables watchers after the row delete succeeds — moved out of prepareProfileDeletion into DeleteProfile post-delete, so a failed delete no longer strands watchers disabled against a still-live profile; the preflight remains the fallback if the post-delete disable itself fails.

Local gates all green: make fmt/build/vet/lint (0 issues) + full go test, and frontend typecheck/lint/test (2774 passing). CI re-running now.

`getLinearIssueWatch` called `.find()` directly on the
`GET /api/v1/linear/watches/issue` response, but that endpoint returns a
`{ watches: [...] }` envelope (it always has — same on the pre-rebase
branch), so the call threw `TypeError: watches.find is not a function` and
failed the "force-deleting profile with watcher disables the watcher row"
spec in E2E Shard 5/10. Destructure the envelope before searching.

Also refresh the spec's explanatory comment: the eager watcher-disable now
runs in DeleteProfile after the row delete succeeds (not in
prepareProfileDeletion), per the maintainer's optional-note follow-up.

Verified locally: `pnpm e2e:run --host tests/settings/agent-profile-delete.spec.ts` → 1 passed.
@nlenepveu
Copy link
Copy Markdown
Contributor Author

CI is fully green now ✅ — all 10 E2E shards pass, including Shard 5/10 which was the only one still red after the rebase.

That shard turned out to be a pre-existing bug in this PR's own e2e helper, not the stale base: getLinearIssueWatch called .find() directly on the GET /api/v1/linear/watches/issue response, but that endpoint returns a { watches: [...] } envelope (it always has — identical on the pre-rebase branch), so it threw TypeError: watches.find is not a function. Fixed by destructuring the envelope (commit c0ba71f8); verified locally with pnpm e2e:run --host (1 passed) and now confirmed green in CI.

Final state: rebased onto latest main, MERGEABLE, all checks passing, both optional notes folded in. Good to go from my side.

@nlenepveu
Copy link
Copy Markdown
Contributor Author

Heads up: one CI check came back red after the last push — Run Backend Tests (run 26719846305) — but it's a goroutine-leak flake, not a regression:

goleak: Errors on successful test run: found unexpected goroutines:
[Goroutine 982 ... agentctl.(*Client).StreamWorkspace ...
 Goroutine 959 ... net/http.(*Server).Serve ...
 Goroutine 983 ... agentctl.(*WorkspaceStream).writeLoop ...
  • It's in internal/agent/runtime/lifecycle / agentctl (streaming teardown) — a package this PR doesn't touch (all changes are in watcher/integration/settings).
  • goleak reports "on successful test run" — every test passed; it just caught async stream/server goroutines that hadn't drained at TestMain.
  • The same suite is green locally (make test, 0 failures) and passed in the earlier CI run for this commit.

I don't have admin rights to re-run the job — could you kick off a re-run of the failed Backend Tests job? It should go green. Everything else (lint, Build, Frontend, Backend windows, all 10 E2E shards incl. the previously-failing 5/10) is passing.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants