diff --git a/DECISIONS.md b/DECISIONS.md
index ddb58eb..317c258 100644
--- a/DECISIONS.md
+++ b/DECISIONS.md
@@ -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` 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').
@@ -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]
diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go
index 26e05dd..24385a8 100644
--- a/backend/cmd/server/main.go
+++ b/backend/cmd/server/main.go
@@ -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
@@ -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()
diff --git a/backend/db/migrations/029_task_dispatch.down.sql b/backend/db/migrations/029_task_dispatch.down.sql
new file mode 100644
index 0000000..141c37f
--- /dev/null
+++ b/backend/db/migrations/029_task_dispatch.down.sql
@@ -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;
diff --git a/backend/db/migrations/029_task_dispatch.sql b/backend/db/migrations/029_task_dispatch.sql
new file mode 100644
index 0000000..b06c113
--- /dev/null
+++ b/backend/db/migrations/029_task_dispatch.sql
@@ -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);
diff --git a/backend/internal/connector/client.go b/backend/internal/connector/client.go
index ec5de99..161f651 100644
--- a/backend/internal/connector/client.go
+++ b/backend/internal/connector/client.go
@@ -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")
diff --git a/backend/internal/connector/dispatch.go b/backend/internal/connector/dispatch.go
new file mode 100644
index 0000000..637d185
--- /dev/null
+++ b/backend/internal/connector/dispatch.go
@@ -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"`
+}
diff --git a/backend/internal/connector/service.go b/backend/internal/connector/service.go
index d938ce1..e36cdda 100644
--- a/backend/internal/connector/service.go
+++ b/backend/internal/connector/service.go
@@ -13,6 +13,7 @@ import (
"time"
"github.com/screenleon/agent-native-pm/internal/models"
+ "github.com/screenleon/agent-native-pm/internal/prompts"
)
// cliInterpreterBlocklist is the set of bare command names that must NOT be
@@ -140,6 +141,16 @@ func (s *Service) Run(ctx context.Context) error {
} else {
lastError = ""
}
+ // Phase 6b: when the planning-run queue is idle, also try the task
+ // dispatch queue. Both loops share the same poll/sleep cadence.
+ if !worked {
+ workedTask, taskErr := s.RunOnceTask(ctx)
+ if taskErr != nil {
+ lastError = taskErr.Error()
+ fmt.Fprintf(s.Stderr, "task dispatch loop error: %s\n", lastError)
+ }
+ worked = workedTask
+ }
if worked {
continue
}
@@ -151,6 +162,191 @@ func (s *Service) Run(ctx context.Context) error {
}
}
+// RunOnceTask implements the Phase 6b role_dispatch execution loop. It claims
+// one queued task, renders the role prompt, invokes the CLI, and submits the
+// result. Returns (true, nil) when a task was processed (regardless of
+// success/failure of the task itself), (false, nil) when the queue is empty,
+// and (false, err) on infrastructure errors.
+func (s *Service) RunOnceTask(ctx context.Context) (bool, error) {
+ resp, err := s.Client.ClaimNextTask(ctx)
+ if err != nil {
+ return false, err
+ }
+ if resp == nil || resp.Task == nil {
+ return false, nil
+ }
+ task := resp.Task
+
+ // Parse role_id from source ("role_dispatch:backend-architect" → "backend-architect").
+ roleID := ""
+ if after, ok := strings.CutPrefix(task.Source, "role_dispatch:"); ok {
+ roleID = strings.TrimSpace(after)
+ }
+ if roleID == "" {
+ fmt.Fprintf(s.Stderr, "task %s has invalid source %q — missing role_id\n", task.ID, task.Source)
+ if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{
+ Success: false,
+ ErrorMessage: fmt.Sprintf("invalid task source %q: missing role_id", task.Source),
+ ErrorKind: "unknown",
+ }); err != nil {
+ fmt.Fprintf(s.Stderr, "task %s: submit result failed: %v\n", task.ID, err)
+ }
+ return true, nil
+ }
+
+ // Catalog enforcement: role must exist in the embedded prompt library.
+ if !prompts.Exists("roles/" + roleID) {
+ fmt.Fprintf(s.Stderr, "task %s: role %q not found in catalog\n", task.ID, roleID)
+ if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{
+ Success: false,
+ ErrorMessage: fmt.Sprintf("role %q not found in catalog", roleID),
+ ErrorKind: "unknown",
+ }); err != nil {
+ fmt.Fprintf(s.Stderr, "task %s: submit result failed: %v\n", task.ID, err)
+ }
+ return true, nil
+ }
+
+ fmt.Fprintf(s.Stdout, "claimed task %s (role=%s title=%q)\n", task.ID, roleID, task.Title)
+
+ // Resolve CLI. Use the connector's primary CLI config if available (the
+ // connector state does not carry a binding for task dispatch, so we
+ // construct a nil selection and let resolveBuiltinCLI fall back to env /
+ // PATH lookup, which is the correct behaviour for role_dispatch).
+ // The model is read from task or env; there is no per-task ModelID field
+ // in Phase 6b so we pass nil run.
+ _, cliPath, cliModel, _, resolveErr := resolveBuiltinCLI(nil, nil)
+ if resolveErr != "" {
+ fmt.Fprintf(s.Stderr, "task %s: CLI resolve failed: %s\n", task.ID, resolveErr)
+ if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{
+ Success: false,
+ ErrorMessage: resolveErr,
+ ErrorKind: "adapter_timeout",
+ }); err != nil {
+ fmt.Fprintf(s.Stderr, "task %s: submit result failed: %v\n", task.ID, err)
+ }
+ return true, nil
+ }
+
+ // Build template vars.
+ vars := map[string]string{
+ "TASK_TITLE": strings.TrimSpace(task.Title),
+ "TASK_DESCRIPTION": strings.TrimSpace(task.Description),
+ "REQUIREMENT": buildConnectorRequirementContext(resp.Requirement),
+ "PROJECT_CONTEXT": strings.TrimSpace(resp.ProjectContext),
+ }
+
+ // Render role prompt.
+ rendered, renderErr := prompts.Render("roles/"+roleID, vars)
+ if renderErr != nil {
+ fmt.Fprintf(s.Stderr, "task %s: prompt render failed: %v\n", task.ID, renderErr)
+ if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{
+ Success: false,
+ ErrorMessage: fmt.Sprintf("prompt render error: %v", renderErr),
+ ErrorKind: "unknown",
+ }); err != nil {
+ fmt.Fprintf(s.Stderr, "task %s: submit result failed: %v\n", task.ID, err)
+ }
+ return true, nil
+ }
+
+ // Read timeout.
+ timeoutSec := builtinDefaultTimeoutSec
+
+ // Invoke CLI.
+ output, runErrMsg := invokeBuiltinCLI(ctx, resolveAgentFromBinary(cliPath), cliPath, cliModel, rendered, timeoutSec)
+ if runErrMsg != "" {
+ errKind := classifyRunError(runErrMsg)
+ fmt.Fprintf(s.Stderr, "task %s: CLI failed: %s\n", task.ID, runErrMsg)
+ if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{
+ Success: false,
+ ErrorMessage: runErrMsg,
+ ErrorKind: errKind,
+ }); err != nil {
+ fmt.Fprintf(s.Stderr, "task %s: submit result failed: %v\n", task.ID, err)
+ }
+ return true, nil
+ }
+
+ output = stripANSI(output)
+
+ // Extract JSON from output.
+ parsed, extractErr := extractJSONFromOutput(output)
+ if extractErr != nil {
+ snippet := strings.TrimSpace(output)
+ if len(snippet) > 240 {
+ snippet = snippet[:240]
+ }
+ errMsg := fmt.Sprintf("could not parse output as JSON: %v; first 240 chars: %s", extractErr, snippet)
+ fmt.Fprintf(s.Stderr, "task %s: %s\n", task.ID, errMsg)
+ if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{
+ Success: false,
+ ErrorMessage: errMsg,
+ ErrorKind: "unknown",
+ }); err != nil {
+ fmt.Fprintf(s.Stderr, "task %s: submit result failed: %v\n", task.ID, err)
+ }
+ return true, nil
+ }
+
+ // Re-marshal the full parsed map as the result payload.
+ resultBytes, _ := json.Marshal(parsed)
+
+ if err := s.Client.SubmitTaskResult(ctx, task.ID, SubmitTaskResultRequest{
+ Success: true,
+ Result: json.RawMessage(resultBytes),
+ }); err != nil {
+ return true, fmt.Errorf("submit task result for %s: %w", task.ID, err)
+ }
+ fmt.Fprintf(s.Stdout, "completed task %s (role=%s)\n", task.ID, roleID)
+ return true, nil
+}
+
+// buildConnectorRequirementContext formats the requirement summary for prompt injection.
+func buildConnectorRequirementContext(req *ConnectorRequirementSummary) string {
+ if req == nil {
+ return ""
+ }
+ var parts []string
+ if t := strings.TrimSpace(req.Title); t != "" {
+ parts = append(parts, "Title: "+t)
+ }
+ if s := strings.TrimSpace(req.Summary); s != "" {
+ parts = append(parts, "Summary: "+s)
+ }
+ return strings.Join(parts, "\n")
+}
+
+// resolveAgentFromBinary infers the agent name from the binary path.
+func resolveAgentFromBinary(binary string) string {
+ base := strings.ToLower(filepath.Base(binary))
+ switch {
+ case strings.HasPrefix(base, "claude"):
+ return "claude"
+ case strings.HasPrefix(base, "codex"):
+ return "codex"
+ default:
+ return "claude"
+ }
+}
+
+// classifyRunError maps common error substrings to error_kind values.
+func classifyRunError(msg string) string {
+ lower := strings.ToLower(msg)
+ switch {
+ case strings.Contains(lower, "session") && strings.Contains(lower, "expired"):
+ return "session_expired"
+ case strings.Contains(lower, "rate limit"):
+ return "rate_limited"
+ case strings.Contains(lower, "context") && strings.Contains(lower, "overflow"):
+ return "context_overflow"
+ case strings.Contains(lower, "timed out"):
+ return "adapter_timeout"
+ default:
+ return "unknown"
+ }
+}
+
func (s *Service) RunOnce(ctx context.Context) (bool, error) {
claim, err := s.Client.ClaimNextRun(ctx)
if err != nil {
diff --git a/backend/internal/handlers/connector_dispatch.go b/backend/internal/handlers/connector_dispatch.go
new file mode 100644
index 0000000..52d9670
--- /dev/null
+++ b/backend/internal/handlers/connector_dispatch.go
@@ -0,0 +1,185 @@
+package handlers
+
+// ConnectorDispatchHandler handles the Phase 6b task dispatch API:
+// POST /api/connector/claim-next-task
+// POST /api/connector/tasks/:task_id/execution-result
+//
+// Both endpoints require X-Connector-Token authentication (same as claim-next-run).
+// The handler is wired onto LocalConnectorHandler so it reuses authenticatedConnector.
+
+import (
+ "encoding/json"
+ "errors"
+ "io"
+ "log"
+ "net/http"
+ "strings"
+
+ "github.com/go-chi/chi/v5"
+ "github.com/screenleon/agent-native-pm/internal/models"
+ "github.com/screenleon/agent-native-pm/internal/store"
+)
+
+// ClaimNextTaskResponse is returned by POST /api/connector/claim-next-task.
+type ClaimNextTaskResponse struct {
+ Task *models.Task `json:"task"`
+ Requirement *RequirementSummary `json:"requirement,omitempty"`
+ ProjectContext string `json:"project_context,omitempty"`
+}
+
+// RequirementSummary is a slim view of the requirement sent alongside the task.
+type RequirementSummary struct {
+ ID string `json:"id"`
+ Title string `json:"title"`
+ Summary string `json:"summary,omitempty"`
+}
+
+// SubmitTaskResultRequest is the request body for POST …/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"`
+}
+
+// ClaimNextTask implements POST /api/connector/claim-next-task.
+func (h *LocalConnectorHandler) ClaimNextTask(w http.ResponseWriter, r *http.Request) {
+ connector, ok := h.authenticatedConnector(w, r)
+ if !ok {
+ return
+ }
+
+ if h.taskStore == nil {
+ writeError(w, http.StatusInternalServerError, "task dispatch store not configured")
+ return
+ }
+
+ task, req, err := h.taskStore.ClaimNextDispatchTask(connector.ID, connector.UserID)
+ if err != nil {
+ log.Printf("claim-next-task: error for connector %s: %v", connector.ID, err)
+ writeError(w, http.StatusInternalServerError, "failed to claim next dispatch task")
+ return
+ }
+ if task == nil {
+ // Queue empty — return {"task": null}.
+ writeSuccess(w, http.StatusOK, ClaimNextTaskResponse{}, nil)
+ return
+ }
+
+ resp := ClaimNextTaskResponse{Task: task}
+ if req != nil {
+ resp.Requirement = &RequirementSummary{
+ ID: req.ID,
+ Title: req.Title,
+ Summary: req.Summary,
+ }
+ resp.ProjectContext = buildDispatchProjectContext(req)
+ }
+
+ writeSuccess(w, http.StatusOK, resp, nil)
+}
+
+// SubmitTaskResult implements POST /api/connector/tasks/:task_id/execution-result.
+func (h *LocalConnectorHandler) SubmitTaskResult(w http.ResponseWriter, r *http.Request) {
+ connector, ok := h.authenticatedConnector(w, r)
+ if !ok {
+ return
+ }
+
+ taskID := chi.URLParam(r, "task_id")
+ if strings.TrimSpace(taskID) == "" {
+ writeError(w, http.StatusBadRequest, "task_id is required")
+ return
+ }
+
+ if h.taskStore == nil {
+ writeError(w, http.StatusInternalServerError, "task dispatch store not configured")
+ return
+ }
+
+ var req SubmitTaskResultRequest
+ if err := json.NewDecoder(r.Body).Decode(&req); err != nil && !errors.Is(err, io.EOF) {
+ writeError(w, http.StatusBadRequest, "invalid request body")
+ return
+ }
+
+ // Verify connector has ownership over this task.
+ task, err := h.taskStore.GetTaskForConnector(taskID, connector.UserID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to verify task ownership")
+ return
+ }
+ if task == nil {
+ writeError(w, http.StatusNotFound, "task not found")
+ return
+ }
+
+ // Validate that the task is currently running (not already settled).
+ if task.DispatchStatus != models.TaskDispatchStatusRunning {
+ writeError(w, http.StatusBadRequest, "task is not in running state")
+ return
+ }
+
+ // Normalize error_kind (same allowlist as planning-runs).
+ errorKind := strings.TrimSpace(req.ErrorKind)
+ if errorKind != "" && !models.AllowedErrorKinds[errorKind] {
+ errorKind = models.ErrorKindUnknown
+ }
+
+ if req.Success {
+ if err := h.taskStore.CompleteDispatchTask(taskID, connector.UserID, req.Result); err != nil {
+ if errors.Is(err, store.ErrDispatchOwnership) {
+ writeError(w, http.StatusForbidden, "connector does not own this task")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "failed to complete task")
+ return
+ }
+ } else {
+ errMsg := strings.TrimSpace(req.ErrorMessage)
+ if errMsg == "" {
+ errMsg = "execution failed"
+ }
+ if errorKind != "" && errorKind != models.ErrorKindUnknown {
+ errMsg = errMsg + " [" + errorKind + "]"
+ }
+ if err := h.taskStore.FailDispatchTask(taskID, connector.UserID, errMsg); err != nil {
+ if errors.Is(err, store.ErrDispatchOwnership) {
+ writeError(w, http.StatusForbidden, "connector does not own this task")
+ return
+ }
+ writeError(w, http.StatusInternalServerError, "failed to fail task")
+ return
+ }
+ }
+
+ // Return the updated task.
+ updated, err := h.taskStore.GetByID(taskID)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to reload task")
+ return
+ }
+ writeSuccess(w, http.StatusOK, updated, nil)
+}
+
+// buildDispatchProjectContext produces a compact text summary of the requirement
+// that the role prompt can inject as PROJECT_CONTEXT.
+func buildDispatchProjectContext(req *models.Requirement) string {
+ if req == nil {
+ return ""
+ }
+ var parts []string
+ if t := strings.TrimSpace(req.Title); t != "" {
+ parts = append(parts, "Requirement: "+t)
+ }
+ if d := strings.TrimSpace(req.Description); d != "" {
+ parts = append(parts, "Description: "+d)
+ }
+ if a := strings.TrimSpace(req.Audience); a != "" {
+ parts = append(parts, "Audience: "+a)
+ }
+ if s := strings.TrimSpace(req.SuccessCriteria); s != "" {
+ parts = append(parts, "Success criteria: "+s)
+ }
+ return strings.Join(parts, "\n")
+}
diff --git a/backend/internal/handlers/connector_dispatch_test.go b/backend/internal/handlers/connector_dispatch_test.go
new file mode 100644
index 0000000..ae88e0f
--- /dev/null
+++ b/backend/internal/handlers/connector_dispatch_test.go
@@ -0,0 +1,341 @@
+package handlers_test
+
+// connector_dispatch_test.go covers the Phase 6b task dispatch endpoints:
+// POST /api/connector/claim-next-task
+// POST /api/connector/tasks/:task_id/execution-result
+//
+// Test IDs are prefixed T-6b to match the Phase 6b DoD.
+
+import (
+ "bytes"
+ "crypto/sha256"
+ "database/sql"
+ "encoding/hex"
+ "encoding/json"
+ "fmt"
+ "net/http"
+ "net/http/httptest"
+ "strings"
+ "testing"
+ "time"
+
+ "github.com/screenleon/agent-native-pm/internal/database"
+ "github.com/screenleon/agent-native-pm/internal/handlers"
+ "github.com/screenleon/agent-native-pm/internal/middleware"
+ "github.com/screenleon/agent-native-pm/internal/models"
+ "github.com/screenleon/agent-native-pm/internal/planning"
+ "github.com/screenleon/agent-native-pm/internal/router"
+ "github.com/screenleon/agent-native-pm/internal/store"
+ "github.com/screenleon/agent-native-pm/internal/testutil"
+)
+
+// hashConnectorToken mimics the store's hashSecret function for test token insertion.
+func hashConnectorToken(token string) string {
+ sum := sha256.Sum256([]byte(strings.TrimSpace(token)))
+ return hex.EncodeToString(sum[:])
+}
+
+// dispatchFixture wires up all the stores needed to test the dispatch endpoints.
+type dispatchFixture struct {
+ srv http.Handler
+ db *sql.DB
+ dialect database.Dialect
+ taskStore *store.TaskStore
+ localConnectorStore *store.LocalConnectorStore
+ requirementStore *store.RequirementStore
+ candidateStore *store.BacklogCandidateStore
+ projectStore *store.ProjectStore
+ // ownerUserID is the project/task owner (linked to connector).
+ ownerUserID string
+ // otherUserID owns nothing.
+ otherUserID string
+ projectID string
+}
+
+func newDispatchFixture(t *testing.T) *dispatchFixture {
+ t.Helper()
+ db := testutil.OpenTestDB(t)
+ dialect := testutil.TestDialect()
+
+ // Seed two users.
+ mustExec(t, db, `INSERT INTO users (id, username, email, password_hash, role, is_active)
+ VALUES ('owner-user', 'owner', 'owner@test.com', '', 'member', TRUE)`)
+ mustExec(t, db, `INSERT INTO users (id, username, email, password_hash, role, is_active)
+ VALUES ('other-user', 'other', 'other@test.com', '', 'member', TRUE)`)
+
+ // Owner creates the project, then is added as a project_member.
+ projects := store.NewProjectStore(db)
+ project, err := projects.Create(models.CreateProjectRequest{Name: "Dispatch Test Project"})
+ if err != nil {
+ t.Fatalf("create project: %v", err)
+ }
+ // Add owner-user as project member (project_members table controls ownership).
+ memberID := fmt.Sprintf("pm-%s", project.ID)
+ mustExec(t, db,
+ `INSERT INTO project_members (id, project_id, user_id, role) VALUES ($1, $2, $3, 'owner')`,
+ memberID, project.ID, "owner-user",
+ )
+
+ rs := store.NewRequirementStore(db)
+ ts := store.NewTaskStoreWithDialect(db, dialect)
+ bcs := store.NewBacklogCandidateStore(db, dialect)
+ connectors := store.NewLocalConnectorStore(db, dialect)
+ agentRuns := store.NewAgentRunStore(db)
+
+ planningRuns := store.NewPlanningRunStore(db, dialect)
+ planner := stubPlanner{}
+ planningHandler := handlers.NewPlanningRunHandler(planningRuns, bcs, projects, rs, agentRuns, planner).
+ WithLocalConnectorStore(connectors)
+
+ localConnHandler := handlers.NewLocalConnectorHandler(connectors, planningRuns, rs, bcs, agentRuns).
+ WithTaskStore(ts)
+
+ srv := router.New(router.Deps{
+ PlanningRunHandler: planningHandler,
+ LocalConnectorHandler: localConnHandler,
+ AuthMiddleware: func(next http.Handler) http.Handler {
+ return next
+ },
+ LocalModeMiddleware: middleware.InjectLocalAdmin,
+ })
+
+ return &dispatchFixture{
+ srv: srv,
+ db: db,
+ dialect: dialect,
+ taskStore: ts,
+ localConnectorStore: connectors,
+ requirementStore: rs,
+ candidateStore: bcs,
+ projectStore: projects,
+ ownerUserID: "owner-user",
+ otherUserID: "other-user",
+ projectID: project.ID,
+ }
+}
+
+func mustExec(t *testing.T, db *sql.DB, query string, args ...interface{}) {
+ t.Helper()
+ if _, err := db.Exec(query, args...); err != nil {
+ t.Fatalf("mustExec %q: %v", query, err)
+ }
+}
+
+// seedConnectorForUser inserts a local connector with the given plaintext token for the given user.
+// The token is stored as sha256 hash matching the store's hashSecret function.
+func (fx *dispatchFixture) seedConnectorForUser(t *testing.T, connectorID, userID, token string) {
+ t.Helper()
+ now := time.Now().UTC()
+ mustExec(t, fx.db,
+ `INSERT INTO local_connectors (id, user_id, label, platform, client_version, status, capabilities, protocol_version, token_hash, last_seen_at, last_error, created_at, updated_at)
+ VALUES ($1, $2, $3, '', '', $4, '{}', 1, $5, $6, '', $6, $6)`,
+ connectorID, userID, "test-connector", models.LocalConnectorStatusOnline, hashConnectorToken(token), now,
+ )
+}
+
+// seedQueuedTask inserts a role_dispatch task in the queued state for the owner's project.
+func (fx *dispatchFixture) seedQueuedTask(t *testing.T, title, roleID string) string {
+ t.Helper()
+ id := fmt.Sprintf("task-%d", time.Now().UnixNano())
+ now := time.Now().UTC()
+ source := "role_dispatch:" + roleID
+ mustExec(t, fx.db,
+ `INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, source, dispatch_status, created_at, updated_at)
+ VALUES ($1, $2, $3, 'desc', 'todo', 'medium', '', $4, $5, $6, $6)`,
+ id, fx.projectID, title, source, models.TaskDispatchStatusQueued, now,
+ )
+ return id
+}
+
+func (fx *dispatchFixture) doClaimTask(connectorToken string) *httptest.ResponseRecorder {
+ req := httptest.NewRequest(http.MethodPost, "/api/connector/claim-next-task", nil)
+ req.Header.Set("X-Connector-Token", connectorToken)
+ rec := httptest.NewRecorder()
+ fx.srv.ServeHTTP(rec, req)
+ return rec
+}
+
+func (fx *dispatchFixture) doSubmitResult(connectorToken, taskID string, body interface{}) *httptest.ResponseRecorder {
+ raw, _ := json.Marshal(body)
+ req := httptest.NewRequest(http.MethodPost,
+ "/api/connector/tasks/"+taskID+"/execution-result",
+ bytes.NewReader(raw),
+ )
+ req.Header.Set("Content-Type", "application/json")
+ req.Header.Set("X-Connector-Token", connectorToken)
+ rec := httptest.NewRecorder()
+ fx.srv.ServeHTTP(rec, req)
+ return rec
+}
+
+// T-6b-1: claim with empty queue returns 200 task:null.
+func TestClaimNextTask_EmptyQueue(t *testing.T) {
+ fx := newDispatchFixture(t)
+ fx.seedConnectorForUser(t, "conn-owner", fx.ownerUserID, "tok-owner")
+
+ rec := fx.doClaimTask("tok-owner")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var env struct {
+ Data handlers.ClaimNextTaskResponse `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if env.Data.Task != nil {
+ t.Errorf("expected task=null, got task.ID=%s", env.Data.Task.ID)
+ }
+}
+
+// T-6b-2: connector owned by the project owner claims a queued task → dispatch_status=running.
+func TestClaimNextTask_OwnerClaimsTask(t *testing.T) {
+ fx := newDispatchFixture(t)
+ fx.seedConnectorForUser(t, "conn-owner2", fx.ownerUserID, "tok-owner2")
+ taskID := fx.seedQueuedTask(t, "Implement API", "backend-architect")
+
+ rec := fx.doClaimTask("tok-owner2")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var env struct {
+ Data handlers.ClaimNextTaskResponse `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if env.Data.Task == nil {
+ t.Fatal("expected a task, got null")
+ }
+ if env.Data.Task.ID != taskID {
+ t.Errorf("expected task %s, got %s", taskID, env.Data.Task.ID)
+ }
+
+ // Verify dispatch_status updated in DB.
+ task, err := fx.taskStore.GetByID(taskID)
+ if err != nil || task == nil {
+ t.Fatalf("reload task: %v", err)
+ }
+ if task.DispatchStatus != models.TaskDispatchStatusRunning {
+ t.Errorf("expected dispatch_status=running, got %s", task.DispatchStatus)
+ }
+}
+
+// T-6b-3: connector owned by a different user → 200 task:null (no ownership).
+func TestClaimNextTask_NonMemberGetsNull(t *testing.T) {
+ fx := newDispatchFixture(t)
+ // other-user has no project.
+ fx.seedConnectorForUser(t, "conn-other", fx.otherUserID, "tok-other")
+ fx.seedQueuedTask(t, "Implement UI", "ui-scaffolder")
+
+ rec := fx.doClaimTask("tok-other")
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+ var env struct {
+ Data handlers.ClaimNextTaskResponse `json:"data"`
+ }
+ if err := json.Unmarshal(rec.Body.Bytes(), &env); err != nil {
+ t.Fatalf("decode: %v", err)
+ }
+ if env.Data.Task != nil {
+ t.Errorf("expected task=null for non-member connector, got task.ID=%s", env.Data.Task.ID)
+ }
+}
+
+// T-6b-4: submit success result → dispatch_status=completed.
+func TestSubmitTaskResult_Success(t *testing.T) {
+ fx := newDispatchFixture(t)
+ fx.seedConnectorForUser(t, "conn-s4", fx.ownerUserID, "tok-s4")
+ taskID := fx.seedQueuedTask(t, "Write tests", "test-writer")
+
+ // Claim first.
+ claimRec := fx.doClaimTask("tok-s4")
+ if claimRec.Code != http.StatusOK {
+ t.Fatalf("claim: %d %s", claimRec.Code, claimRec.Body.String())
+ }
+
+ result := map[string]interface{}{"files": []string{"foo_test.go"}}
+ rec := fx.doSubmitResult("tok-s4", taskID, handlers.SubmitTaskResultRequest{
+ Success: true,
+ Result: mustMarshal(t, result),
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ task, _ := fx.taskStore.GetByID(taskID)
+ if task.DispatchStatus != models.TaskDispatchStatusCompleted {
+ t.Errorf("expected completed, got %s", task.DispatchStatus)
+ }
+ if len(task.ExecutionResult) == 0 {
+ t.Error("expected execution_result to be non-empty")
+ }
+}
+
+// T-6b-5: submit failure → dispatch_status=failed.
+func TestSubmitTaskResult_Failure(t *testing.T) {
+ fx := newDispatchFixture(t)
+ fx.seedConnectorForUser(t, "conn-s5", fx.ownerUserID, "tok-s5")
+ taskID := fx.seedQueuedTask(t, "Review code", "code-reviewer")
+
+ claimRec := fx.doClaimTask("tok-s5")
+ if claimRec.Code != http.StatusOK {
+ t.Fatalf("claim: %d %s", claimRec.Code, claimRec.Body.String())
+ }
+
+ rec := fx.doSubmitResult("tok-s5", taskID, handlers.SubmitTaskResultRequest{
+ Success: false,
+ ErrorMessage: "CLI crashed",
+ ErrorKind: "unknown",
+ })
+ if rec.Code != http.StatusOK {
+ t.Fatalf("expected 200, got %d: %s", rec.Code, rec.Body.String())
+ }
+
+ task, _ := fx.taskStore.GetByID(taskID)
+ if task.DispatchStatus != models.TaskDispatchStatusFailed {
+ t.Errorf("expected failed, got %s", task.DispatchStatus)
+ }
+}
+
+// T-6b-6: submit result for task not in running state → 400.
+func TestSubmitTaskResult_NotRunningReturns400(t *testing.T) {
+ fx := newDispatchFixture(t)
+ fx.seedConnectorForUser(t, "conn-s6", fx.ownerUserID, "tok-s6")
+ // Insert task with dispatch_status=queued (not running) directly.
+ taskID := fx.seedQueuedTask(t, "DB schema", "db-schema-designer")
+ // Do NOT claim it — task stays queued.
+
+ rec := fx.doSubmitResult("tok-s6", taskID, handlers.SubmitTaskResultRequest{
+ Success: true,
+ Result: json.RawMessage(`{"ok":true}`),
+ })
+ if rec.Code != http.StatusBadRequest {
+ t.Fatalf("expected 400, got %d: %s", rec.Code, rec.Body.String())
+ }
+}
+
+// T-6b-7: invalid connector token → 401.
+func TestClaimNextTask_InvalidToken(t *testing.T) {
+ fx := newDispatchFixture(t)
+ rec := fx.doClaimTask("invalid-token-xyz")
+ if rec.Code != http.StatusUnauthorized {
+ t.Fatalf("expected 401, got %d", rec.Code)
+ }
+}
+
+// --- helpers ---
+
+func mustMarshal(t *testing.T, v interface{}) json.RawMessage {
+ t.Helper()
+ b, err := json.Marshal(v)
+ if err != nil {
+ t.Fatalf("marshal: %v", err)
+ }
+ return json.RawMessage(b)
+}
+
+// Ensure planning.DraftPlanner is satisfied — stubPlanner is defined in
+// handlers_test.go in this same package.
+var _ planning.DraftPlanner = stubPlanner{}
diff --git a/backend/internal/handlers/handlers_test.go b/backend/internal/handlers/handlers_test.go
index d508737..2538f90 100644
--- a/backend/internal/handlers/handlers_test.go
+++ b/backend/internal/handlers/handlers_test.go
@@ -1083,22 +1083,27 @@ func TestPlanningRunValidationAndConflict(t *testing.T) {
t.Fatalf("seed draft candidate: %v", err)
}
+ // REJECT the draft candidate, then verify rejected is blocked (400)
+ rejectedStatus := models.BacklogCandidateStatusRejected
+ if _, err := bcs.Update(draftCandidates[0].ID, models.UpdateBacklogCandidateRequest{Status: &rejectedStatus}); err != nil {
+ t.Fatalf("reject conflict candidate: %v", err)
+ }
req = httptest.NewRequest("POST", "/api/backlog-candidates/"+draftCandidates[0].ID+"/apply", nil)
w = httptest.NewRecorder()
srv.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
- t.Fatalf("apply draft backlog candidate: expected 400, got %d: %s", w.Code, w.Body.String())
+ t.Fatalf("apply rejected backlog candidate: expected 400, got %d: %s", w.Code, w.Body.String())
}
- approvedTitle := "Approved conflict candidate"
- approvedStatus := models.BacklogCandidateStatusApproved
- if _, err := bcs.Update(draftCandidates[0].ID, models.UpdateBacklogCandidateRequest{Title: &approvedTitle, Status: &approvedStatus}); err != nil {
- t.Fatalf("approve conflict candidate: %v", err)
+ // Reset to draft, set a unique title, create duplicate task, verify conflict (409)
+ draftStatus := models.BacklogCandidateStatusDraft
+ conflictTitle := "Conflict candidate"
+ if _, err := bcs.Update(draftCandidates[0].ID, models.UpdateBacklogCandidateRequest{Title: &conflictTitle, Status: &draftStatus}); err != nil {
+ t.Fatalf("reset conflict candidate: %v", err)
}
- if _, err := ts.Create(project.ID, models.CreateTaskRequest{Title: approvedTitle, Status: "todo", Priority: "medium", Source: "human"}); err != nil {
+ if _, err := ts.Create(project.ID, models.CreateTaskRequest{Title: conflictTitle, Status: "todo", Priority: "medium", Source: "human"}); err != nil {
t.Fatalf("seed duplicate task: %v", err)
}
-
req = httptest.NewRequest("POST", "/api/backlog-candidates/"+draftCandidates[0].ID+"/apply", nil)
w = httptest.NewRecorder()
srv.ServeHTTP(w, req)
diff --git a/backend/internal/handlers/local_connectors.go b/backend/internal/handlers/local_connectors.go
index ec20c24..b2c67ee 100644
--- a/backend/internal/handlers/local_connectors.go
+++ b/backend/internal/handlers/local_connectors.go
@@ -31,6 +31,9 @@ type LocalConnectorHandler struct {
// CLI binding row so the connector receives cli_command + model_id. Wired
// in main.go via WithAccountBindingStore.
bindings *store.AccountBindingStore
+ // taskStore is optional; when set the Phase 6b dispatch endpoints are
+ // functional. Wired in main.go via WithTaskStore.
+ taskStore *store.TaskStore
}
// WithAccountBindingStore allows the probe-binding handler to look up the
@@ -42,6 +45,14 @@ func (h *LocalConnectorHandler) WithAccountBindingStore(bindings *store.AccountB
return h
}
+// WithTaskStore wires the task store so the Phase 6b dispatch endpoints
+// (claim-next-task, execution-result) are functional. When nil those two
+// endpoints return 500.
+func (h *LocalConnectorHandler) WithTaskStore(tasks *store.TaskStore) *LocalConnectorHandler {
+ h.taskStore = tasks
+ return h
+}
+
func NewLocalConnectorHandler(s *store.LocalConnectorStore, planningRuns *store.PlanningRunStore, requirements *store.RequirementStore, candidates *store.BacklogCandidateStore, agentRuns *store.AgentRunStore) *LocalConnectorHandler {
return &LocalConnectorHandler{store: s, planningRuns: planningRuns, requirements: requirements, candidates: candidates, agentRuns: agentRuns}
}
diff --git a/backend/internal/handlers/planning_runs.go b/backend/internal/handlers/planning_runs.go
index 56f1507..d9370f1 100644
--- a/backend/internal/handlers/planning_runs.go
+++ b/backend/internal/handlers/planning_runs.go
@@ -98,6 +98,10 @@ func (h *PlanningRunHandler) Create(w http.ResponseWriter, r *http.Request) {
writeError(w, http.StatusBadRequest, "invalid execution mode")
return
}
+ if req.AdapterType != "" && req.AdapterType != "backlog" && req.AdapterType != "whatsnext" {
+ writeError(w, http.StatusBadRequest, fmt.Sprintf("invalid adapter_type %q: must be backlog or whatsnext", req.AdapterType))
+ return
+ }
req.ProviderID = ""
requestingUserID := ""
if apiKey := middleware.APIKeyFromContext(r.Context()); apiKey != nil && strings.TrimSpace(req.ExecutionMode) == models.PlanningExecutionModeLocalConnector {
@@ -244,21 +248,18 @@ func (h *PlanningRunHandler) resolvePathBBinding(w http.ResponseWriter, r *http.
Label: cfg.Label,
IsPrimary: cfg.IsPrimary,
}
- // Reject mismatched adapter_type / model_override rather than
- // silently overwriting them. A row that says
- // "adapter_type=whatsnext" but persists a binding_snapshot with
- // provider_id=cli:claude is internally inconsistent and confuses
- // downstream auditing (Critic SHOULD-FIX #3 / Copilot review on
- // PR #23).
- if at := strings.TrimSpace(req.AdapterType); at != "" && at != cfg.ProviderID {
- writeError(w, http.StatusBadRequest, "adapter_type does not match the cli_config provider_id")
- return nil, nil, false
+ // adapter_type is a semantic planning type ("backlog", "whatsnext")
+ // and is independent of cfg.ProviderID ("cli:claude", "cli:codex").
+ // Preserve the caller's intent; default to "backlog" if omitted.
+ // The provider identity is captured in the binding_snapshot.ProviderID
+ // — no need to overwrite adapter_type with it.
+ if strings.TrimSpace(req.AdapterType) == "" {
+ req.AdapterType = "backlog"
}
if mo := strings.TrimSpace(req.ModelOverride); mo != "" && cfg.ModelID != "" && mo != cfg.ModelID {
writeError(w, http.StatusBadRequest, "model_override does not match the cli_config model_id")
return nil, nil, false
}
- req.AdapterType = cfg.ProviderID
req.ModelOverride = cfg.ModelID
// Clear any caller-supplied account_binding_id so it does not get
// persisted alongside the cli_config snapshot — otherwise the
@@ -557,7 +558,14 @@ func (h *PlanningRunHandler) decorateProviderOptions(r *http.Request, options mo
}
options.PairedConnectorAvailable = true
options.ActiveConnectorLabel = connector.Label
- options.AvailableExecutionModes = append(options.AvailableExecutionModes, models.PlanningExecutionModeLocalConnector)
+ // local_connector goes first: for subscription-only users it is the
+ // only usable mode, so it should be the default (index 0).
+ // Users who have both a connector and an API key benefit from the
+ // local path too since it uses their existing CLI subscription.
+ options.AvailableExecutionModes = append(
+ []string{models.PlanningExecutionModeLocalConnector},
+ options.AvailableExecutionModes...,
+ )
return options, nil
}
diff --git a/backend/internal/handlers/requirements.go b/backend/internal/handlers/requirements.go
index be796e0..41a4711 100644
--- a/backend/internal/handlers/requirements.go
+++ b/backend/internal/handlers/requirements.go
@@ -105,6 +105,46 @@ func (h *RequirementHandler) Create(w http.ResponseWriter, r *http.Request) {
writeSuccess(w, http.StatusCreated, requirement, nil)
}
+func (h *RequirementHandler) Delete(w http.ResponseWriter, r *http.Request) {
+ id := chi.URLParam(r, "id")
+ req, err := h.store.GetByID(id)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to verify requirement")
+ return
+ }
+ if req == nil {
+ writeError(w, http.StatusNotFound, "requirement not found")
+ return
+ }
+ if !requestAllowsProject(r, req.ProjectID) {
+ writeError(w, http.StatusForbidden, "api key not allowed for this project")
+ return
+ }
+ hasLineage, err := h.store.HasAppliedTasks(id)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to check requirement lineage")
+ return
+ }
+ if hasLineage {
+ writeError(w, http.StatusConflict, "requirement has applied tasks and cannot be deleted; use archive instead")
+ return
+ }
+ hasActive, err := h.store.HasActiveRun(id)
+ if err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to check active runs")
+ return
+ }
+ if hasActive {
+ writeError(w, http.StatusConflict, "requirement has an active planning run; wait for it to complete before deleting")
+ return
+ }
+ if err := h.store.Delete(id); err != nil {
+ writeError(w, http.StatusInternalServerError, "failed to delete requirement")
+ return
+ }
+ writeSuccess(w, http.StatusOK, nil, nil)
+}
+
func (h *RequirementHandler) Update(w http.ResponseWriter, r *http.Request) {
id := chi.URLParam(r, "id")
requirement, err := h.store.GetByID(id)
diff --git a/backend/internal/models/task.go b/backend/internal/models/task.go
index a34394b..027f64c 100644
--- a/backend/internal/models/task.go
+++ b/backend/internal/models/task.go
@@ -1,18 +1,32 @@
package models
-import "time"
+import (
+ "encoding/json"
+ "time"
+)
+
+// Task dispatch status constants. Phase 6b.
+const (
+ TaskDispatchStatusNone = "none"
+ TaskDispatchStatusQueued = "queued"
+ TaskDispatchStatusRunning = "running"
+ TaskDispatchStatusCompleted = "completed"
+ TaskDispatchStatusFailed = "failed"
+)
type Task struct {
- ID string `json:"id"`
- ProjectID string `json:"project_id"`
- Title string `json:"title"`
- Description string `json:"description"`
- Status string `json:"status"`
- Priority string `json:"priority"`
- Assignee string `json:"assignee"`
- Source string `json:"source"`
- CreatedAt time.Time `json:"created_at"`
- UpdatedAt time.Time `json:"updated_at"`
+ ID string `json:"id"`
+ ProjectID string `json:"project_id"`
+ Title string `json:"title"`
+ Description string `json:"description"`
+ Status string `json:"status"`
+ Priority string `json:"priority"`
+ Assignee string `json:"assignee"`
+ Source string `json:"source"`
+ DispatchStatus string `json:"dispatch_status"`
+ ExecutionResult json.RawMessage `json:"execution_result,omitempty"`
+ CreatedAt time.Time `json:"created_at"`
+ UpdatedAt time.Time `json:"updated_at"`
}
type CreateTaskRequest struct {
diff --git a/backend/internal/router/router.go b/backend/internal/router/router.go
index b2135c0..9e936ed 100644
--- a/backend/internal/router/router.go
+++ b/backend/internal/router/router.go
@@ -92,6 +92,9 @@ func New(deps Deps) http.Handler {
r.Post("/connector/heartbeat", deps.LocalConnectorHandler.Heartbeat)
r.Post("/connector/claim-next-run", deps.LocalConnectorHandler.ClaimNextRun)
r.Post("/connector/planning-runs/{id}/result", deps.LocalConnectorHandler.SubmitPlanningRunResult)
+ // Phase 6b: role_dispatch task execution loop.
+ r.Post("/connector/claim-next-task", deps.LocalConnectorHandler.ClaimNextTask)
+ r.Post("/connector/tasks/{task_id}/execution-result", deps.LocalConnectorHandler.SubmitTaskResult)
}
// ── Auth (public) ──────────────────────────────────────────────
@@ -121,6 +124,7 @@ func New(deps Deps) http.Handler {
r.Post("/projects/{id}/requirements", deps.RequirementHandler.Create)
r.Get("/requirements/{id}", deps.RequirementHandler.Get)
r.Patch("/requirements/{id}", deps.RequirementHandler.Update)
+ r.Delete("/requirements/{id}", deps.RequirementHandler.Delete)
}
if deps.PlanningRunHandler != nil {
r.Get("/projects/{id}/planning-provider-options", deps.PlanningRunHandler.ProviderOptions)
diff --git a/backend/internal/store/backlog_candidate_store.go b/backend/internal/store/backlog_candidate_store.go
index b3243c0..961ce23 100644
--- a/backend/internal/store/backlog_candidate_store.go
+++ b/backend/internal/store/backlog_candidate_store.go
@@ -384,7 +384,7 @@ func (s *BacklogCandidateStore) ApplyToTaskWithMode(id, executionMode string) (*
}
}
- if candidate.Status != models.BacklogCandidateStatusApproved && candidate.Status != models.BacklogCandidateStatusApplied {
+ if candidate.Status == models.BacklogCandidateStatusRejected {
return nil, ErrBacklogCandidateNotApproved
}
@@ -593,13 +593,25 @@ func cloneEvidenceDetail(detail models.PlanningEvidenceDetail) models.PlanningEv
func scanTask(row rowScanner) (*models.Task, error) {
var task models.Task
- err := row.Scan(&task.ID, &task.ProjectID, &task.Title, &task.Description, &task.Status, &task.Priority, &task.Assignee, &task.Source, &task.CreatedAt, &task.UpdatedAt)
+ var executionResultRaw sql.NullString
+ err := row.Scan(
+ &task.ID, &task.ProjectID, &task.Title, &task.Description,
+ &task.Status, &task.Priority, &task.Assignee, &task.Source,
+ &task.DispatchStatus, &executionResultRaw,
+ &task.CreatedAt, &task.UpdatedAt,
+ )
if err == sql.ErrNoRows {
return nil, nil
}
if err != nil {
return nil, err
}
+ if executionResultRaw.Valid && executionResultRaw.String != "" {
+ task.ExecutionResult = json.RawMessage(executionResultRaw.String)
+ }
+ if task.DispatchStatus == "" {
+ task.DispatchStatus = models.TaskDispatchStatusNone
+ }
return &task, nil
}
@@ -729,7 +741,8 @@ func getTaskLineageByCandidateID(tx *sql.Tx, candidateID string) (*models.TaskLi
func getTaskByID(tx *sql.Tx, id string) (*models.Task, error) {
return scanTask(
tx.QueryRow(`
- SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at
+ SELECT id, project_id, title, description, status, priority, assignee, source,
+ dispatch_status, execution_result, created_at, updated_at
FROM tasks
WHERE id = $1
`, id),
@@ -773,7 +786,8 @@ func (s *BacklogCandidateStore) lockCandidateApplyKey(tx *sql.Tx, projectID, nor
func findOpenTaskByNormalizedTitle(tx *sql.Tx, projectID, normalizedTitle string) (*models.Task, error) {
return scanTask(
tx.QueryRow(`
- SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at
+ SELECT id, project_id, title, description, status, priority, assignee, source,
+ dispatch_status, execution_result, created_at, updated_at
FROM tasks
WHERE project_id = $1
AND status IN ('todo', 'in_progress')
@@ -791,6 +805,9 @@ func createAppliedCandidateTask(tx *sql.Tx, projectID, title, description string
// createAppliedCandidateTaskWithSource is the Phase-5-aware variant.
// Phase 4 callers go through createAppliedCandidateTask which pins the
// source to the pre-existing AppliedCandidateTaskSource sentinel.
+//
+// Phase 6b: when source starts with "role_dispatch" the task is given
+// dispatch_status = 'queued' so the connector polling loop can claim it.
func createAppliedCandidateTaskWithSource(tx *sql.Tx, projectID, title, description, source string) (*models.Task, error) {
id := uuid.New().String()
now := time.Now().UTC()
@@ -801,25 +818,32 @@ func createAppliedCandidateTaskWithSource(tx *sql.Tx, projectID, title, descript
trimmedSource = appliedCandidateTaskSource
}
+ // Phase 6b: role_dispatch tasks enter the connector queue immediately.
+ dispatchStatus := models.TaskDispatchStatusNone
+ if strings.HasPrefix(trimmedSource, "role_dispatch") {
+ dispatchStatus = models.TaskDispatchStatusQueued
+ }
+
_, err := tx.Exec(`
- INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, source, created_at, updated_at)
- VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $9)
- `, id, projectID, trimmedTitle, trimmedDescription, "todo", "medium", "", trimmedSource, now)
+ INSERT INTO tasks (id, project_id, title, description, status, priority, assignee, source, dispatch_status, created_at, updated_at)
+ VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $10)
+ `, id, projectID, trimmedTitle, trimmedDescription, "todo", "medium", "", trimmedSource, dispatchStatus, now)
if err != nil {
return nil, err
}
return &models.Task{
- ID: id,
- ProjectID: projectID,
- Title: trimmedTitle,
- Description: trimmedDescription,
- Status: "todo",
- Priority: "medium",
- Assignee: "",
- Source: trimmedSource,
- CreatedAt: now,
- UpdatedAt: now,
+ ID: id,
+ ProjectID: projectID,
+ Title: trimmedTitle,
+ Description: trimmedDescription,
+ Status: "todo",
+ Priority: "medium",
+ Assignee: "",
+ Source: trimmedSource,
+ DispatchStatus: dispatchStatus,
+ CreatedAt: now,
+ UpdatedAt: now,
}, nil
}
diff --git a/backend/internal/store/backlog_candidate_store_test.go b/backend/internal/store/backlog_candidate_store_test.go
index 99c97ad..5a3403a 100644
--- a/backend/internal/store/backlog_candidate_store_test.go
+++ b/backend/internal/store/backlog_candidate_store_test.go
@@ -319,15 +319,20 @@ func TestBacklogCandidateStoreApplyToTaskRejectsDraftAndDuplicate(t *testing.T)
}
candidate := created[0]
+ // Rejected candidates must be blocked
+ rejected := models.BacklogCandidateStatusRejected
+ if _, err := store.Update(candidate.ID, models.UpdateBacklogCandidateRequest{Status: &rejected}); err != nil {
+ t.Fatalf("reject candidate: %v", err)
+ }
if _, err := store.ApplyToTask(candidate.ID); !errors.Is(err, ErrBacklogCandidateNotApproved) {
- t.Fatalf("expected ErrBacklogCandidateNotApproved, got %v", err)
+ t.Fatalf("expected ErrBacklogCandidateNotApproved for rejected candidate, got %v", err)
}
- approved := models.BacklogCandidateStatusApproved
- if _, err := store.Update(candidate.ID, models.UpdateBacklogCandidateRequest{Status: &approved}); err != nil {
- t.Fatalf("approve candidate: %v", err)
+ // Reset to draft and test duplicate conflict (draft CAN be applied, but duplicate wins)
+ draft := models.BacklogCandidateStatusDraft
+ if _, err := store.Update(candidate.ID, models.UpdateBacklogCandidateRequest{Status: &draft}); err != nil {
+ t.Fatalf("reset candidate to draft: %v", err)
}
-
taskStore := NewTaskStore(store.db)
if _, err := taskStore.Create(requirement.ProjectID, models.CreateTaskRequest{
Title: candidate.Title,
@@ -337,7 +342,6 @@ func TestBacklogCandidateStoreApplyToTaskRejectsDraftAndDuplicate(t *testing.T)
}); err != nil {
t.Fatalf("seed duplicate task: %v", err)
}
-
var conflictErr *BacklogCandidateTaskConflictError
if _, err := store.ApplyToTask(candidate.ID); !errors.As(err, &conflictErr) {
t.Fatalf("expected BacklogCandidateTaskConflictError, got %v", err)
diff --git a/backend/internal/store/requirement_store.go b/backend/internal/store/requirement_store.go
index c652dbc..4b4065d 100644
--- a/backend/internal/store/requirement_store.go
+++ b/backend/internal/store/requirement_store.go
@@ -171,3 +171,37 @@ func (s *RequirementStore) PromoteToPlannedIfDraft(id string) error {
`, now, id)
return err
}
+
+// HasAppliedTasks returns true if any task_lineage row references this requirement.
+func (s *RequirementStore) HasAppliedTasks(id string) (bool, error) {
+ var count int
+ err := s.db.QueryRow(`SELECT COUNT(*) FROM task_lineage WHERE requirement_id = $1`, id).Scan(&count)
+ return count > 0, err
+}
+
+// HasActiveRun returns true if any planning run for this requirement is in an active state
+// (status = queued/running or dispatch_status = queued/leased).
+func (s *RequirementStore) HasActiveRun(id string) (bool, error) {
+ var count int
+ err := s.db.QueryRow(`
+ SELECT COUNT(*) FROM planning_runs
+ WHERE requirement_id = $1
+ AND (status IN ('queued', 'running') OR dispatch_status IN ('queued', 'leased'))
+ `, id).Scan(&count)
+ return count > 0, err
+}
+
+// Delete permanently removes a requirement and cascades to planning_runs and backlog_candidates.
+// Callers must verify HasAppliedTasks and HasActiveRun return false before calling this.
+func (s *RequirementStore) Delete(id string) error {
+ tx, err := s.db.Begin()
+ if err != nil {
+ return err
+ }
+ defer func() { _ = tx.Rollback() }()
+ _, err = tx.Exec(`DELETE FROM requirements WHERE id = $1`, id)
+ if err != nil {
+ return err
+ }
+ return tx.Commit()
+}
diff --git a/backend/internal/store/task_store.go b/backend/internal/store/task_store.go
index 2d056ac..6b4707e 100644
--- a/backend/internal/store/task_store.go
+++ b/backend/internal/store/task_store.go
@@ -2,26 +2,71 @@ package store
import (
"database/sql"
+ "encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/google/uuid"
+ "github.com/screenleon/agent-native-pm/internal/database"
"github.com/screenleon/agent-native-pm/internal/models"
)
type TaskStore struct {
- db *sql.DB
+ db *sql.DB
+ dialect database.Dialect
}
var ErrTaskBatchNotFound = errors.New("one or more tasks not found in project")
var ErrTaskBatchEmpty = errors.New("task batch update requires at least one task id")
+// ErrDispatchOwnership is returned when a connector tries to operate on a task
+// it does not own (via the project membership check).
+var ErrDispatchOwnership = errors.New("connector does not have ownership over this task")
+
func NewTaskStore(db *sql.DB) *TaskStore {
return &TaskStore{db: db}
}
+// NewTaskStoreWithDialect creates a TaskStore with an explicit dialect, required
+// for the transaction-based dispatch methods.
+func NewTaskStoreWithDialect(db *sql.DB, dialect database.Dialect) *TaskStore {
+ return &TaskStore{db: db, dialect: dialect}
+}
+
+// taskColumns is the canonical column list for scanning a full Task row.
+// Keep in sync with scanTask / scanTaskFull.
+const taskColumns = `id, project_id, title, description, status, priority, assignee, source,
+ dispatch_status, execution_result, created_at, updated_at`
+
+// scanTaskFull scans all columns including the Phase 6b dispatch fields.
+func scanTaskFull(row interface {
+ Scan(dest ...interface{}) error
+}) (*models.Task, error) {
+ var t models.Task
+ var executionResultRaw sql.NullString
+ err := row.Scan(
+ &t.ID, &t.ProjectID, &t.Title, &t.Description,
+ &t.Status, &t.Priority, &t.Assignee, &t.Source,
+ &t.DispatchStatus, &executionResultRaw,
+ &t.CreatedAt, &t.UpdatedAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if executionResultRaw.Valid && executionResultRaw.String != "" {
+ t.ExecutionResult = json.RawMessage(executionResultRaw.String)
+ }
+ if t.DispatchStatus == "" {
+ t.DispatchStatus = models.TaskDispatchStatusNone
+ }
+ return &t, nil
+}
+
func (s *TaskStore) ListByProject(projectID string, page, perPage int, sort, order string, filters models.TaskListFilters) ([]models.Task, int, error) {
whereClause, filterArgs, nextPos := buildTaskListWhereClause(projectID, filters)
@@ -72,9 +117,9 @@ func (s *TaskStore) ListByProject(projectID string, page, perPage int, sort, ord
orderClause := fmt.Sprintf("ORDER BY %s %s", sort, order)
queryArgs := append(append([]interface{}{}, filterArgs...), perPage, offset)
query := fmt.Sprintf(`
- SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at
+ SELECT %s
FROM tasks %s %s LIMIT $%d OFFSET $%d
- `, whereClause, orderClause, nextPos, nextPos+1)
+ `, taskColumns, whereClause, orderClause, nextPos, nextPos+1)
rows, err := s.db.Query(query, queryArgs...)
if err != nil {
return nil, 0, err
@@ -83,11 +128,13 @@ func (s *TaskStore) ListByProject(projectID string, page, perPage int, sort, ord
var tasks []models.Task
for rows.Next() {
- var t models.Task
- if err := rows.Scan(&t.ID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.Priority, &t.Assignee, &t.Source, &t.CreatedAt, &t.UpdatedAt); err != nil {
+ t, err := scanTaskFull(rows)
+ if err != nil {
return nil, 0, err
}
- tasks = append(tasks, t)
+ if t != nil {
+ tasks = append(tasks, *t)
+ }
}
return tasks, total, rows.Err()
}
@@ -117,18 +164,9 @@ func buildTaskListWhereClause(projectID string, filters models.TaskListFilters)
}
func (s *TaskStore) GetByID(id string) (*models.Task, error) {
- var t models.Task
- err := s.db.QueryRow(`
- SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at
- FROM tasks WHERE id = $1
- `, id).Scan(&t.ID, &t.ProjectID, &t.Title, &t.Description, &t.Status, &t.Priority, &t.Assignee, &t.Source, &t.CreatedAt, &t.UpdatedAt)
- if err == sql.ErrNoRows {
- return nil, nil
- }
- if err != nil {
- return nil, err
- }
- return &t, nil
+ return scanTaskFull(s.db.QueryRow(
+ `SELECT `+taskColumns+` FROM tasks WHERE id = $1`, id,
+ ))
}
func (s *TaskStore) Create(projectID string, req models.CreateTaskRequest) (*models.Task, error) {
@@ -262,11 +300,13 @@ func (s *TaskStore) BatchUpdate(projectID string, taskIDs []string, changes mode
byID := make(map[string]models.Task, len(normalizedIDs))
for rows.Next() {
- var task models.Task
- if err := rows.Scan(&task.ID, &task.ProjectID, &task.Title, &task.Description, &task.Status, &task.Priority, &task.Assignee, &task.Source, &task.CreatedAt, &task.UpdatedAt); err != nil {
+ t, err := scanTaskFull(rows)
+ if err != nil {
return nil, err
}
- byID[task.ID] = task
+ if t != nil {
+ byID[t.ID] = *t
+ }
}
if err := rows.Err(); err != nil {
return nil, err
@@ -311,6 +351,197 @@ func (s *TaskStore) CountByProjectAndStatus(projectID string) (map[string]int, e
return counts, rows.Err()
}
+// ─── Phase 6b: dispatch methods ──────────────────────────────────────────────
+
+// ClaimNextDispatchTask atomically finds the next queued role_dispatch task
+// belonging to a project the connector's user is a member of, marks it as
+// "running", and returns the task together with its requirement.
+//
+// Ownership check: the task's project_id must appear in project_members where
+// user_id = connectorUserID. A SQLite write-lock is acquired via a no-op
+// UPDATE before the SELECT so concurrent claim attempts serialise.
+//
+// Returns (nil, nil, nil) when the queue is empty for this connector's user.
+func (s *TaskStore) ClaimNextDispatchTask(connectorID, connectorUserID string) (*models.Task, *models.Requirement, error) {
+ var tx *sql.Tx
+ var err error
+ if s.dialect.IsSQLite() {
+ tx, err = s.db.Begin()
+ } else {
+ tx, err = s.db.Begin()
+ }
+ if err != nil {
+ return nil, nil, fmt.Errorf("begin transaction: %w", err)
+ }
+ defer func() { _ = tx.Rollback() }()
+
+ // On SQLite force an immediate write lock before the SELECT so concurrent
+ // claim attempts serialise (same pattern as lockCandidateApplyKey).
+ if s.dialect.IsSQLite() {
+ if _, err := tx.Exec(`UPDATE tasks SET updated_at = updated_at WHERE 1 = 0`); err != nil {
+ return nil, nil, fmt.Errorf("sqlite write lock: %w", err)
+ }
+ }
+
+ // Find the oldest queued role_dispatch task in any project the connector's
+ // user is a member of (via project_members).
+ forUpdate := s.dialect.ForUpdate()
+ skipLocked := s.dialect.SkipLocked()
+ query := fmt.Sprintf(`
+ SELECT t.id, t.project_id, t.title, t.description, t.status, t.priority,
+ t.assignee, t.source, t.dispatch_status, t.execution_result,
+ t.created_at, t.updated_at
+ FROM tasks t
+ INNER JOIN project_members pm ON pm.project_id = t.project_id
+ WHERE t.dispatch_status = $1
+ AND pm.user_id = $2
+ AND t.source LIKE $3
+ ORDER BY t.created_at ASC, t.id ASC
+ LIMIT 1
+ %s%s
+ `, forUpdate, skipLocked)
+
+ row := tx.QueryRow(query, models.TaskDispatchStatusQueued, connectorUserID, "role_dispatch%")
+ task, err := scanTaskFull(row)
+ if err != nil {
+ return nil, nil, fmt.Errorf("query queued task: %w", err)
+ }
+ if task == nil {
+ // Queue empty — commit and return nil.
+ _ = tx.Commit()
+ return nil, nil, nil
+ }
+
+ // Mark as running.
+ now := time.Now().UTC()
+ if _, err := tx.Exec(
+ `UPDATE tasks SET dispatch_status = $1, updated_at = $2 WHERE id = $3`,
+ models.TaskDispatchStatusRunning, now, task.ID,
+ ); err != nil {
+ return nil, nil, fmt.Errorf("mark task running: %w", err)
+ }
+ task.DispatchStatus = models.TaskDispatchStatusRunning
+ task.UpdatedAt = now
+
+ // Load the requirement via task_lineage so we can return context.
+ var req *models.Requirement
+ req, err = getRequirementForTask(tx, task.ID)
+ if err != nil {
+ // Non-fatal: we can proceed without requirement context.
+ req = nil
+ }
+
+ if err := tx.Commit(); err != nil {
+ return nil, nil, fmt.Errorf("commit claim: %w", err)
+ }
+ return task, req, nil
+}
+
+// getRequirementForTask joins task_lineage → requirements to find the
+// requirement associated with the task.
+func getRequirementForTask(tx *sql.Tx, taskID string) (*models.Requirement, error) {
+ var req models.Requirement
+ var summary, description, source, audience, successCriteria sql.NullString
+ err := tx.QueryRow(`
+ SELECT r.id, r.project_id, COALESCE(r.title,''), COALESCE(r.summary,''),
+ COALESCE(r.description,''), COALESCE(r.status,''), COALESCE(r.source,''),
+ COALESCE(r.audience,''), COALESCE(r.success_criteria,''),
+ r.created_at, r.updated_at
+ FROM requirements r
+ INNER JOIN task_lineage tl ON tl.requirement_id = r.id
+ WHERE tl.task_id = $1
+ ORDER BY tl.created_at ASC
+ LIMIT 1
+ `, taskID).Scan(
+ &req.ID, &req.ProjectID, &req.Title, &summary,
+ &description, &req.Status, &source,
+ &audience, &successCriteria,
+ &req.CreatedAt, &req.UpdatedAt,
+ )
+ if err == sql.ErrNoRows {
+ return nil, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+ if summary.Valid {
+ req.Summary = summary.String
+ }
+ if description.Valid {
+ req.Description = description.String
+ }
+ if source.Valid {
+ req.Source = source.String
+ }
+ if audience.Valid {
+ req.Audience = audience.String
+ }
+ if successCriteria.Valid {
+ req.SuccessCriteria = successCriteria.String
+ }
+ return &req, nil
+}
+
+// CompleteDispatchTask marks a task as completed and stores the result JSON.
+// The connectorUserID parameter ensures cross-user protection via the project
+// ownership check.
+func (s *TaskStore) CompleteDispatchTask(taskID, connectorUserID string, result json.RawMessage) error {
+ return s.updateDispatchStatus(taskID, connectorUserID, models.TaskDispatchStatusCompleted, "", result)
+}
+
+// FailDispatchTask marks a task as failed and records an error message in the
+// result JSON.
+func (s *TaskStore) FailDispatchTask(taskID, connectorUserID, errorMsg string) error {
+ errJSON, _ := json.Marshal(map[string]string{
+ "error_message": errorMsg,
+ "error_kind": "dispatch_failed",
+ })
+ return s.updateDispatchStatus(taskID, connectorUserID, models.TaskDispatchStatusFailed, "", json.RawMessage(errJSON))
+}
+
+func (s *TaskStore) updateDispatchStatus(taskID, connectorUserID, status, _ string, result json.RawMessage) error {
+ now := time.Now().UTC()
+ var resultStr *string
+ if len(result) > 0 {
+ str := string(result)
+ resultStr = &str
+ }
+ res, err := s.db.Exec(`
+ UPDATE tasks
+ SET dispatch_status = $1,
+ execution_result = $2,
+ updated_at = $3
+ WHERE id = $4
+ AND project_id IN (
+ SELECT project_id FROM project_members WHERE user_id = $5
+ )
+ `, status, resultStr, now, taskID, connectorUserID)
+ if err != nil {
+ return fmt.Errorf("update dispatch status: %w", err)
+ }
+ n, _ := res.RowsAffected()
+ if n == 0 {
+ return ErrDispatchOwnership
+ }
+ return nil
+}
+
+// GetTaskForConnector returns a task only if it is owned by the connector's
+// user (via project_members membership). Used by the execution-result handler
+// to verify the connector has rights before accepting a result.
+func (s *TaskStore) GetTaskForConnector(taskID, connectorUserID string) (*models.Task, error) {
+ return scanTaskFull(s.db.QueryRow(`
+ SELECT t.id, t.project_id, t.title, t.description, t.status, t.priority,
+ t.assignee, t.source, t.dispatch_status, t.execution_result,
+ t.created_at, t.updated_at
+ FROM tasks t
+ INNER JOIN project_members pm ON pm.project_id = t.project_id
+ WHERE t.id = $1 AND pm.user_id = $2
+ `, taskID, connectorUserID))
+}
+
+// ─── helpers ─────────────────────────────────────────────────────────────────
+
func normalizeTaskIDs(taskIDs []string) []string {
seen := make(map[string]bool, len(taskIDs))
normalized := make([]string, 0, len(taskIDs))
@@ -370,7 +601,7 @@ func buildTaskBatchSelectQuery(projectID string, taskIDs []string) (string, []in
args = append(args, id)
}
return fmt.Sprintf(`
- SELECT id, project_id, title, description, status, priority, assignee, source, created_at, updated_at
+ SELECT `+taskColumns+`
FROM tasks WHERE project_id = $1 AND id IN (%s)
`, buildPositionalPlaceholders(2, len(taskIDs))), args
}
diff --git a/docs/api-surface.md b/docs/api-surface.md
index 2224366..e515200 100644
--- a/docs/api-surface.md
+++ b/docs/api-surface.md
@@ -353,6 +353,8 @@ Source: `[agent:backend-architect]`
| POST | `/api/connector/heartbeat` | Refresh connector presence using `X-Connector-Token` |
| POST | `/api/connector/claim-next-run` | Lease the next queued local-connector planning run for the connector owner |
| POST | `/api/connector/planning-runs/:id/result` | Return success or failure for one leased planning run |
+| POST | `/api/connector/claim-next-task` | (Phase 6b) Claim the next queued role-dispatch task for the connector owner |
+| POST | `/api/connector/tasks/:task_id/execution-result` | (Phase 6b) Submit execution result for a claimed task |
Behavior:
@@ -369,6 +371,8 @@ Behavior:
- `POST /api/me/local-connectors/:id/probe-binding` (Phase 4 P4-4) is authenticated and takes `{ binding_id }`. The referenced binding MUST belong to the same user and MUST have a `cli:*` provider_id; otherwise 400/404 is returned. The server enqueues a pending-probe entry on the named connector's `metadata.pending_cli_probe_requests[]`. If a probe for the same binding is already in-flight on that connector, the existing `probe_id` is returned (idempotent). Returns `{ probe_id }`. If the connector's pending-probe list is already at the hard cap (64 entries, see `DECISIONS.md` 2026-04-24 "P4-4 probe pipeline"), the handler returns HTTP 429.
- `GET /api/me/local-connectors/:id/probe-binding/:probe_id` (Phase 4 P4-4) returns `{ status: "pending" | "completed" | "not_found", result? }`. The `result` block is populated only when `status == "completed"` and matches the `CliProbeResult` shape. Stored results are retained for 24 hours; callers that poll past that window receive `not_found`.
- Connector tokens are distinct from session tokens and API keys.
+- `POST /api/connector/claim-next-task` (Phase 6b) requires `X-Connector-Token`. Claims one task with `dispatch_status = 'queued'` whose project has the connector's owner as a `project_members` row. Returns `{ task, requirement }` where `task` is null when the queue is empty. Sets the task's `dispatch_status = 'running'` atomically.
+- `POST /api/connector/tasks/:task_id/execution-result` (Phase 6b) requires `X-Connector-Token`. Accepts `{ success, result?, error_message?, error_kind? }`. The task MUST already be in `dispatch_status = 'running'` (owned by the connector's user); otherwise 400. On success: `dispatch_status = 'completed'`, `execution_result` stored as JSON. On failure: `dispatch_status = 'failed'`. `error_kind` is validated against the same allowlist as planning runs (`session_expired`, `rate_limited`, `context_overflow`, `adapter_timeout`, `unknown`); values outside the list are normalised to `"unknown"`.
### Planning Settings
diff --git a/docs/data-model.md b/docs/data-model.md
index 8cc07ae..25bcc66 100644
--- a/docs/data-model.md
+++ b/docs/data-model.md
@@ -87,10 +87,19 @@ Notes:
| `status` | TEXT | NOT NULL DEFAULT 'todo' | `todo`, `in_progress`, `done`, `cancelled` |
| `priority` | TEXT | DEFAULT 'medium' | `low`, `medium`, `high` |
| `assignee` | TEXT | DEFAULT '' | Human name or agent identifier |
-| `source` | TEXT | DEFAULT '' | `human` or `agent:` |
+| `source` | TEXT | DEFAULT '' | `human` or `agent:` or `role_dispatch:` |
+| `dispatch_status` | TEXT | NOT NULL DEFAULT 'none' | (Phase 6b, migration 029) `none`, `queued`, `running`, `completed`, `failed` |
+| `execution_result` | JSONB | | (Phase 6b, migration 029) Raw JSON result from connector execution; null until completed or failed |
| `created_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
| `updated_at` | TIMESTAMPTZ | NOT NULL DEFAULT NOW() | |
+Notes (tasks):
+- `dispatch_status = 'queued'` is set automatically at task creation when `source` starts with `role_dispatch:`.
+- `dispatch_status = 'running'` is set when a connector claims the task via `POST /api/connector/claim-next-task`.
+- Terminal states (`completed`, `failed`) are set by `POST /api/connector/tasks/:task_id/execution-result`.
+- Ownership check uses the `project_members` table (not `projects.user_id`); only connectors whose owner is a member of the task's project can claim or submit results.
+- Added by migration `029_task_dispatch.sql`.
+
## Planning Foundation Tables
### Table: `planning_settings`
diff --git a/docs/phase6b-plan.md b/docs/phase6b-plan.md
new file mode 100644
index 0000000..d5d2822
--- /dev/null
+++ b/docs/phase6b-plan.md
@@ -0,0 +1,193 @@
+# Phase 6b 計畫 — 關閉執行迴圈(Role Dispatch)
+
+**Status**: draft · 2026-04-25 · `[agent:feature-planner]`
+**前置條件**: Phase 6a(PR #23, #24)已合併到 `main`。
+
+---
+
+## 1. 問題陳述
+
+Phase 1 ~ 6a 完成了「要件 → Planning Run → 候補清單 → 審查 → Apply → Task」的前半迴圈。
+
+Apply 時若選 `execution_mode: "role_dispatch"`,Task 的 `source` 被打上 `"role_dispatch:backend-architect"` 標記,但沒有任何程式去「執行」它。
+
+Phase 5 DECISIONS.md 的明確約束:
+> "Phase 6 MUST introduce catalog enforcement for execution_role before shipping auto-dispatch."
+> "Phase 6 blocker: the dispatcher that consumes role prompts MUST treat {{TASK_DESCRIPTION}} and {{PROJECT_CONTEXT}} as untrusted."
+
+**結論**:Phase 6b 必要,是因為目前 `role_dispatch` task 是一個只有標籤、沒有執行的死路。不關閉這個迴圈,「AI agents 直接參與開發流程」的核心價值主張就沒有完成。
+
+---
+
+## 2. End State
+
+完成後的使用者操作路徑:
+1. 在候補清單審查頁面,將某個候補設定 `execution_role = "backend-architect"`
+2. Apply 時選擇 `execution_mode = "role_dispatch"`
+3. Task 建立,source = `"role_dispatch:backend-architect"`
+4. Connector 在輪詢 claim-next-run(planning run)之外,**額外輪詢** `claim-next-task`
+5. Connector 拿到 task,讀取 `source` 得知角色,用 `prompts.Render("roles/backend-architect", vars)` 組 prompt
+6. Connector 呼叫 Claude CLI 執行 prompt
+7. Claude 回傳結構化 JSON(`files`, `test_instructions`, `risks`, `followups`)
+8. Connector 把結果 POST 回伺服器
+9. 伺服器將 task `dispatch_status` 更新為 `completed`,並寫入 `execution_result` + `agent_runs` 記錄
+10. Task 卡片顯示執行結果(可展開的 result panel)
+11. 人類審核後手動將 task 推進到 `done`
+
+---
+
+## 3. Slice 計畫
+
+### Slice B1:DB schema(0.5 天)
+
+**Scope**:Migration 029,在 `tasks` 表新增兩個欄位。
+
+```sql
+-- 029_task_dispatch.sql
+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);
+```
+
+`dispatch_status` 狀態機:`none` → `queued`(apply 時若 role_dispatch)→ `running`(claim 時)→ `completed`/`failed`
+
+**DoD**:Migration 通過 SQLite + PostgreSQL 測試,現有 task row 的 `dispatch_status = 'none'`。
+
+---
+
+### Slice B2:後端 Task Dispatch 端點(1.5 天)
+
+**新增端點**:
+
+```
+POST /api/connector/claim-next-task
+POST /api/connector/tasks/:id/execution-result
+```
+
+**claim-next-task Response**:
+```jsonc
+{
+ "task": {
+ "id": "uuid",
+ "title": "...",
+ "description": "...",
+ "source": "role_dispatch:backend-architect",
+ "dispatch_status": "running"
+ },
+ "requirement": { "id": "uuid", "title": "...", "summary": "..." },
+ "project_context": "",
+ "cli_binding": { ... } // connector primary cli_config snapshot
+}
+```
+
+**execution-result Request**:
+```jsonc
+{
+ "success": true,
+ "error_message": "",
+ "error_kind": "session_expired",
+ "result": { "files": [...], "test_instructions": "...", "risks": [...], "followups": [...] }
+}
+```
+
+**DoD**(9 test cases):
+
+| T-6b-B2-1 | claim:queue 為空 | 回傳 `{ task: null }` |
+| T-6b-B2-2 | claim:有 queued task,connector 是 project member | task claimed,dispatch_status=running |
+| T-6b-B2-3 | claim:connector user 不是 project member | 回傳空 |
+| T-6b-B2-4 | claim:execution_role 不在 catalog | 回傳空(skip) |
+| T-6b-B2-5 | execution-result success | dispatch_status=completed,execution_result 儲存 |
+| T-6b-B2-6 | execution-result failure | dispatch_status=failed |
+| T-6b-B2-7 | execution-result unknown error_kind | normalized to "unknown" |
+| T-6b-B2-8 | execution-result:task 不屬於 connector user | 404 |
+| T-6b-B2-9 | execution-result:task dispatch_status != "running" | 400 |
+
+---
+
+### Slice B3:Connector Task 執行 loop(2 天)
+
+在 `connector/service.go` 的主 loop 中,新增 `RunOnceTask(ctx)` 方法:
+
+1. 呼叫 `client.ClaimNextTask()`
+2. 解析 `task.source` 得到 `role_id`(`"role_dispatch:backend-architect"` → `"backend-architect"`)
+3. Catalog enforcement:`prompts.Exists("roles/" + roleID)`,若不存在則送失敗結果
+4. 呼叫 `resolveBuiltinCLI()`(複用現有函式)
+5. 建構 vars:`TASK_TITLE`, `TASK_DESCRIPTION`, `REQUIREMENT`, `PROJECT_CONTEXT`
+6. 呼叫 `prompts.Render("roles/"+roleID, vars)`
+7. 呼叫 `invokeBuiltinCLI()`(複用現有函式)
+8. 呼叫 `extractJSONFromOutput()` 解析輸出
+9. POST 回 `tasks/:id/execution-result`
+
+**DoD**(6 test cases):RunOnceTask 的各種 CLI 結果、catalog missing、JSON parse 失敗等路徑。
+
+---
+
+### Slice B4:前端 Task card 執行結果 panel(1 天)
+
+- `GET /api/projects/:id/tasks` 回傳新欄位(`dispatch_status`, `execution_result`)
+- TasksTab:
+ - `dispatch_status = "running"` → "In progress" badge
+ - `dispatch_status = "completed"` → result panel(files 清單、test_instructions、risks)
+ - `dispatch_status = "failed"` → error message
+
+---
+
+### Slice B5:Apply UI 啟用 role_dispatch(0.5 天)
+
+- `CandidateReviewPanel` 移除 role_dispatch radio 的 `disabled` 狀態
+- 只有當 `execution_role` 有值且存在於 role catalog 時才可選
+- Apply 後 task `dispatch_status = "queued"`
+
+---
+
+## 4. 實作順序
+
+```
+B1 → B2(handler + store)→ B5(apply 端)→ B3(connector)→ B4(前端 result panel)
+```
+
+---
+
+## 5. 非目標(Non-Goals)
+
+- Docker / sandbox(deferred)
+- API key mode(只支援 local_connector)
+- Per-task connector 選擇
+- Real-time push(SSE/WebSocket)
+- Role prompt 輸出的 server-side schema 驗證
+- Task result 的 git 自動寫入
+- Retry logic
+- 新增 role prompt
+
+---
+
+## 6. 風險
+
+| 風險 | 可能性 | 影響 | 緩解 |
+|---|---|---|---|
+| R1:PROJECT_CONTEXT 資訊密度不夠 | 中 | 中 | 重用 PlanningContextV1;Phase 6c 細化 |
+| R2:claim-next-task ownership check 邏輯複雜 | 中 | 高 | 明確單元測試;參考 planning run ownership check |
+| R3:role prompt 輸出各角色格式不一致 | 低 | 低 | server 儲存原始 JSONB,前端顯示通用欄位 |
+| R4:connector 雙重 poll 負載 | 低 | 低 | RunOnceTask 只在 planning run idle 時才呼叫 |
+| R5:TASK_DESCRIPTION prompt injection(Phase 5 D7 約束) | 低 | 高 | 明確記錄限制;不做 sandbox(本 phase) |
+
+---
+
+## 7. Open Questions
+
+- Q1(阻擋 B3):`PROJECT_CONTEXT` 變數的組裝方式 — 重用 `PlanningContextV1` 還是輕量 summary?建議:重用 PlanningContextV1。
+- Q2:`dispatch_status = "queued"` 卡住時不做 expiry(Phase 6b 不做,人類手動 cancel)。
+- Q3:cli_binding 使用 connector primary cli_config(不支援 per-task 選擇)。
+- Q4:catalog enforcement 兩側都做(server skip + connector Exists 檢查)。
+
+---
+
+## 8. 狀態追蹤
+
+| Slice | 狀態 | PR |
+|---|---|---|
+| B1 — DB migration 029 | pending | — |
+| B2 — API endpoints | pending | — |
+| B3 — Connector loop | pending | — |
+| B4 — 前端 result panel | pending | — |
+| B5 — Apply UI 啟用 | pending | — |
diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts
index 24d4124..56faed4 100644
--- a/frontend/src/api/client.ts
+++ b/frontend/src/api/client.ts
@@ -251,6 +251,10 @@ export async function updateRequirement(id: string, data: { status: string }) {
});
}
+export async function deleteRequirement(id: string): Promise {
+ await request(`/requirements/${encodeURIComponent(id)}`, { method: 'DELETE' })
+}
+
export async function getPlanningProviderOptions(projectId: string) {
return request(`/projects/${encodeURIComponent(projectId)}/planning-provider-options`);
}
diff --git a/frontend/src/index.css b/frontend/src/index.css
index b168f33..705a172 100644
--- a/frontend/src/index.css
+++ b/frontend/src/index.css
@@ -597,6 +597,160 @@ a:hover {
margin-top: 1rem;
}
+/* Two-panel planning layout */
+.planning-welcome-view {
+ max-width: 640px;
+ padding: 2rem 0;
+ display: grid;
+ gap: 1.75rem;
+}
+
+.planning-welcome-how {
+ display: grid;
+ gap: 0.4rem;
+}
+
+.planning-welcome-steps {
+ margin: 0;
+ padding-left: 1.25rem;
+ display: grid;
+ gap: 0.35rem;
+ color: var(--text-muted);
+ font-size: 0.88rem;
+}
+
+.planning-welcome-steps li strong {
+ color: var(--text);
+}
+
+.planning-welcome-demo {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.75rem 1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ font-size: 0.88rem;
+}
+
+.planning-welcome-input-area {
+ display: grid;
+ gap: 0.5rem;
+}
+
+.planning-welcome-input-row {
+ display: flex;
+ gap: 0.6rem;
+ align-items: flex-end;
+ flex-wrap: wrap;
+}
+
+.planning-welcome-whatsnext {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+ padding: 0.75rem 1rem;
+ background: var(--bg-card);
+ border: 1px solid var(--border);
+ border-radius: 0.5rem;
+ font-size: 0.88rem;
+ color: var(--text-muted);
+}
+
+.planning-two-panel {
+ display: grid;
+ grid-template-columns: 240px 1fr;
+ gap: 0;
+ align-items: start;
+}
+
+.planning-sidebar {
+ border-right: 1px solid var(--border);
+ padding-right: 1rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0;
+}
+
+.planning-sidebar-header {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding-bottom: 0.75rem;
+ margin-bottom: 0.5rem;
+ border-bottom: 1px solid var(--border);
+}
+
+.planning-sidebar-counts {
+ border-top: 1px solid var(--border);
+ padding-top: 0.6rem;
+ margin-top: 0.5rem;
+ display: grid;
+ gap: 0.35rem;
+}
+
+.planning-sidebar-count {
+ display: flex;
+ align-items: center;
+ gap: 0.4rem;
+ background: none;
+ border: none;
+ cursor: pointer;
+ padding: 0.2rem 0;
+ font-size: 0.82rem;
+ color: var(--text-muted);
+ text-align: left;
+}
+
+.planning-sidebar-count:hover {
+ color: var(--text);
+}
+
+.planning-main {
+ padding-left: 1.5rem;
+ display: grid;
+ gap: 1rem;
+}
+
+.planning-main-empty {
+ padding: 2rem 0;
+ display: flex;
+ flex-direction: column;
+ gap: 1rem;
+ align-items: flex-start;
+}
+
+/* Inline form inside sidebar — removes card wrapper styling */
+.planning-inline-form {
+ padding: 0.75rem 0;
+ border-bottom: 1px solid var(--border);
+ margin-bottom: 0.5rem;
+}
+
+.planning-inline-form .form-group {
+ margin-bottom: 0.5rem;
+}
+
+@media (max-width: 1000px) {
+ .planning-two-panel {
+ grid-template-columns: 1fr;
+ }
+
+ .planning-sidebar {
+ border-right: none;
+ border-bottom: 1px solid var(--border);
+ padding-right: 0;
+ padding-bottom: 1rem;
+ margin-bottom: 1rem;
+ }
+
+ .planning-main {
+ padding-left: 0;
+ }
+}
+
.planning-grid {
display: grid;
grid-template-columns: minmax(0, 1.15fr) minmax(320px, 0.85fr);
diff --git a/frontend/src/pages/ProjectDetail/PlanningTab.tsx b/frontend/src/pages/ProjectDetail/PlanningTab.tsx
index f9d5756..1eb54f8 100644
--- a/frontend/src/pages/ProjectDetail/PlanningTab.tsx
+++ b/frontend/src/pages/ProjectDetail/PlanningTab.tsx
@@ -1,7 +1,5 @@
+import { Link } from 'react-router-dom'
import type { Requirement, Task } from '../../types'
-import Jargon from '../../components/Jargon'
-import { PlanningStepper } from './PlanningStepper'
-import { AttentionRow } from './planning/AttentionRow'
import { RequirementIntake } from './planning/RequirementIntake'
import { RequirementQueue } from './planning/RequirementQueue'
import { PlanningLauncher } from './planning/PlanningLauncher'
@@ -37,9 +35,11 @@ interface PlanningTabProps {
/**
* Planning Workspace shell. Composes presentational siblings under
* `pages/ProjectDetail/planning/`; all planning-domain state + effects +
- * handlers live in `usePlanningWorkspaceData`. Per Phase 2 S1 acceptance
- * criteria (§8 of docs/phase2-planning-workspace-design.md) this shell
- * stays under 200 LOC; growth signals an architectural drift.
+ * handlers live in `usePlanningWorkspaceData`.
+ *
+ * Layout:
+ * - Empty project (requirements.length === 0): centered welcome view via WorkspaceOnboardingPanel
+ * - Non-empty project: two-panel layout (240px sidebar + flex main)
*/
export function PlanningTab({
projectId,
@@ -56,6 +56,11 @@ export function PlanningTab({
onViewDocumentById,
onViewDriftSignal,
}: PlanningTabProps) {
+ // openDriftCount and onNavigateToDrift are kept in the props interface for
+ // parent compatibility (ProjectDetail passes them). They were previously used
+ // by AttentionRow which was removed in the two-panel redesign.
+ void openDriftCount
+ void onNavigateToDrift
const ws = usePlanningWorkspaceData({
projectId,
requirements,
@@ -88,189 +93,241 @@ export function PlanningTab({
const el = document.querySelector(selector) as HTMLElement | null
if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' })
}
- function focusSelector(selector: string) {
- const el = document.querySelector(selector) as HTMLElement | null
- if (el) el.focus()
- }
const jumpToRequirements = () => scrollToSelector('.requirement-list')
const jumpToCandidates = () => scrollToSelector('.planning-candidate-panel')
- const jumpToWorkspace = () => scrollToSelector('.planning-workspace-card')
- const jumpToIntake = () => focusSelector('.planning-foundation-grid input')
+
+ const sidebarRequirements = requirements.filter(
+ r => r.source !== 'analysis' && r.source !== 'system'
+ )
+ const isEmpty = sidebarRequirements.length === 0
return (
-
+ {planningLoadError && (
+
{planningLoadError}
+ )}
-
+ {isEmpty ? (
+ <>
+
{
+ ws.onSelectLineage(requirementId, runId)
+ await onReload()
+ }}
+ planningRunsCount={ws.planningRuns.length}
+ planningRunReady={ws.planningRunReady}
+ onRunWhatsnext={ws.onRunWhatsnext}
+ runningWhatsnext={ws.runningWhatsnext}
+ />
+ {ws.selectedRequirement && (
+
+ )}
+ >
+ ) : (
+
+
+
+
Requirements
+
+ {ws.showRequirementIntake ? '✕' : '+ New'}
+
+
-
-
+ {ws.showRequirementIntake && (
+
+ )}
-
-
+
-
-
-
-
Planning Workspace
-
- Start a tracked planning run from the selected requirement, then review and apply its persisted draft backlog candidates without leaving this page.
-
-
-
P2-07
-
+ {(requirementsAwaitingPlanning > 0 || candidatesAwaitingReview > 0 || appliedOpenTasks > 0) && (
+
+ {requirementsAwaitingPlanning > 0 && (
+
+ {requirementsAwaitingPlanning}
+ awaiting planning
+
+ )}
+ {candidatesAwaitingReview > 0 && (
+
+ {candidatesAwaitingReview}
+ to review
+
+ )}
+ {appliedOpenTasks > 0 && (
+
+ {appliedOpenTasks}
+ applied tasks open
+
+ )}
+
+ )}
+
- {ws.selectedRequirement ? (
-
-
ws.selectedRequirement && ws.loadPlanningRuns(ws.selectedRequirement.id)}
- onRunWhatsnext={ws.onRunWhatsnext}
- />
+
+ {ws.selectedRequirement ? (
+ <>
+ ws.selectedRequirement && ws.loadPlanningRuns(ws.selectedRequirement.id)}
+ onRunWhatsnext={ws.onRunWhatsnext}
+ activeRunDispatchStatus={ws.activeRunDispatchStatus}
+ />
-
+
-
+
- {
- ws.onSelectLineage(requirementId, runId, candidateId)
- // Scroll priority: candidate > requirement. If the click
- // carried a candidate id we want the review panel visible;
- // otherwise the requirement queue is the correct landing.
- if (candidateId) {
- jumpToCandidates()
- } else {
- jumpToRequirements()
- }
- }}
- onJumpToTasks={onNavigateToTasks}
- />
-
- ) : requirements.length === 0 ? (
-
ws.onSelectLineage(requirementId, runId)}
- onWhatsnext={ws.onRunWhatsnext}
- planningRunsCount={ws.planningRuns.length}
- />
- ) : (
-
-
-
Start here
-
- Select a requirement above to plan a specific feature, or run a full project health check to surface the most urgent open work across tasks, drift signals, and stale docs.
-
-
- {ws.planningRunReady ? (
-
- {ws.runningWhatsnext ? 'Starting analysis…' : "Run What's Next — full project health check"}
-
+
{
+ ws.onSelectLineage(requirementId, runId, candidateId)
+ if (candidateId) {
+ jumpToCandidates()
+ } else {
+ jumpToRequirements()
+ }
+ }}
+ onJumpToTasks={onNavigateToTasks}
+ />
+ >
) : (
-
- Configure a planning provider in Model Settings or connect a local connector to enable health-check runs.
-
+
+
+ Select a requirement on the left to plan a specific feature.
+
+ {ws.planningRunReady ? (
+
+ {ws.runningWhatsnext ? 'Starting…' : "Run What's Next — full project health check"}
+
+ ) : (
+
+ Configure a planning provider or connect a local connector to enable health-check runs.
+
+ )}
+
)}
-
- )}
-
+
+
+ )}
)
}
diff --git a/frontend/src/pages/ProjectDetail/TasksTab.test.tsx b/frontend/src/pages/ProjectDetail/TasksTab.test.tsx
index 993951d..0d2a8ea 100644
--- a/frontend/src/pages/ProjectDetail/TasksTab.test.tsx
+++ b/frontend/src/pages/ProjectDetail/TasksTab.test.tsx
@@ -1,5 +1,5 @@
import { describe, it, expect, vi } from 'vitest'
-import { render, screen } from '@testing-library/react'
+import { render, screen, fireEvent } from '@testing-library/react'
import type { Task } from '../../types'
import { TasksTab } from './TasksTab'
@@ -61,4 +61,44 @@ describe(' ', () => {
const [badge] = screen.getAllByText('in progress', { selector: 'span.badge' })
expect(badge).toBeInTheDocument()
})
+
+ // --- Phase 6b: dispatch_status display ---
+
+ it('T-6b-UI-1: renders queued badge for dispatch_status=queued', () => {
+ const tasks = [makeTask({ id: 'q1', title: 'Queued task', dispatch_status: 'queued', source: 'role_dispatch:backend-architect' })]
+ render( )
+ expect(screen.getByTestId('dispatch-badge-queued')).toBeInTheDocument()
+ expect(screen.getByTestId('dispatch-badge-queued').textContent).toBe('Queued')
+ })
+
+ it('T-6b-UI-2: renders running badge for dispatch_status=running', () => {
+ const tasks = [makeTask({ id: 'r1', title: 'Running task', dispatch_status: 'running', source: 'role_dispatch:backend-architect' })]
+ render( )
+ expect(screen.getByTestId('dispatch-badge-running')).toBeInTheDocument()
+ expect(screen.getByTestId('dispatch-badge-running').textContent).toBe('Running…')
+ })
+
+ it('T-6b-UI-3: renders completed badge with expandable result block; clicking shows file paths', () => {
+ const result = { files: ['src/api.go', 'src/store.go'] }
+ const tasks = [
+ makeTask({
+ id: 'c1',
+ title: 'Completed task',
+ dispatch_status: 'completed',
+ execution_result: result as Record,
+ source: 'role_dispatch:backend-architect',
+ }),
+ ]
+ render( )
+ const toggle = screen.getByTestId('dispatch-badge-completed')
+ expect(toggle).toBeInTheDocument()
+ // Result block not visible before click
+ expect(screen.queryByTestId('dispatch-result-block')).toBeNull()
+ fireEvent.click(toggle)
+ // Result block visible after click
+ const block = screen.getByTestId('dispatch-result-block')
+ expect(block).toBeInTheDocument()
+ expect(block.textContent).toContain('src/api.go')
+ expect(block.textContent).toContain('src/store.go')
+ })
})
diff --git a/frontend/src/pages/ProjectDetail/TasksTab.tsx b/frontend/src/pages/ProjectDetail/TasksTab.tsx
index bfcf207..7768d43 100644
--- a/frontend/src/pages/ProjectDetail/TasksTab.tsx
+++ b/frontend/src/pages/ProjectDetail/TasksTab.tsx
@@ -2,6 +2,129 @@ import { useState, useEffect } from 'react'
import type { Task, ProjectSummary } from '../../types'
import { createTask, updateTask, deleteTask, batchUpdateTasks, listProjectTaskLineage, type AppliedLineageEntry } from '../../api/client'
+// ---------------------------------------------------------------------------
+// DispatchStatusBadge — inline indicator for role_dispatch execution lifecycle
+// ---------------------------------------------------------------------------
+interface DispatchStatusBadgeProps { task: Task }
+
+function DispatchStatusBadge({ task }: DispatchStatusBadgeProps) {
+ const [expanded, setExpanded] = useState(false)
+ const ds = task.dispatch_status
+
+ if (!ds || ds === 'none') return null
+
+ const labelMap: Record = {
+ queued: 'Queued',
+ running: 'Running…',
+ completed: 'Completed',
+ failed: 'Failed',
+ }
+ const colorMap: Record = {
+ queued: 'var(--text-muted)',
+ running: 'var(--color-info, #6366f1)',
+ completed: 'var(--color-success, #22c55e)',
+ failed: 'var(--color-danger, #ef4444)',
+ }
+ const label = labelMap[ds] ?? ds
+ const color = colorMap[ds] ?? 'var(--text-muted)'
+
+ if (ds === 'completed' && task.execution_result) {
+ const files: string[] = []
+ try {
+ const raw = task.execution_result as Record
+ if (Array.isArray(raw['files'])) {
+ for (const f of raw['files'] as unknown[]) {
+ if (typeof f === 'string') files.push(f)
+ }
+ }
+ } catch { /* ignore */ }
+ return (
+
+ { e.stopPropagation(); setExpanded(v => !v) }}
+ style={{
+ fontSize: '0.7rem',
+ padding: '1px 6px',
+ borderRadius: '4px',
+ border: `1px solid ${color}`,
+ background: 'transparent',
+ color,
+ cursor: 'pointer',
+ }}
+ aria-expanded={expanded}
+ data-testid="dispatch-badge-completed"
+ >
+ {label} {expanded ? '▲' : '▼'}
+
+ {expanded && (
+ e.stopPropagation()}
+ style={{
+ display: 'block',
+ marginTop: '4px',
+ fontSize: '0.72rem',
+ color: 'var(--text-muted)',
+ whiteSpace: 'pre-wrap',
+ wordBreak: 'break-all',
+ }}
+ data-testid="dispatch-result-block"
+ >
+ {files.length > 0
+ ? files.map(f => {f} )
+ : JSON.stringify(task.execution_result, null, 2)}
+
+ )}
+
+ )
+ }
+
+ if (ds === 'failed') {
+ const errMsg: string = (() => {
+ try {
+ const raw = task.execution_result as Record | null | undefined
+ if (raw && typeof raw['error_message'] === 'string') return raw['error_message']
+ if (raw && typeof raw['error'] === 'string') return raw['error']
+ } catch { /* ignore */ }
+ return ''
+ })()
+ return (
+
+
+ {label}
+
+ {errMsg && (
+
+ {errMsg}
+
+ )}
+
+ )
+ }
+
+ return (
+
+ {label}
+
+ )
+}
+
type TaskFilterState = { status: '' | Task['status']; priority: '' | Task['priority']; assignee: string }
type BatchTaskFormState = { status: '' | Task['status']; priority: '' | Task['priority']; assignee: string; clearAssignee: boolean }
@@ -350,6 +473,9 @@ export function TasksTab({
{lineageByTask[task.id].lineage_kind === 'applied_candidate' ? '← Plan' : '← Req'}
)}
+ {task.dispatch_status && task.dispatch_status !== 'none' && (
+
+ )}
{task.status.replace('_', ' ')}
{task.priority}
diff --git a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx
index 7a66936..db6e60f 100644
--- a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.test.tsx
@@ -102,6 +102,7 @@ function renderPanel(overrides: Partial ', () => {
renderPanel()
// Title appears in both the list and the detail form input
expect(screen.getAllByText('Persist recovery options').length).toBeGreaterThan(0)
- expect(screen.getByRole('button', { name: /Approve/i })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /Reject/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Apply/i })).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Skip/i })).toBeInTheDocument()
})
it('disables Apply to Tasks until canApplySelectedCandidate is true', () => {
renderPanel({ canApplySelectedCandidate: false })
- expect(screen.getByRole('button', { name: /Apply To Tasks/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /^Apply$/i })).toBeDisabled()
})
it('renders document evidence as a clickable link when onViewDocumentById is provided and the document has an id', async () => {
@@ -277,26 +278,31 @@ describe(' ', () => {
expect(screen.queryByText(/Role: /i)).not.toBeInTheDocument()
})
- // T-P5-B3-7: Manual / Auto-dispatch radio group renders only when the
- // onSelectedExecutionModeChange callback is provided. Auto-dispatch is
- // disabled with the "(coming in Phase 6)" label.
+ // T-P5-B3-7 (updated Phase 6b): Manual / Auto-dispatch radio group renders
+ // only when the onSelectedExecutionModeChange callback is provided.
+ // Auto-dispatch is disabled when the selected candidate has no execution_role.
it('renders the Manual + Auto-dispatch radio group only when onSelectedExecutionModeChange is provided', () => {
const onChange = vi.fn()
renderPanel({ selectedExecutionMode: 'manual', onSelectedExecutionModeChange: onChange })
expect(screen.getByLabelText(/Manual/i)).toBeInTheDocument()
- expect(screen.getByText(/coming in Phase 6/i)).toBeInTheDocument()
+ // Phase 6b: "coming in Phase 6" text removed; radio is dynamically enabled by execution_role.
+ expect(screen.queryByText(/coming in Phase 6/i)).not.toBeInTheDocument()
+ // Auto-dispatch radio exists but is disabled when candidate has no execution_role (default fixture).
+ const autoRadio = screen.getByRole('radio', { name: /Auto-dispatch/i })
+ expect(autoRadio).toBeInTheDocument()
})
- it('disables the Auto-dispatch radio even when selected, reserving real dispatch for Phase 6', () => {
+ it('disables the Auto-dispatch radio when candidate has no execution_role (Phase 6b)', () => {
const onChange = vi.fn()
- renderPanel({ selectedExecutionMode: 'role_dispatch', onSelectedExecutionModeChange: onChange })
- // Query the Auto-dispatch radio specifically by its nested text label.
+ // Default renderPanel fixture has no execution_role on selectedCandidate.
+ renderPanel({ selectedExecutionMode: 'manual', onSelectedExecutionModeChange: onChange })
const autoRadio = screen.getByRole('radio', { name: /Auto-dispatch/i })
expect(autoRadio).toBeDisabled()
})
it('hides the execution mode radio group when onSelectedExecutionModeChange is not wired', () => {
renderPanel()
- expect(screen.queryByText(/coming in Phase 6/i)).not.toBeInTheDocument()
+ // No radio group rendered when callback not provided.
+ expect(screen.queryByRole('radio', { name: /Auto-dispatch/i })).not.toBeInTheDocument()
})
})
diff --git a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx
index 18e30d1..f1d99f0 100644
--- a/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/CandidateReviewPanel.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react'
import type { BacklogCandidate, PlanningProviderOptions, PlanningRun } from '../../../types'
import { formatDateTime, formatRelativeTime } from '../../../utils/formatters'
import Jargon from '../../../components/Jargon'
@@ -88,7 +89,8 @@ interface CandidateReviewPanelProps {
onPersistReview: (nextStatus?: 'draft' | 'approved' | 'rejected') => void
onApplyCandidate: () => void
- onResetCandidateForm: () => void
+ onSkipCandidate: () => void
+ onResetCandidateForm?: () => void
// Phase 5 B3: execution mode radio group. `manual` is the Phase 4
// behaviour; `role_dispatch` is a forward-looking marker. Both props
@@ -139,12 +141,13 @@ export function CandidateReviewPanel({
providerOptions,
onPersistReview,
onApplyCandidate,
- onResetCandidateForm,
+ onSkipCandidate,
selectedExecutionMode,
onSelectedExecutionModeChange,
onViewDocumentById,
onViewDriftSignal,
}: CandidateReviewPanelProps) {
+ const [showSkipped, setShowSkipped] = useState(false)
const providerLabel = makeProviderLabeler(providerOptions)
const modelLabel = makeModelLabeler(providerOptions)
@@ -182,7 +185,10 @@ export function CandidateReviewPanel({
)}
- {selectedRun && {candidates.length} candidate{candidates.length === 1 ? '' : 's'} }
+ {selectedRun && (() => {
+ const activeCount = candidates.filter(c => c.status !== 'rejected').length
+ return {activeCount} candidate{activeCount === 1 ? '' : 's'}
+ })()}
{candidatesError && {candidatesError}
}
@@ -229,7 +235,7 @@ export function CandidateReviewPanel({
) : (
- {candidates.map(candidate => (
+ {candidates.filter(c => c.status !== 'rejected').map(candidate => (
))}
+
+ {(() => {
+ const skipped = candidates.filter(c => c.status === 'rejected')
+ if (skipped.length === 0) return null
+ return (
+
+ setShowSkipped(s => !s)}
+ >
+ {skipped.length} skipped {showSkipped ? '▴' : '▾'}
+
+ {showSkipped && skipped.map(c => (
+ onSelectCandidate(c.id)}
+ >
+ #{c.rank} {c.title}
+
+ ))}
+
+ )
+ })()}
@@ -302,19 +336,6 @@ export function CandidateReviewPanel({
/>
-
- Review Status
- onCandidateFormChange({ ...candidateForm, status: e.target.value as BacklogCandidate['status'] })}
- disabled={savingCandidate || applyingCandidate || selectedCandidateApplied}
- >
- draft
- approved
- rejected
-
-
-
{selectedCandidate.rationale &&
{selectedCandidate.rationale}
}
{selectedCandidate.validation_criteria && (
@@ -533,47 +554,62 @@ export function CandidateReviewPanel({
/>
Manual
-
- onSelectedExecutionModeChange('role_dispatch')}
- disabled
- aria-disabled="true"
- />
- Auto-dispatch (coming in Phase 6)
-
+ {(() => {
+ const hasRole = !!(selectedCandidate?.execution_role && selectedCandidate.execution_role.trim() !== '')
+ return (
+
+ onSelectedExecutionModeChange('role_dispatch')}
+ disabled={!hasRole}
+ aria-disabled={!hasRole}
+ />
+ Auto-dispatch
+
+ )
+ })()}
)}
- onPersistReview()} disabled={savingCandidate || applyingCandidate || !candidateFormDirty || selectedCandidateApplied}>
- {savingCandidate ? 'Saving…' : 'Save Changes'}
-
-
- Reset
-
- onPersistReview('draft')} disabled={savingCandidate || applyingCandidate || selectedCandidateApplied}>
- Return To Draft
-
- onPersistReview('approved')} disabled={savingCandidate || applyingCandidate || selectedCandidateApplied}>
- Approve
-
- onPersistReview('rejected')} disabled={savingCandidate || applyingCandidate || selectedCandidateApplied}>
- Reject
+ {candidateFormDirty && !selectedCandidateApplied && (
+ onPersistReview()}
+ disabled={savingCandidate || applyingCandidate}
+ >
+ {savingCandidate ? 'Saving…' : 'Save edits'}
+
+ )}
+
+ {selectedCandidate.status === 'rejected' ? 'Skipped' : 'Skip'}
-
- {applyingCandidate ? 'Applying…' : selectedCandidateApplied ? 'Applied' : 'Apply To Tasks'}
+
+ {applyingCandidate ? 'Applying…' : selectedCandidateApplied ? 'Applied ✓' : 'Apply'}
>
diff --git a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx
index d731817..e964215 100644
--- a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.test.tsx
@@ -1,7 +1,8 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { render, screen } from '@testing-library/react'
import { MemoryRouter } from 'react-router-dom'
-import type { AccountBinding, PlanningProviderOptions, Requirement } from '../../../types'
+import type { PlanningProviderOptions, Requirement } from '../../../types'
+import type { CliConfigOption } from './hooks/usePlanningWorkspaceData'
import { PlanningLauncher } from './PlanningLauncher'
function makeRequirement(overrides: Partial = {}): Requirement {
@@ -19,24 +20,16 @@ function makeRequirement(overrides: Partial = {}): Requirement {
}
}
-function makeBinding(overrides: Partial = {}): AccountBinding {
+function makeCliConfig(overrides: Partial = {}): CliConfigOption {
return {
- id: 'b1',
- user_id: 'u1',
- provider_id: 'cli:claude',
- label: 'My Claude',
- base_url: '',
- model_id: 'claude-sonnet-4-6',
- configured_models: [],
- api_key_configured: false,
- is_active: true,
- cli_command: 'claude',
- is_primary: true,
- created_at: '2026-04-22T00:00:00Z',
- updated_at: '2026-04-22T00:00:00Z',
- last_probe_at: null,
- last_probe_ok: null,
- last_probe_ms: null,
+ key: 'connector1:config1',
+ connectorId: 'connector1',
+ connectorLabel: 'My Machine',
+ configId: 'config1',
+ configLabel: 'My Claude',
+ modelId: 'claude-sonnet-4-6',
+ isPrimary: true,
+ isConnectorOnline: true,
...overrides,
}
}
@@ -49,10 +42,10 @@ function renderLauncher(overrides: Partial ', () => {
expect(btn).toBeDisabled()
})
- it('shows "No CLI binding configured" when cliBindings is empty and connector is online', () => {
+ it('shows "No CLI configured on this machine" when cliConfigs is empty and connector is online', () => {
localStorage.setItem('anpm_launcher_advanced_open', '1')
const providerOptions = {
providers: [],
@@ -192,12 +185,12 @@ describe(' ', () => {
resolved_binding_source: 'shared',
resolved_binding_label: '',
} as unknown as PlanningProviderOptions
- renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliBindings: [], selectedCliBindingId: null })
- expect(screen.getByText(/No CLI binding configured/i)).toBeInTheDocument()
- expect(screen.getByRole('link', { name: /Set up a CLI binding/i })).toBeInTheDocument()
+ renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliConfigs: [], selectedCliConfigKey: null })
+ expect(screen.getByText(/No CLI configured on this machine/i)).toBeInTheDocument()
+ expect(screen.getByRole('link', { name: /Set up CLIs in My Connector/i })).toBeInTheDocument()
})
- it('shows CLI binding select with correct options when bindings exist', () => {
+ it('shows CLI config select with correct options when configs exist', () => {
localStorage.setItem('anpm_launcher_advanced_open', '1')
const providerOptions = {
providers: [],
@@ -212,15 +205,15 @@ describe(' ', () => {
resolved_binding_source: 'shared',
resolved_binding_label: '',
} as unknown as PlanningProviderOptions
- const binding = makeBinding({ id: 'b1', label: 'My Claude', model_id: 'claude-sonnet-4-6', is_primary: true })
- renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliBindings: [binding], selectedCliBindingId: 'b1' })
- expect(screen.getByText('My Claude [claude-sonnet-4-6] (primary)')).toBeInTheDocument()
- expect(screen.getByLabelText(/CLI binding for this run/i)).toBeInTheDocument()
+ const config = makeCliConfig({ key: 'connector1:config1', connectorLabel: 'My Machine', configLabel: 'My Claude', modelId: 'claude-sonnet-4-6', isPrimary: true })
+ renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliConfigs: [config], selectedCliConfigKey: 'connector1:config1' })
+ expect(screen.getByText('My Machine — My Claude [claude-sonnet-4-6] (primary)')).toBeInTheDocument()
+ expect(screen.getByLabelText(/CLI for this run/i)).toBeInTheDocument()
})
- it('calls onCliBindingChange when binding is changed', async () => {
+ it('calls onCliConfigChange when CLI config is changed', async () => {
localStorage.setItem('anpm_launcher_advanced_open', '1')
- const onCliBindingChange = vi.fn()
+ const onCliConfigChange = vi.fn()
const { default: userEvent } = await import('@testing-library/user-event')
const providerOptions = {
providers: [],
@@ -235,18 +228,18 @@ describe(' ', () => {
resolved_binding_source: 'shared',
resolved_binding_label: '',
} as unknown as PlanningProviderOptions
- const binding1 = makeBinding({ id: 'b1', label: 'My Claude', model_id: 'claude-sonnet-4-6', is_primary: true })
- const binding2 = makeBinding({ id: 'b2', label: 'My Codex', model_id: 'codex-mini-latest', is_primary: false, provider_id: 'cli:codex' })
- renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliBindings: [binding1, binding2], selectedCliBindingId: 'b1', onCliBindingChange })
- const bindingSelect = screen.getByLabelText(/CLI binding for this run/i)
- await userEvent.selectOptions(bindingSelect, 'b2')
- expect(onCliBindingChange).toHaveBeenCalledWith('b2')
+ const config1 = makeCliConfig({ key: 'connector1:config1', connectorLabel: 'My Machine', configLabel: 'My Claude', modelId: 'claude-sonnet-4-6', isPrimary: true })
+ const config2 = makeCliConfig({ key: 'connector1:config2', connectorLabel: 'My Machine', configLabel: 'My Codex', modelId: 'codex-mini-latest', isPrimary: false })
+ renderLauncher({ providerOptions, executionMode: 'local_connector', runReady: true, cliConfigs: [config1, config2], selectedCliConfigKey: 'connector1:config1', onCliConfigChange })
+ const configSelect = screen.getByLabelText(/CLI for this run/i)
+ await userEvent.selectOptions(configSelect, 'connector1:config2')
+ expect(onCliConfigChange).toHaveBeenCalledWith('connector1:config2')
})
it('T-6a-A2-1: default render hides advanced controls', () => {
renderLauncher()
expect(screen.queryByLabelText(/Execution mode/i)).not.toBeInTheDocument()
- expect(screen.queryByLabelText(/CLI binding for this run/i)).not.toBeInTheDocument()
+ expect(screen.queryByLabelText(/CLI for this run/i)).not.toBeInTheDocument()
expect(screen.queryByLabelText(/Model override for this run/i)).not.toBeInTheDocument()
expect(screen.getByRole('button', { name: /Advanced/i })).toBeInTheDocument()
})
diff --git a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx
index ab636b8..e1007c8 100644
--- a/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/PlanningLauncher.tsx
@@ -1,6 +1,7 @@
import { useState } from 'react'
import { Link } from 'react-router-dom'
-import type { AccountBinding, PlanningExecutionMode, PlanningProviderOptions, Requirement } from '../../../types'
+import type { PlanningExecutionMode, PlanningProviderOptions, Requirement } from '../../../types'
+import type { CliConfigOption } from './hooks/usePlanningWorkspaceData'
import { formatRelativeTime } from '../../../utils/formatters'
import Jargon from '../../../components/Jargon'
import {
@@ -19,10 +20,10 @@ interface PlanningLauncherProps {
executionMode: PlanningExecutionMode
onExecutionModeChange: (mode: PlanningExecutionMode) => void
- cliBindings: AccountBinding[]
- cliBindingsLoading: boolean
- selectedCliBindingId: string | null
- onCliBindingChange: (id: string) => void
+ cliConfigs: CliConfigOption[]
+ cliConfigsLoading: boolean
+ selectedCliConfigKey: string | null
+ onCliConfigChange: (key: string) => void
planningModelOverride: string
onPlanningModelOverrideChange: (value: string) => void
@@ -36,6 +37,8 @@ interface PlanningLauncherProps {
onStartRun: () => void
onRefreshRuns: () => void
onRunWhatsnext: () => void
+ /** Dispatch status of the current active run (if any), used to show connector badge state */
+ activeRunDispatchStatus?: string | null
}
/**
@@ -54,10 +57,10 @@ export function PlanningLauncher({
providerOptionsError,
executionMode,
onExecutionModeChange,
- cliBindings,
- cliBindingsLoading,
- selectedCliBindingId,
- onCliBindingChange,
+ cliConfigs,
+ cliConfigsLoading,
+ selectedCliConfigKey,
+ onCliConfigChange,
planningModelOverride,
onPlanningModelOverrideChange,
creatingRun,
@@ -68,6 +71,7 @@ export function PlanningLauncher({
onStartRun,
onRefreshRuns,
onRunWhatsnext,
+ activeRunDispatchStatus,
}: PlanningLauncherProps) {
const [advanced, setAdvanced] = useState(() => localStorage.getItem('anpm_launcher_advanced_open') === '1')
@@ -118,10 +122,10 @@ export function PlanningLauncher({
- Decomposition Settings
+ Run via
@@ -129,11 +133,9 @@ export function PlanningLauncher({
- {!advanced && (
+ {!advanced && !providerOptionsLoading && (
{planningExecutionModeLabel(executionMode)}
- {' · '}
- {cliBindings.find(b => b.id === selectedCliBindingId)?.label ?? 'auto'}
)}
@@ -148,7 +150,7 @@ export function PlanningLauncher({
onExecutionModeChange(e.target.value as PlanningExecutionMode)}
- disabled={creatingRun || providerOptionsLoading}
+ disabled={creatingRun}
>
{providerOptions.available_execution_modes.map(mode => (
{planningExecutionModeLabel(mode)}
@@ -162,7 +164,15 @@ export function PlanningLauncher({
{providerOptions?.paired_connector_available ? (
- ● Online
+ {(() => {
+ if (activeRunDispatchStatus === 'leased') {
+ return ● Running job…
+ }
+ if (activeRunDispatchStatus === 'queued') {
+ return ⏳ Queued…
+ }
+ return ● Online
+ })()}
{providerOptions.active_connector_label ?? 'My Machine'}
@@ -171,30 +181,30 @@ export function PlanningLauncher({
- {cliBindingsLoading ? (
-
Loading CLI bindings…
- ) : cliBindings.length === 0 ? (
+ {cliConfigsLoading ? (
+
Loading CLI configs…
+ ) : cliConfigs.length === 0 ? (
- No CLI binding configured.{' '}
- Set up a CLI binding to use your subscription.
+ No CLI configured on this machine.{' '}
+ Set up CLIs in My Connector →
) : (
- CLI binding for this run
+ CLI for this run
onCliBindingChange(e.target.value)}
+ value={selectedCliConfigKey ?? ''}
+ onChange={e => onCliConfigChange(e.target.value)}
disabled={creatingRun}
style={{ padding: '0.4rem 0.6rem', fontSize: '0.88rem', background: 'var(--bg-card)', border: '1px solid var(--border)', borderRadius: '0.375rem', color: 'var(--text)' }}
>
- {cliBindings.map(b => (
-
- {b.label}{b.model_id ? ` [${b.model_id}]` : ''}{b.is_primary ? ' (primary)' : ''}
+ {cliConfigs.map(cfg => (
+
+ {cfg.connectorLabel} — {cfg.configLabel} [{cfg.modelId}]{cfg.isPrimary ? ' (primary)' : ''}{!cfg.isConnectorOnline ? ' (offline)' : ''}
))}
- Manage bindings in My Bindings.
+ Manage CLIs in My Connector.
)}
@@ -260,7 +270,7 @@ export function PlanningLauncher({
{creatingRun ? 'Starting…' : 'Start Planning Run'}
diff --git a/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx b/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx
index 1cde6a8..6a127fc 100644
--- a/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/RequirementIntake.tsx
@@ -16,6 +16,12 @@ interface RequirementIntakeProps {
onToggleForm: () => void
onSubmit: (e: FormEvent) => void
onReset: () => void
+ /**
+ * 'card' (default) renders with the full card wrapper, header, and toggle button.
+ * 'inline' renders just the form fields inside a slim wrapper — the toggle is
+ * controlled externally (e.g. by the sidebar header button).
+ */
+ variant?: 'card' | 'inline'
}
/**
@@ -26,6 +32,62 @@ interface RequirementIntakeProps {
* a "+ New Requirement" toggle. Sequential disclosure is part of the
* 2026-04-21 progressive-disclosure decision.
*/
+const intakeForm = (
+ form: RequirementIntakeForm,
+ onFormChange: (f: RequirementIntakeForm) => void,
+ creating: boolean,
+ onSubmit: (e: FormEvent) => void,
+ onReset: () => void,
+) => (
+
+)
+
export function RequirementIntake({
requirementCount,
form,
@@ -35,8 +97,19 @@ export function RequirementIntake({
onToggleForm,
onSubmit,
onReset,
+ variant = 'card',
}: RequirementIntakeProps) {
- const formOpen = requirementCount === 0 || showForm
+ // In inline mode the form is always shown (controlled externally).
+ // In card mode: always open if no requirements yet, otherwise toggle-driven.
+ const formOpen = variant === 'inline' ? true : (requirementCount === 0 || showForm)
+
+ if (variant === 'inline') {
+ return (
+
+ {formOpen && intakeForm(form, onFormChange, creating, onSubmit, onReset)}
+
+ )
+ }
return (
@@ -63,53 +136,9 @@ export function RequirementIntake({
{formOpen && (
-
-
- Title *
- onFormChange({ ...form, title: e.target.value })}
- placeholder="Improve sync failure recovery UX"
- />
-
-
- Summary
- onFormChange({ ...form, summary: e.target.value })}
- placeholder="One-line planning summary"
- />
-
-
- Description
- onFormChange({ ...form, description: e.target.value })}
- placeholder="Describe what the system should do before tasks are created."
- rows={5}
- />
-
-
- Source
- onFormChange({ ...form, source: e.target.value })}
- placeholder="human or agent:name"
- />
-
-
-
- Reset
-
-
- {creating ? 'Capturing…' : 'Capture Requirement'}
-
-
-
+
+ {intakeForm(form, onFormChange, creating, onSubmit, onReset)}
+
)}
)
diff --git a/frontend/src/pages/ProjectDetail/planning/RequirementQueue.test.tsx b/frontend/src/pages/ProjectDetail/planning/RequirementQueue.test.tsx
index 8d4853f..0fc4d79 100644
--- a/frontend/src/pages/ProjectDetail/planning/RequirementQueue.test.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/RequirementQueue.test.tsx
@@ -47,7 +47,8 @@ describe('
', () => {
)
expect(screen.getByText(/1 draft/i)).toBeInTheDocument()
expect(screen.getByText(/1 planned/i)).toBeInTheDocument()
- expect(screen.getByText(/1 archived/i)).toBeInTheDocument()
+ // "1 archived" appears twice: once in the header badge, once in the collapsed section toggle
+ expect(screen.getAllByText(/1 archived/i).length).toBeGreaterThanOrEqual(1)
// Title appears as rendered text (original is in
; the badge is a separate element)
expect(screen.getByText('Improve sync failure UX')).toBeInTheDocument()
expect(screen.getByText('Another')).toBeInTheDocument()
diff --git a/frontend/src/pages/ProjectDetail/planning/RequirementQueue.tsx b/frontend/src/pages/ProjectDetail/planning/RequirementQueue.tsx
index 16b744b..b04cf86 100644
--- a/frontend/src/pages/ProjectDetail/planning/RequirementQueue.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/RequirementQueue.tsx
@@ -1,3 +1,4 @@
+import { useState } from 'react'
import type { Requirement } from '../../../types'
import { formatRelativeTime } from '../../../utils/formatters'
import { requirementStatusBadgeClass } from './labels'
@@ -9,6 +10,13 @@ interface RequirementQueueProps {
planningLoadError: string | null
onArchiveRequirement?: (id: string) => Promise
archivingRequirementId?: string | null
+ /** When true, omits the outer card wrapper and verbose header — renders just the list */
+ compact?: boolean
+ /** New: permanently delete a requirement that has no applied tasks */
+ onDiscardRequirement?: (id: string) => Promise
+ discardingRequirementId?: string | null
+ /** New: set of requirement IDs that have applied task lineage (used to choose Archive vs Discard) */
+ requirementIdsWithAppliedTasks?: Set
}
/**
@@ -17,8 +25,12 @@ interface RequirementQueueProps {
* the downstream PlanningLauncher + PlanningRunList + CandidateReviewPanel
* surfaces.
*
- * Archive button closes out requirements that are planned or no longer needed,
- * keeping the queue focused on active work.
+ * Action button per active requirement:
+ * - If requirement has applied tasks → Archive only (permanent delete not safe)
+ * - If onDiscardRequirement provided and lineage data loaded and no lineage → Discard (with confirmation)
+ * - Otherwise → Archive
+ *
+ * Archived requirements are hidden behind a collapsible "N archived" toggle.
*/
export function RequirementQueue({
requirements,
@@ -27,11 +39,195 @@ export function RequirementQueue({
planningLoadError,
onArchiveRequirement,
archivingRequirementId,
+ compact,
+ onDiscardRequirement,
+ discardingRequirementId,
+ requirementIdsWithAppliedTasks,
}: RequirementQueueProps) {
+ const [confirmDiscardId, setConfirmDiscardId] = useState(null)
+ const [showArchived, setShowArchived] = useState(false)
+
const draftCount = requirements.filter(r => r.status === 'draft').length
const plannedCount = requirements.filter(r => r.status === 'planned').length
const archivedCount = requirements.filter(r => r.status === 'archived').length
+ const activeRequirements = requirements.filter(r => r.status === 'draft' || r.status === 'planned')
+ const archivedRequirements = requirements.filter(r => r.status === 'archived')
+
+ function renderActionButton(requirement: Requirement) {
+ if (requirement.status === 'archived') return null
+
+ // canDiscard: only when handler is provided AND lineage data has been loaded
+ // (requirementIdsWithAppliedTasks !== undefined) AND this requirement has no lineage.
+ // If requirementIdsWithAppliedTasks is undefined (not yet loaded or not passed),
+ // we default to Archive (safe fallback).
+ const canDiscard = Boolean(
+ onDiscardRequirement &&
+ requirementIdsWithAppliedTasks !== undefined &&
+ !requirementIdsWithAppliedTasks.has(requirement.id)
+ )
+
+ // If requirement has applied tasks → Archive only
+ const hasLineage = requirementIdsWithAppliedTasks?.has(requirement.id) ?? false
+
+ if (confirmDiscardId === requirement.id) {
+ return (
+
+ Discard?
+ {
+ e.stopPropagation()
+ void onDiscardRequirement?.(requirement.id)
+ setConfirmDiscardId(null)
+ }}
+ >
+ {discardingRequirementId === requirement.id ? '…' : 'Yes'}
+
+ { e.stopPropagation(); setConfirmDiscardId(null) }}
+ >
+ Cancel
+
+
+ )
+ }
+
+ if (hasLineage || !canDiscard) {
+ // Show Archive (has lineage, or discard not available)
+ if (!onArchiveRequirement) return null
+ return (
+ {
+ e.stopPropagation()
+ void onArchiveRequirement(requirement.id)
+ }}
+ style={{
+ position: 'absolute',
+ top: '0.5rem',
+ right: '0.5rem',
+ padding: '0.2rem 0.45rem',
+ fontSize: '0.75rem',
+ color: 'var(--text-muted)',
+ opacity: archivingRequirementId === requirement.id ? 0.4 : 0.7,
+ }}
+ >
+ {archivingRequirementId === requirement.id ? '…' : 'Archive'}
+
+ )
+ }
+
+ // No lineage and discard handler available → show Discard
+ return (
+ { e.stopPropagation(); setConfirmDiscardId(requirement.id) }}
+ style={{
+ position: 'absolute',
+ top: '0.5rem',
+ right: '0.5rem',
+ padding: '0.2rem 0.45rem',
+ fontSize: '0.75rem',
+ color: 'var(--text-muted)',
+ opacity: 0.7,
+ }}
+ >
+ Discard
+
+ )
+ }
+
+ const listContent = (
+ <>
+ {planningLoadError && {planningLoadError}
}
+
+ {requirements.length === 0 ? (
+ compact ? (
+ No requirements yet.
+ ) : (
+
+
No requirements yet
+
Use the intake form to capture the first planning requirement for this project.
+
+ )
+ ) : (
+ <>
+
+ {activeRequirements.map(requirement => (
+
+
onSelectRequirement(requirement.id)}
+ style={{ width: '100%', paddingRight: (onArchiveRequirement || onDiscardRequirement) ? '3.5rem' : undefined }}
+ >
+
+ {requirement.title}
+ {requirement.status}
+
+ {requirement.summary && {requirement.summary}
}
+ {requirement.description && {requirement.description}
}
+
+ {requirement.source}
+ Updated {formatRelativeTime(requirement.updated_at)}
+
+
+
+ {renderActionButton(requirement)}
+
+ ))}
+
+
+ {archivedRequirements.length > 0 && (
+
+
setShowArchived(v => !v)}
+ style={{ background: 'none', border: 'none', cursor: 'pointer', fontSize: '0.78rem', color: 'var(--text-muted)', padding: '0.15rem 0', width: '100%', textAlign: 'left' }}
+ >
+ {archivedRequirements.length} archived {showArchived ? '▴' : '▾'}
+
+ {showArchived && (
+
+ {archivedRequirements.map(r => (
+
+ {r.title}
+
+ ))}
+
+ )}
+
+ )}
+ >
+ )}
+ >
+ )
+
+ if (compact) {
+ return {listContent}
+ }
+
return (
@@ -48,65 +244,7 @@ export function RequirementQueue({
- {planningLoadError && {planningLoadError}
}
-
- {requirements.length === 0 ? (
-
-
No requirements yet
-
Use the intake form to capture the first planning requirement for this project.
-
- ) : (
-
- {requirements.map(requirement => (
-
-
onSelectRequirement(requirement.id)}
- style={{ width: '100%', paddingRight: requirement.status !== 'archived' && onArchiveRequirement ? '3.5rem' : undefined }}
- >
-
- {requirement.title}
- {requirement.status}
-
- {requirement.summary && {requirement.summary}
}
- {requirement.description && {requirement.description}
}
-
- {requirement.source}
- Updated {formatRelativeTime(requirement.updated_at)}
-
-
-
- {requirement.status !== 'archived' && onArchiveRequirement && (
-
{
- e.stopPropagation()
- onArchiveRequirement(requirement.id)
- }}
- style={{
- position: 'absolute',
- top: '0.5rem',
- right: '0.5rem',
- padding: '0.2rem 0.45rem',
- fontSize: '0.75rem',
- color: 'var(--text-muted)',
- opacity: archivingRequirementId === requirement.id ? 0.4 : 0.7,
- }}
- >
- {archivingRequirementId === requirement.id ? '…' : 'Archive'}
-
- )}
-
- ))}
-
- )}
+ {listContent}
)
}
diff --git a/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.test.tsx b/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.test.tsx
index f6159c8..f6c27ed 100644
--- a/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.test.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.test.tsx
@@ -34,7 +34,6 @@ function renderPanel(overrides: Partial ', () => {
localStorage.clear()
})
- it('T-6a-A1-1: renders input and both action buttons', () => {
+ it('T-6a-A1-1: renders input and primary action button', () => {
renderPanel({ planningRunsCount: 1 })
- expect(screen.getByLabelText(/What are you working on/i)).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /Start planning/i })).toBeInTheDocument()
- expect(screen.getByRole('button', { name: /What should I focus on next/i })).toBeInTheDocument()
+ expect(screen.getByLabelText(/What are you building/i)).toBeInTheDocument()
+ expect(screen.getByRole('button', { name: /Generate backlog/i })).toBeInTheDocument()
})
it('T-6a-A1-3: when provider has no usable selection → button disabled and link visible', async () => {
@@ -64,14 +62,14 @@ describe(' ', () => {
getProvMock.mockResolvedValue(makeProviderOptions(false))
renderPanel({ planningRunsCount: 1 })
- const input = screen.getByLabelText(/What are you working on/i)
+ const input = screen.getByLabelText(/What are you building/i)
await userEvent.type(input, 'My feature')
- await userEvent.click(screen.getByRole('button', { name: /Start planning/i }))
+ await userEvent.click(screen.getByRole('button', { name: /Generate backlog/i }))
await waitFor(() => {
expect(screen.getByRole('link', { name: /Set one up/i })).toBeInTheDocument()
})
- expect(screen.getByRole('button', { name: /Start planning/i })).toBeDisabled()
+ expect(screen.getByRole('button', { name: /Generate backlog/i })).toBeDisabled()
})
it('T-6a-A1-5: after run created, calls onRunCreated callback', async () => {
@@ -83,9 +81,9 @@ describe(' ', () => {
renderPanel({ planningRunsCount: 1, onRunCreated })
- const input = screen.getByLabelText(/What are you working on/i)
+ const input = screen.getByLabelText(/What are you building/i)
await userEvent.type(input, 'My feature')
- await userEvent.click(screen.getByRole('button', { name: /Start planning/i }))
+ await userEvent.click(screen.getByRole('button', { name: /Generate backlog/i }))
await waitFor(() => {
expect(onRunCreated).toHaveBeenCalledWith('req-1', 'run-1')
diff --git a/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.tsx b/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.tsx
index ee7aa66..d1c8e1d 100644
--- a/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.tsx
+++ b/frontend/src/pages/ProjectDetail/planning/WorkspaceOnboardingPanel.tsx
@@ -6,11 +6,13 @@ import { RequirementWizardModal } from './RequirementWizardModal'
interface Props {
projectId: string
onRunCreated: (requirementId: string, runId: string) => void
- onWhatsnext: () => void
planningRunsCount: number
+ planningRunReady?: boolean
+ onRunWhatsnext?: () => void
+ runningWhatsnext?: boolean
}
-export function WorkspaceOnboardingPanel({ projectId, onRunCreated, onWhatsnext, planningRunsCount }: Props) {
+export function WorkspaceOnboardingPanel({ projectId, onRunCreated, planningRunsCount, planningRunReady, onRunWhatsnext, runningWhatsnext }: Props) {
const [input, setInput] = useState('')
const [busy, setBusy] = useState(false)
const [error, setError] = useState(null)
@@ -81,82 +83,82 @@ export function WorkspaceOnboardingPanel({ projectId, onRunCreated, onWhatsnext,
}
return (
-
+
+
+
How it works
+
+ Describe a feature or goal — this becomes a requirement .
+ The AI breaks it down into a prioritized backlog of draft tasks.
+ You review the draft, approve what fits, and apply them as real tasks.
+
+
+
{showDemoBanner && (
-
-
- New here? Try the demo: we'll drop a sample requirement + approved backlog into this project so you can see the full loop.
-
+
+
Want to skip ahead? Load a sample requirement + backlog to see the full flow.
- {demoBusy ? 'Loading…' : 'Show me'}
-
-
- Not now
+ {demoBusy ? 'Loading…' : 'Load demo'}
+ Dismiss
)}
-
-
-
- What are you working on?
-
+
+
+ What are you building?
+
+
setInput(e.target.value)}
- placeholder="Describe your goal or feature…"
- style={{ width: '100%', boxSizing: 'border-box' }}
+ placeholder="e.g. Add Google SSO login for enterprise customers"
+ style={{ flex: 1, minWidth: '14rem' }}
onKeyDown={e => { if (e.key === 'Enter' && !busy) void startPlanning(input) }}
+ autoFocus
/>
-
-
- startPlanning(input)}
- disabled={busy || !input.trim() || noProvider}
- >
- {busy ? 'Starting…' : 'Start planning →'}
+ startPlanning(input)} disabled={busy || !input.trim() || noProvider}>
+ {busy ? 'Starting…' : 'Generate backlog →'}
- setShowWizard(true)}
- disabled={busy}
- type="button"
- >
- Refine (audience + success)
+ setShowWizard(true)} disabled={busy} title="Add audience and success criteria for better results">
+ Add context…
+
+
+ {noProvider && (
+
+ No AI provider configured. Set one up in Model Settings →
+
+ )}
+
+ {!noProvider && planningRunReady === false && (
+
+ No planning provider configured yet. Set up Model Settings → to run the AI step. You can still capture requirements now.
+
+ )}
+
+ {error &&
{error}
}
+
+
+ {planningRunReady && onRunWhatsnext && (
+
+ Already have tasks and want a health check?
- What should I focus on next?
+ {runningWhatsnext ? 'Starting…' : "Run What's Next →"}
-
-
- {noProvider && (
-
- No planning provider configured.{' '}
- Set one up →
-
- )}
-
- {error && (
-
{error}
)}
{showWizard && (
-
setShowWizard(false)}
- />
+ setShowWizard(false)} />
)}
)
diff --git a/frontend/src/pages/ProjectDetail/planning/hooks/usePlanningWorkspaceData.ts b/frontend/src/pages/ProjectDetail/planning/hooks/usePlanningWorkspaceData.ts
index 46cc298..7f9ad3b 100644
--- a/frontend/src/pages/ProjectDetail/planning/hooks/usePlanningWorkspaceData.ts
+++ b/frontend/src/pages/ProjectDetail/planning/hooks/usePlanningWorkspaceData.ts
@@ -7,11 +7,13 @@ import type {
BacklogCandidate,
PlanningProviderOptions,
PlanningExecutionMode,
- AccountBinding,
+ LocalConnector,
} from '../../../../types'
import {
createRequirement,
updateRequirement,
+ deleteRequirement,
+ listProjectTaskLineage,
getPlanningProviderOptions,
listPlanningRuns,
createPlanningRun,
@@ -19,11 +21,24 @@ import {
listPlanningRunBacklogCandidates,
updateBacklogCandidate,
applyBacklogCandidate,
- listAccountBindings,
+ listLocalConnectors,
+ listConnectorCliConfigs,
} from '../../../../api/client'
+import type { CliConfig } from '../../../../api/client'
import type { RequirementIntakeForm } from '../RequirementIntake'
import type { CandidateReviewForm } from '../CandidateReviewPanel'
+export interface CliConfigOption {
+ key: string // composite: `${connectorId}:${configId}`
+ connectorId: string
+ connectorLabel: string
+ configId: string
+ configLabel: string
+ modelId: string
+ isPrimary: boolean
+ isConnectorOnline: boolean
+}
+
export interface UsePlanningWorkspaceDataInput {
projectId: string
requirements: Requirement[]
@@ -67,9 +82,9 @@ export function usePlanningWorkspaceData({
const [planningProviderOptionsError, setPlanningProviderOptionsError] = useState
(null)
const [planningModelOverride, setPlanningModelOverride] = useState('')
const [planningExecutionMode, setPlanningExecutionMode] = useState('server_provider')
- const [cliBindings, setCliBindings] = useState([])
- const [cliBindingsLoading, setCliBindingsLoading] = useState(false)
- const [selectedCliBindingId, setSelectedCliBindingId] = useState(null)
+ const [cliConfigs, setCliConfigs] = useState([])
+ const [cliConfigsLoading, setCliConfigsLoading] = useState(false)
+ const [selectedCliConfigKey, setSelectedCliConfigKey] = useState(null)
const [planningCandidatesLoading, setPlanningCandidatesLoading] = useState(false)
const [planningCandidatesError, setPlanningCandidatesError] = useState(null)
const [candidateReviewError, setCandidateReviewError] = useState(null)
@@ -86,6 +101,8 @@ export function usePlanningWorkspaceData({
const [candidateFormSourceId, setCandidateFormSourceId] = useState(null)
const [showRequirementIntake, setShowRequirementIntake] = useState(false)
const [archivingRequirementId, setArchivingRequirementId] = useState(null)
+ const [discardingRequirementId, setDiscardingRequirementId] = useState(null)
+ const [requirementIdsWithAppliedTasks, setRequirementIdsWithAppliedTasks] = useState | undefined>(undefined)
const [runningWhatsnext, setRunningWhatsnext] = useState(false)
const [cancellingPlanningRunId, setCancellingPlanningRunId] = useState(null)
const [planningRunFlash, setPlanningRunFlash] = useState<{ runId: string; kind: 'success' | 'error'; message: string } | null>(null)
@@ -125,29 +142,77 @@ export function usePlanningWorkspaceData({
loadProviderOptions()
}, [loadProviderOptions])
- async function loadCliBindings() {
- setCliBindingsLoading(true)
+ async function loadCliConfigs() {
+ setCliConfigsLoading(true)
try {
- const res = await listAccountBindings()
- const cli = res.data.filter(b => b.provider_id.startsWith('cli:') && b.is_active)
- setCliBindings(cli)
- const primary = cli.find(b => b.is_primary) ?? cli[0] ?? null
- setSelectedCliBindingId(prev => prev ?? primary?.id ?? null)
+ const connectorsResp = await listLocalConnectors()
+ const activeConnectors = connectorsResp.data.filter((c: LocalConnector) => c.status !== 'revoked')
+ const entries = await Promise.all(
+ activeConnectors.map((c: LocalConnector) =>
+ listConnectorCliConfigs(c.id)
+ .then(r => ({ connector: c, configs: r.data as CliConfig[] }))
+ .catch(() => ({ connector: c, configs: [] as CliConfig[] }))
+ )
+ )
+ const options: CliConfigOption[] = []
+ for (const { connector, configs } of entries) {
+ for (const cfg of configs) {
+ options.push({
+ key: `${connector.id}:${cfg.id}`,
+ connectorId: connector.id,
+ connectorLabel: connector.label || 'Unnamed Connector',
+ configId: cfg.id,
+ configLabel: cfg.label || cfg.provider_id,
+ modelId: cfg.model_id,
+ isPrimary: cfg.is_primary,
+ isConnectorOnline: connector.status === 'online',
+ })
+ }
+ }
+ setCliConfigs(options)
+ const autoSelect = options.find(o => o.isPrimary && o.isConnectorOnline)
+ ?? options.find(o => o.isPrimary)
+ ?? options[0]
+ ?? null
+ setSelectedCliConfigKey(prev => prev ?? (autoSelect?.key ?? null))
} catch {
- // non-critical; leave empty
+ // non-critical
} finally {
- setCliBindingsLoading(false)
+ setCliConfigsLoading(false)
+ }
+ }
+
+ async function loadAppliedLineageMeta() {
+ try {
+ const resp = await listProjectTaskLineage(projectId)
+ const ids = new Set(resp.data.map((e: { requirement_id?: string }) => e.requirement_id).filter((id): id is string => Boolean(id)))
+ setRequirementIdsWithAppliedTasks(ids)
+ } catch {
+ // non-fatal, leave as undefined so RequirementQueue shows Archive-only until load succeeds
}
}
+ useEffect(() => {
+ void loadAppliedLineageMeta()
+ // Only re-run when projectId changes; the function is stable within a project mount.
+ // eslint-disable-next-line react-hooks/exhaustive-deps
+ }, [projectId])
+
useEffect(() => {
if (requirements.length === 0) {
if (selectedRequirementId !== null) setSelectedRequirementId(null)
return
}
if (!selectedRequirementId || !requirements.some(r => r.id === selectedRequirementId)) {
- const first = requirements.find(r => r.status !== 'archived')
- setSelectedRequirementId(first?.id ?? null)
+ const firstUserReq = requirements.find(
+ r => r.status !== 'archived' && r.source !== 'analysis' && r.source !== 'system'
+ )
+ // When no user requirements exist, fall back to the most recent analysis
+ // requirement (What's Next) so results remain accessible after reload.
+ const firstSelectable = firstUserReq ?? requirements.find(
+ r => r.status !== 'archived' && r.source === 'analysis'
+ )
+ setSelectedRequirementId(firstSelectable?.id ?? null)
}
}, [requirements, selectedRequirementId])
@@ -246,7 +311,8 @@ export function usePlanningWorkspaceData({
return
}
if (!selectedPlanningCandidateId || !planningCandidates.some(c => c.id === selectedPlanningCandidateId)) {
- setSelectedPlanningCandidateId(planningCandidates[0].id)
+ const firstActive = planningCandidates.find(c => c.status !== 'rejected')
+ setSelectedPlanningCandidateId(firstActive?.id ?? null)
}
}, [planningCandidates, selectedPlanningCandidateId, pendingSelection.candidateId])
@@ -290,6 +356,12 @@ export function usePlanningWorkspaceData({
// --- Computed values ---
+ const activeRun = planningRuns.find(
+ r => r.status === 'queued' || r.status === 'running' ||
+ r.dispatch_status === 'queued' || r.dispatch_status === 'leased'
+ )
+ const activeRunDispatchStatus = activeRun?.dispatch_status ?? null
+
const selectedRequirement = requirements.find(r => r.id === selectedRequirementId) ?? null
const selectedPlanningRun = planningRuns.find(run => run.id === selectedPlanningRunId) ?? null
const selectedPlanningCandidate = planningCandidates.find(c => c.id === selectedPlanningCandidateId) ?? null
@@ -323,8 +395,8 @@ export function usePlanningWorkspaceData({
)
const canApplySelectedCandidate = Boolean(
selectedPlanningCandidate &&
- selectedPlanningCandidate.status === 'approved' &&
- !candidateFormDirty &&
+ selectedPlanningCandidate.status !== 'rejected' &&
+ selectedPlanningCandidate.status !== 'applied' &&
!savingCandidate &&
!applyingCandidate,
)
@@ -362,9 +434,9 @@ export function usePlanningWorkspaceData({
useEffect(() => {
if (planningSelectedExecutionMode === 'local_connector') {
- void loadCliBindings()
+ void loadCliConfigs()
}
- // loadCliBindings is defined in the same render scope but is not memoized.
+ // loadCliConfigs is defined in the same render scope but is not memoized.
// The dep array intentionally contains only the mode to avoid re-fetching on
// every render; the function reads current state via closure at call time.
}, [planningSelectedExecutionMode])
@@ -482,6 +554,21 @@ export function usePlanningWorkspaceData({
}
}
+ async function handleDiscardRequirement(id: string) {
+ setDiscardingRequirementId(id)
+ try {
+ await deleteRequirement(id)
+ onRequirementsChange(requirements.filter(r => r.id !== id))
+ if (selectedRequirementId === id) setSelectedRequirementId(null)
+ onSuccess('Requirement discarded.')
+ await loadAppliedLineageMeta()
+ } catch (err) {
+ onError(err instanceof Error ? err.message : 'Failed to discard requirement')
+ } finally {
+ setDiscardingRequirementId(null)
+ }
+ }
+
async function handleCreatePlanningRun() {
if (!selectedRequirement || !planningRunReady) return
try {
@@ -495,7 +582,12 @@ export function usePlanningWorkspaceData({
...(requestedModelID ? { model_id: requestedModelID } : {}),
execution_mode: planningSelectedExecutionMode,
...(planningSelectedExecutionMode === 'local_connector' ? { adapter_type: 'backlog' } : {}),
- ...(planningSelectedExecutionMode === 'local_connector' && selectedCliBindingId ? { account_binding_id: selectedCliBindingId } : {}),
+ ...(planningSelectedExecutionMode === 'local_connector' && selectedCliConfigKey
+ ? (() => {
+ const [connectorId, configId] = selectedCliConfigKey.split(':')
+ return { connector_id: connectorId, cli_config_id: configId }
+ })()
+ : {}),
})
setPlanningRuns(prev => [response.data, ...prev.filter(run => run.id !== response.data.id)])
setSelectedPlanningRunId(response.data.id)
@@ -551,7 +643,12 @@ export function usePlanningWorkspaceData({
trigger_source: 'manual',
execution_mode: planningSelectedExecutionMode,
adapter_type: 'whatsnext',
- ...(planningSelectedExecutionMode === 'local_connector' && selectedCliBindingId ? { account_binding_id: selectedCliBindingId } : {}),
+ ...(planningSelectedExecutionMode === 'local_connector' && selectedCliConfigKey
+ ? (() => {
+ const [connectorId, configId] = selectedCliConfigKey.split(':')
+ return { connector_id: connectorId, cli_config_id: configId }
+ })()
+ : {}),
})
setPlanningRuns(prev => [runRes.data, ...prev.filter(r => r.id !== runRes.data.id)])
setSelectedPlanningRunId(runRes.data.id)
@@ -610,18 +707,53 @@ export function usePlanningWorkspaceData({
// so the backend records the task's `source` correctly for audit.
const [selectedExecutionMode, setSelectedExecutionMode] = useState<'manual' | 'role_dispatch'>('manual')
+ async function handleSkipCandidate() {
+ if (!selectedPlanningCandidate || savingCandidate || applyingCandidate) return
+ const currentId = selectedPlanningCandidate.id
+
+ // Advance to next non-rejected candidate in list order, wrapping if at end
+ const currentIdx = planningCandidates.findIndex(c => c.id === currentId)
+ const rest = [
+ ...planningCandidates.slice(currentIdx + 1),
+ ...planningCandidates.slice(0, currentIdx),
+ ]
+ const next = rest.find(c => c.status !== 'rejected') ?? null
+
+ try {
+ setSavingCandidate(true)
+ setCandidateReviewError(null)
+ const response = await updateBacklogCandidate(currentId, { status: 'rejected' })
+ setPlanningCandidates(prev => prev.map(c => c.id === currentId ? response.data : c))
+ setSelectedPlanningCandidateId(next?.id ?? null)
+ syncCandidateForm(next ?? null)
+ } catch (err) {
+ setCandidateReviewError(err instanceof Error ? err.message : 'Failed to skip candidate')
+ } finally {
+ setSavingCandidate(false)
+ }
+ }
+
async function handleApplyPlanningCandidate() {
if (!selectedPlanningCandidate) return
- if (candidateFormDirty) {
- setCandidateReviewError('Save or reset candidate edits before applying it to tasks.')
- setCandidateReviewMessage(null)
- return
- }
try {
setApplyingCandidate(true)
setCandidateReviewError(null)
setCandidateReviewMessage(null)
+
+ // Auto-save any dirty title/description edits before applying
+ if (candidateFormDirty) {
+ const editPayload: { title?: string; description?: string } = {}
+ const trimmedTitle = candidateForm.title.trim()
+ if (trimmedTitle !== selectedPlanningCandidate.title) editPayload.title = trimmedTitle
+ if (candidateForm.description !== selectedPlanningCandidate.description) editPayload.description = candidateForm.description
+ if (Object.keys(editPayload).length > 0) {
+ const saved = await updateBacklogCandidate(selectedPlanningCandidate.id, editPayload)
+ setPlanningCandidates(prev => prev.map(c => c.id === saved.data.id ? saved.data : c))
+ syncCandidateForm(saved.data)
+ }
+ }
+
const response = await applyBacklogCandidate(selectedPlanningCandidate.id, {
executionMode: selectedExecutionMode,
})
@@ -630,6 +762,7 @@ export function usePlanningWorkspaceData({
await Promise.all([
onReload(),
selectedPlanningRunId ? loadPlanningCandidates(selectedPlanningRunId) : Promise.resolve(),
+ loadAppliedLineageMeta(),
])
setCandidateReviewMessage(
response.data.already_applied
@@ -683,6 +816,7 @@ export function usePlanningWorkspaceData({
applyingCandidate,
onPersistCandidateReview: persistCandidateReview,
onApplyCandidate: handleApplyPlanningCandidate,
+ onSkipCandidate: handleSkipCandidate,
onResetCandidateForm: resetCandidateForm,
// Phase 5 B3: apply execution mode + setter so the panel radio group
// can drive the Apply request body.
@@ -694,10 +828,10 @@ export function usePlanningWorkspaceData({
planningProviderOptionsError,
planningSelectedExecutionMode,
onPlanningExecutionModeChange: setPlanningExecutionMode,
- cliBindings,
- cliBindingsLoading,
- selectedCliBindingId,
- onCliBindingChange: setSelectedCliBindingId,
+ cliConfigs,
+ cliConfigsLoading,
+ selectedCliConfigKey,
+ onCliConfigChange: setSelectedCliConfigKey,
planningModelOverride,
onPlanningModelOverrideChange: setPlanningModelOverride,
planningRunReady,
@@ -712,6 +846,10 @@ export function usePlanningWorkspaceData({
onCreateRequirement: handleCreateRequirement,
onArchiveRequirement: handleArchiveRequirement,
archivingRequirementId,
+ onDiscardRequirement: handleDiscardRequirement,
+ discardingRequirementId,
+ requirementIdsWithAppliedTasks,
+ activeRunDispatchStatus,
// run flash
planningRunFlash,
onDismissRunFlash: () => setPlanningRunFlash(null),
diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts
index 81233fa..0fc6c62 100644
--- a/frontend/src/types/index.ts
+++ b/frontend/src/types/index.ts
@@ -57,6 +57,10 @@ export interface Task {
priority: 'low' | 'medium' | 'high';
assignee: string;
source: string;
+ /** Phase 6b — connector execution lifecycle status. */
+ dispatch_status?: 'none' | 'queued' | 'running' | 'completed' | 'failed';
+ /** Phase 6b — raw JSON result from connector execution. */
+ execution_result?: Record | null;
created_at: string;
updated_at: string;
}
diff --git a/go.work b/go.work
new file mode 100644
index 0000000..1f9018a
--- /dev/null
+++ b/go.work
@@ -0,0 +1,3 @@
+go 1.25.0
+
+use ./backend