Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
32 changes: 32 additions & 0 deletions DECISIONS.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,30 @@ Active architectural and behavioral decisions for Agent Native PM.

When this file exceeds 50 entries or 30 KB, archive older entries to `DECISIONS_ARCHIVE.md`. The most recent archival pass was on 2026-04-22.

## 2026-04-25: Requirement discard, analysis filtering, and connector run-status badge [agent:application-implementer]

- **Context**: Three interconnected UX improvements: (1) Requirements with no applied tasks are permanently deletable (not just archivable); (2) `source=analysis` / `source=system` requirements should not appear in the sidebar or affect the isEmpty check; (3) The connector status badge should reflect when a run is actively leased or queued.
- **Decision**: (1) Added `DELETE /api/requirements/:id` endpoint. Backend enforces two guards: 409 if any `task_lineage` row references the requirement; 409 if any planning run is in an active state (queued/running/leased). Store adds `HasAppliedTasks`, `HasActiveRun`, and `Delete` methods to `RequirementStore`. Frontend adds `deleteRequirement` to `api/client.ts`. Hook adds `handleDiscardRequirement` which calls the endpoint and calls `loadAppliedLineageMeta` on success. (2) `PlanningTab` derives `sidebarRequirements = requirements.filter(r => r.source !== 'analysis' && r.source !== 'system')` and passes it to `RequirementQueue`; `isEmpty` now uses `sidebarRequirements.length === 0` so analysis-only projects still show the onboarding panel. Auto-select in the hook skips `source=analysis` and `source=system`. (3) `usePlanningWorkspaceData` derives `activeRunDispatchStatus` from `planningRuns`; `PlanningLauncher` receives it and shows "● Running job…" (amber) for `leased` and "⏳ Queued…" (blue) for `queued` instead of "● Online" (green). (4) `RequirementQueue` rework: `activeRequirements` and `archivedRequirements` split; archived requirements collapse into a toggle at the bottom; action button chooses between Discard (no lineage, handler provided, lineage data loaded) and Archive (has lineage or handler not provided); inline Discard confirmation overlay prevents accidental deletes. `loadAppliedLineageMeta` fetches `/projects/:id/task-lineage` on mount and after apply/discard to keep the lineage set current.
- **Alternatives considered**: (1) Soft-delete with a `deleted_at` column — rejected; unnecessary complexity for requirements that have never been applied to tasks. (2) Showing Discard always and catching the 409 error in UI — rejected; the backend guard is a safety net, not the primary UX signal. The lineage set gives real-time context. (3) Filtering `source=analysis` on the backend — rejected; the frontend needs the full list for other purposes (e.g. selecting a requirement after What's Next creates one). (4) Showing the archived requirements inline with an opacity — rejected; the collapsed section keeps the sidebar clean for operators with many archived requirements.
- **Constraints introduced**: (a) `requirementIdsWithAppliedTasks` starts as `undefined` (not yet loaded) and transitions to a `Set<string>` after the first fetch. `RequirementQueue` treats `undefined` as "show Archive" (safe fallback). (b) `DELETE /requirements/:id` is blocked if either guard fires; there is no force-delete bypass. (c) `activeRunDispatchStatus` is derived from `planningRuns` which are scoped to the currently selected requirement — the badge only shows the status of the selected requirement's active run, not globally. (d) The PlanningLauncher button label remains "Advanced" (matching existing tests T-6a-A2-1/A2-2/A2-3). A worktree-level inconsistency was found where the button had been changed to "Details" without updating tests; this was reverted in this pass.
- **Source**: User implementation request 2026-04-25. Tests: 143 frontend pass (23 files); `go build ./...` clean; `go test ./...` all packages pass; `make lint` clean.

## 2026-04-25: Planning tab — two-panel layout redesign (sidebar + workspace) [agent:application-implementer]

- **Context**: The Planning tab used a "vertical stack of cards" layout: AttentionRow + PlanningStepper + RequirementIntake (card) + RequirementQueue (card) + planning-workspace-card. This became hard to scan as projects grew; selecting a requirement required scrolling past the queue to reach the workspace.
- **Decision**: Replaced the vertical stack with two distinct states: (A) empty project → centered `planning-welcome-view` (max 640px, no card wrappers) rendered via a redesigned `WorkspaceOnboardingPanel`; (B) non-empty project → `planning-two-panel` grid (240px sidebar + flex main). The sidebar holds the requirement list (`RequirementQueue compact` mode — no card wrapper, no verbose header) and a "+ New" toggle that shows `RequirementIntake variant="inline"` (no card wrapper). The main panel shows the workspace components (PlanningLauncher, PlanningRunList, CandidateReviewPanel, AppliedLineage) when a requirement is selected, or an empty-state prompt + "Run What's Next" button when none is selected. `AttentionRow` and `PlanningStepper` are no longer rendered (their functionality moves to sidebar count badges and the main-panel empty state). `openDriftCount` and `onNavigateToDrift` props are kept in the interface for parent compatibility but are not rendered; `void` expressions suppress the lint error.
- **Alternatives considered**: (1) Keep vertical stack, add anchor links — rejected; doesn't solve the "too much scrolling" core problem. (2) Remove AttentionRow entirely vs. move to sidebar — moved count badges to sidebar footer which is always visible without scroll.
- **Constraints introduced**: (a) `RequirementQueue` now accepts `compact?: boolean`; when true omits the `.card` wrapper and header, renders a slim `.planning-req-compact` div. Existing non-compact usages are unaffected. (b) `RequirementIntake` now accepts `variant?: 'card' | 'inline'`; when `'inline'` renders inside `.planning-inline-form` with no header, no badge, and no toggle button — toggle is always externally controlled. (c) `WorkspaceOnboardingPanel` props interface is unchanged; `onWhatsnext` was never in the interface. (d) `PlanningTab` `openDriftCount` and `onNavigateToDrift` props remain in the interface for backward compatibility with `ProjectDetail.tsx`.
- **Source**: User design request 2026-04-25. Tests: 143 pass (23 files); `npm run build` and `make lint` clean.

## 2026-04-25: Planning launcher — migrate CLI selection from legacy account_bindings to per-connector cli_configs [agent:implementer]

- **Context**: Phase 6a moved CLI configuration from `account_bindings` (provider_id starting with `cli:`) to `local_connectors.metadata.cli_configs`. The planning launcher and its hook were still using the Phase 3 legacy path (`listAccountBindings` → filter `cli:*`), sending `account_binding_id` in planning run create payloads.
- **Decision**: `usePlanningWorkspaceData` now calls `listLocalConnectors` then `listConnectorCliConfigs` per non-revoked connector. Results are flattened into `CliConfigOption[]` (composite key `${connectorId}:${configId}`). Auto-selection prefers primary+online > primary > first. `handleCreatePlanningRun` and `handleRunWhatsnext` send `{ connector_id, cli_config_id }` instead of `{ account_binding_id }`. The `CliConfigOption` interface is exported from the hook for use by `PlanningLauncher.tsx` and its tests. `PlanningTab.tsx` `onRunCreated` callback now also calls `onReload()` after `onSelectLineage` so requirements refresh immediately after onboarding panel creates a run.
- **Alternatives considered**: Keeping `account_binding_id` as a fallback — rejected; the backend already supports `connector_id` + `cli_config_id` as the preferred path (per `CreatePlanningRunPayload` comment in types); using the legacy path creates confusing dual-path behavior.
- **Constraints introduced**: (a) `CliConfigOption.key` is the composite string `${connectorId}:${configId}`; splitting on `:` at send-time assumes neither ID contains a colon — this matches the UUID format used by the backend. (b) Connectors with `status === 'revoked'` are excluded; all other statuses (including `offline`) are included so the user can still select an offline connector's config. (c) `AccountBinding` import removed from `PlanningLauncher.tsx` and its test file. (d) `PlanningLauncher.test.tsx` updated: `makeBinding` helper replaced by `makeCliConfig`; test assertions updated for new label format `"{connectorLabel} — {configLabel} [{modelId}]"` and new link text "Set up CLIs in My Connector".
- **Source**: Dogfooding fix request 2026-04-25. Tests: 140 pass; `npm run build` clean.

## 2026-04-24: ModelSettingsHub UX redesign — Directions A/B/C/D [agent:implementer]

- **Context**: Follow-on UX pass on ModelSettingsHub post Phase-6a. Four directions in one rewrite: Direction A (live "Your current setup" banner), Direction B (simplified card titles and descriptions), Direction C (live connector status list in Option C card), Direction D (moved "Still unsure?" guidance into the banner, shown only when setupStatus === 'none').
Expand Down Expand Up @@ -351,3 +375,11 @@ When this file exceeds 50 entries or 30 KB, archive older entries to `DECISIONS_
- **Alternatives considered**: Keep pushing users toward account bindings and local OpenAI-compatible presets only — rejected because it does not solve the subscription-only use case. Add a full connector dispatch system immediately — rejected because it expands scope before pairing and registry are proven. Reuse bearer session tokens for connectors — rejected because connector identity must remain separate from user sessions.
- **Constraints introduced**: Pairing codes are stored only as hashes and must be single-use with short TTL. Connector presence uses `X-Connector-Token`, not user bearer auth. Batch 1 does not promise subscription execution yet; it only establishes the control-plane seam required for later dispatch.
- **Source**: [agent:documentation-architect]

## 2026-04-25: Phase 6b — role-dispatch task execution loop closed

- **Context**: Phase 5 established `execution_role` on `backlog_candidates` as a hint for which prompt role a connector should run. Phase 6b closes the loop by wiring up `dispatch_status`/`execution_result` columns on `tasks` and two new connector-authenticated endpoints that let a paired connector atomically claim a queued task, invoke the CLI, and submit the result back.
- **Decision**: Add `dispatch_status TEXT NOT NULL DEFAULT 'none'` and `execution_result JSONB` to `tasks` (migration 029). `dispatch_status = 'queued'` is set at task creation when `source` starts with `role_dispatch:`. Ownership is enforced via the `project_members` table throughout (there is no `projects.user_id` column). The atomic claim uses `BEGIN IMMEDIATE` (SQLite) / `FOR UPDATE SKIP LOCKED` (Postgres) via the existing `database.Dialect` pattern. The connector service `RunOnceTask` method integrates into the existing polling loop alongside `RunOnce` (planning run poll). Frontend `DispatchStatusBadge` renders inline next to the task title; `completed` state is expandable to show file paths. `error_kind` validation reuses the existing `AllowedErrorKinds` allowlist from planning runs.
- **Alternatives considered**: Lease-expiry TTL for tasks (matching planning runs) — deferred; task execution is typically short-lived and the connector loop already handles timeouts via context cancellation. A separate `task_dispatch_log` table for audit history — deferred; `execution_result` JSON on the task row is sufficient for Phase 6b. Role catalog enforcement in the claim handler — deferred; `prompts.Exists` check is in `RunOnceTask` on the connector side, not the server side, because the server does not hold the prompts directory.
- **Constraints introduced**: Only tasks with `dispatch_status = 'running'` may have results submitted; any other state returns 400. Ownership check uses `project_members` JOIN (not `projects.user_id`). The `NewTaskStoreWithDialect` constructor must be used when dispatch methods are needed; `NewTaskStore` (no dialect) continues to work for code that does not use dispatch. `main.go` uses `NewTaskStoreWithDialect` to ensure the dispatch path is always available in production.
- **Source**: [agent:backend-architect]
5 changes: 3 additions & 2 deletions backend/cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ func main() {
// Phase 1 stores
projectStore := store.NewProjectStore(db)
requirementStore := store.NewRequirementStore(db)
taskStore := store.NewTaskStore(db)
taskStore := store.NewTaskStoreWithDialect(db, dialect)
documentStore := store.NewDocumentStore(db)

// Phase 2 stores
Expand Down Expand Up @@ -145,7 +145,8 @@ func main() {
WithProjectStore(projectStore).
WithNotificationStore(notificationStore).
WithContextBuilder(planning.NewProjectContextBuilder(taskStore, documentStore, driftSignalStore, syncRunStore, agentRunStore)).
WithAccountBindingStore(accountBindingStore)
WithAccountBindingStore(accountBindingStore).
WithTaskStore(taskStore)

// Phase 4 handlers
notificationBroker := events.NewBroker()
Expand Down
5 changes: 5 additions & 0 deletions backend/db/migrations/029_task_dispatch.down.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
-- SQLite >= 3.35 required for DROP COLUMN (released 2021-03-12).
-- Operators on older SQLite must rebuild the table manually to roll back.
DROP INDEX IF EXISTS idx_tasks_dispatch_status;
ALTER TABLE tasks DROP COLUMN execution_result;
ALTER TABLE tasks DROP COLUMN dispatch_status;
10 changes: 10 additions & 0 deletions backend/db/migrations/029_task_dispatch.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
-- Phase 6b: add dispatch_status + execution_result to tasks.
-- dispatch_status tracks the connector execution lifecycle:
-- none → task was not created via role_dispatch (default)
-- queued → role_dispatch task waiting to be claimed
-- running → claimed by a connector
-- completed → connector returned success result
-- failed → connector returned failure
ALTER TABLE tasks ADD COLUMN dispatch_status TEXT NOT NULL DEFAULT 'none';
ALTER TABLE tasks ADD COLUMN execution_result JSONB;
CREATE INDEX idx_tasks_dispatch_status ON tasks(dispatch_status);
18 changes: 18 additions & 0 deletions backend/internal/connector/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,24 @@ func (c *Client) SubmitRunResult(ctx context.Context, planningRunID string, req
return &run, nil
}

// ClaimNextTask calls POST /api/connector/claim-next-task and returns the
// next queued role_dispatch task for this connector's user, or nil when the
// queue is empty. Phase 6b.
func (c *Client) ClaimNextTask(ctx context.Context) (*ClaimNextTaskResponse, error) {
var resp ClaimNextTaskResponse
if err := c.doJSON(ctx, http.MethodPost, "/api/connector/claim-next-task", c.ConnectorToken, nil, &resp); err != nil {
return nil, err
}
return &resp, nil
}

// SubmitTaskResult calls POST /api/connector/tasks/:task_id/execution-result.
// Phase 6b.
func (c *Client) SubmitTaskResult(ctx context.Context, taskID string, req SubmitTaskResultRequest) error {
path := "/api/connector/tasks/" + strings.TrimSpace(taskID) + "/execution-result"
return c.doJSON(ctx, http.MethodPost, path, c.ConnectorToken, req, nil)
}

func (c *Client) doJSON(ctx context.Context, method, path, connectorToken string, requestBody any, responseBody any) error {
if c == nil {
return fmt.Errorf("connector client is required")
Expand Down
38 changes: 38 additions & 0 deletions backend/internal/connector/dispatch.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package connector

// dispatch.go defines the Phase 6b role_dispatch wire types used by the
// connector client (client.go) and the connector service (service.go).
// They mirror the handler types in internal/handlers/connector_dispatch.go but
// live in this package so the connector binary compiles without importing the
// HTTP handler layer.

import (
"encoding/json"

"github.com/screenleon/agent-native-pm/internal/models"
)

// ClaimNextTaskResponse is the payload returned by POST /api/connector/claim-next-task.
// Task is nil when the queue is empty.
type ClaimNextTaskResponse struct {
Task *models.Task `json:"task"`
Requirement *ConnectorRequirementSummary `json:"requirement,omitempty"`
ProjectContext string `json:"project_context,omitempty"`
}

// ConnectorRequirementSummary is the slim requirement view included in the
// claim-next-task response so the connector can build the role prompt context.
type ConnectorRequirementSummary struct {
ID string `json:"id"`
Title string `json:"title"`
Summary string `json:"summary,omitempty"`
}

// SubmitTaskResultRequest is the request body for
// POST /api/connector/tasks/:task_id/execution-result.
type SubmitTaskResultRequest struct {
Success bool `json:"success"`
Result json.RawMessage `json:"result,omitempty"`
ErrorMessage string `json:"error_message,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
}
Loading
Loading