diff --git a/DECISIONS.md b/DECISIONS.md index 317c258..66a4daf 100644 --- a/DECISIONS.md +++ b/DECISIONS.md @@ -4,6 +4,14 @@ 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: Phase 6c v5.1 — L0 safety + authoring lifecycle + LLM router (suggest-only) + activity visibility [agent:feature-planner] + +- **Context**: Phase 6b shipped role-dispatch end-to-end in code but the user-facing path is broken in three independent ways: (1) `execution_role` has no UI authoring surface, so the role_dispatch radio is permanently disabled (catch-22); (2) "auto-dispatch" is in name only — operators must still manually pick a role for every task; (3) connector activity during long task execution (backend-architect can run 90 min) is invisible to the frontend, making dogfood debugging impossible. Phase 5 §(g)/§(h) also flagged subprocess sandboxing as a Phase 6 blocker. The user explicitly rejected simple / phase-staged solutions: "一律不考慮簡單作法 我希望是完善的改動 而不是臨時性處理" (memory: `feedback_no_simple_approach`). Closing all four gaps in one phase is therefore in scope, even at ~15-day total cost. +- **Decision**: Phase 6c ships as **5 separate PRs**, each independently reviewable and rollback-able, but coherent as a single capability slice. **PR-1 (done)**: role catalog skeleton (hand-maintained `[]Role{...}` with per-role `DefaultTimeoutSec`) + L0 safety boundary in connector subprocess invocation (wall-clock timeout via `cmd.Cancel = SIGTERM` + `cmd.WaitDelay = 5s` escalation, output size cap via `boundedWriter`, JSON schema minimum validation, 4 new error kinds: `role_not_found`/`dispatch_timeout`/`output_too_large`/`invalid_result_schema`). **PR-2**: full authoring lifecycle — migration 030 adds generic `actor_audit` table (subject_kind/subject_id/field/old_value/new_value/actor_kind/actor_id/rationale/confidence) which is the **single source of truth** for execution_role authoring metadata (no denormalised columns on `backlog_candidates` per critic round 3 #1; frontend reads via helper `LatestAuthoring(subject_kind, subject_id, field)` joining against latest audit row); PATCH endpoint accepts `execution_role`; apply API extended with `execution_role` payload; catalog enforcement at four entry points (PATCH, apply, claim-next-task; suggest gates at validation in PR-3); CandidateReviewPanel rewritten so role_dispatch radio is always enabled with inline `` 從 catalog 拉,inline edit popover) +2. Operator 可在 apply panel **at apply time** 設 / 改 execution_role(pre-fill 自 candidate latest audit row) +3. Apply payload 帶 `execution_role`;server 在 4 個進入點做 catalog enforcement(PATCH / suggest / apply / claim-next-task) +4. Stale role(candidate 既有 role 但已不在 catalog)顯示 inline warning + 預設清空 dropdown +5. 所有 execution_role 變更走 `actor_audit` table,actor_kind ∈ {user, router, system},含 rationale + timestamp。**Audit 是唯一的 set_by/at/confidence SoT**(critic #1 — 不在 candidate 上重複欄位;frontend 顯示時走 audit JOIN) +6. `MarkTaskRoleNotFound` 在 claim-next-task 時把 stale-role task `queued → failed` 原子轉移 + +### 2.2 LLM Router — Suggest-only(PR-3) + +7. 新 prompt `prompts/meta/dispatcher.md`(category=meta),輸出 `{role_id, confidence, reasoning, alternatives[]}`(critic #10 — 放 `meta/` 子目錄,不和 `roles/` 並列也不和 backlog/whatsnext 並列) +8. `POST /api/backlog-candidates/:id/suggest-role` endpoint:呼叫 router、回傳結果**不持久化** +9. Apply panel + Candidate card 都加 "💡 Suggest" 按鈕:呼叫 router → 預填 dropdown + tooltip 顯示 reasoning + alternatives +10. Router 呼叫重用 PR-1 的 invokeBuiltinCLI(含 timeout / output cap / signal escalation);server 端在 process 內呼叫(單機假設,文件化) +11. Router timeout 來自 catalog(dispatcher role default 60s) +12. 1 個新 error_kind:`router_no_match`(router 自己判斷沒匹配)+ 既有 PR-1 kinds 涵蓋其他失敗(output_too_large / dispatch_timeout / invalid_result_schema) +13. **Auto-apply mode(`mode=role_dispatch_auto`)延到 Phase 6d**(critic #2 / user 拍板 B2)— 待 PR-5 dogfood 收集 router 品質訊號後再決定 + +### 2.3 Activity Visibility(PR-4 完整) + +14. Connector 在每個 phase 邊界(idle / claiming_run / planning / claiming_task / dispatching / submitting)呼叫 `ActivityReporter.Report`。**Phase 變化用 enqueue 不是 overwrite**(critic #5)— 確保連續 phase 切換 `claiming_task → dispatching → submitting` 都會被推送,即使在 coalesce 視窗內。`routing` phase **延到 Phase 6d**(auto-apply 上線後才需要)。 +15. `POST /api/connector/activity` lightweight endpoint 接收上報;server-side activity hub 維護 in-memory state + DB snapshot 欄位(不寫 actor_audit — critic #8,避免 write storm) +16. `GET /api/connectors/:id/activity-stream` SSE 推送即時 activity 變化;polling fallback `GET /api/connectors/:id/activity`(C1 拍板:保留 SSE) +17. Frontend `useConnectorActivity` hook:SSE 為主、polling 為輔、reconnect 邏輯、stale 偵測 +18. `ConnectorActivityBadge` 3 種 density(compact / standard / full);整合進 PlanningTab、TasksTab、CandidateReviewPanel apply 後 watch +19. `GET /api/projects/:id/active-connectors` project-level aggregate + +### 2.4 Dogfood + Docs(PR-5 完整) + +20. `docs/phase6c-dogfood-notes.md`:7 個 dogfood 步驟(5 個原 v3 觸發新 error_kind + 2 個 v5.1 觀察 router suggest 與 activity badge 切換;auto-apply / PhaseRouting 預覽留 6d) +21. `docs/operating-rules.md` 新「Role-dispatch safety + visibility model」一節,含 L0 / L1 / L2 觸發條件 + activity model 約束 +22. DECISIONS.md 補完 Phase 6c 條目(涵蓋 v5 全部設計) + +--- + +## 3. Slice 計畫(5 PR) + +### 3.1 PR-1:Catalog skeleton + L0 safety boundary(**已實作完成**) + +詳見 v3 plan 內容(保留): +- `backend/internal/roles/catalog.go`(Role struct + 6 entries + DefaultTimeoutSec + IsKnown / ByID / TimeoutFor / All) +- `backend/internal/roles/catalog_test.go`(drift detector + 9 tests) +- `backend/internal/connector/dispatch_safety.go`(boundedWriter + signal escalation + validateExecutionResult) +- `backend/internal/connector/dispatch_safety_test.go`(11 dispatch + 4 unit + 1 timeout-truncation precedence test) +- `invokeBuiltinCLI` 簽名擴 `(string, bool, string)`(+ truncated) +- `RunOnceTask` 用 `roles.TimeoutFor` + truncation/runErr precedence + schema validation + classifyDispatchRunError +- 4 個新 error_kind(dispatch_timeout / output_too_large / invalid_result_schema / role_not_found) + +**Critic round 2 修正**: +- TestMain 雙 sentinel guard(避免 user shell env 誤觸) +- ExecuteBuiltin truncation 補 ErrorKindOutputTooLarge +- 移除 redundant resolveAgentFromBinary +- runErr-over-truncated precedence + 對應 test +- boundedWriter 改 atomic.Int64(H2 防禦) +- Codex PTY io.Copy goroutine + ptmx.Close 序列(H1 修正) +- SIGTERM-ignore test slack 5s(M1 防 CI flake) +- TimeoutFor whitespace env test(L8) + +**Status**: 待開 PR;plan v5 確認後一起開 PR-1。 + +--- + +### 3.2 PR-2:Authoring 完整化 + audit log + multi-point catalog enforcement(4.6 天) + +#### 3.2.1 Migration 030 + +```sql +-- 030_authoring_audit.sql + +-- 通用 actor_audit 表 — 是 execution_role 的 single source of truth +-- (critic #1:不在 candidate 上加重複欄位) +CREATE TABLE actor_audit ( + id TEXT PRIMARY KEY, + subject_kind TEXT NOT NULL, -- 'backlog_candidate' | 'task' | 'planning_run' | 'connector' + subject_id TEXT NOT NULL, + field TEXT NOT NULL, -- 'execution_role' | 'status' | 'po_decision' | ... + old_value TEXT, + new_value TEXT, + actor_kind TEXT NOT NULL, -- 'user' | 'router' | 'system' | 'connector' + -- 'router' is reserved for Phase 6d auto-apply; + -- NO writer in 6c (PR-3 suggest writes 'user' after operator confirms) + actor_id TEXT, -- user_id | router prompt version | system component name + rationale TEXT, -- router confidence + reasoning,or system reason + confidence REAL, -- 0.0-1.0;only set when actor_kind='router' + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP +); +CREATE INDEX idx_actor_audit_subject ON actor_audit(subject_kind, subject_id, created_at DESC); +CREATE INDEX idx_actor_audit_subject_field ON actor_audit(subject_kind, subject_id, field, created_at DESC); +``` + +`backlog_candidates.execution_role` 既有欄位**保留**(v3 Phase 5 已加,是當前 task source 的 input)。但「誰設的、何時設、信心多少」一律從 `actor_audit` 查 — 不在 candidate row 上重複欄位。 + +**Helper 函式**:`backend/internal/audit/audit.go` 提供 `LatestAuthoring(subjectKind, subjectID, field)` 回傳最新一筆 audit row(with actor_kind/at/confidence/rationale)。Frontend `GET /api/backlog-candidates/:id` response 加 `execution_role_authoring` 欄位(透過此 helper 回填,非 column)。 + +#### 3.2.2 Backend changes + +**Store 層**: +```go +// backlog_candidate_store.go +func (s *Store) UpdateExecutionRole( + ctx context.Context, id, role string, actor ActorInfo, +) error +// 單一 transaction: +// 1. 驗 role 在 catalog(roles.IsKnown)— role="" 視為 clear,不需 catalog 檢查 +// 2. SELECT old_value(for audit) +// 3. UPDATE candidate.execution_role +// 4. INSERT actor_audit row(含 actor_kind/actor_id/rationale/confidence) +// 5. COMMIT + +// 既有 ApplyToTaskWithMode 簽名擴: +func (s *Store) ApplyToTaskWithMode( + id, executionMode, executionRole string, actor ActorInfo, +) (*ApplyResult, error) +// 內部: +// - mode=role_dispatch && role 空 → ErrApplyMissingRole +// - mode=role_dispatch && !roles.IsKnown(role) → ErrApplyUnknownRole +// - mode=manual → ignore role +// - 同 transaction 寫 candidate.execution_role (若有變) + audit + create task +``` + +**Handler 層**: +```go +// PATCH /api/backlog-candidates/:id 擴:accept execution_role 欄位 +type UpdateBacklogCandidateRequest struct { + POdecision *string `json:"po_decision,omitempty"` + ExecutionRole *string `json:"execution_role,omitempty"` // pointer = explicitly set/clear vs not-mentioned +} + +// POST /api/backlog-candidates/:id/apply 擴: +type ApplyBacklogCandidateRequest struct { + ExecutionMode string `json:"execution_mode"` + ExecutionRole string `json:"execution_role,omitempty"` +} +``` + +Validation: +- mode=`role_dispatch` + role empty → 400 `"execution_role required when execution_mode=role_dispatch"` +- mode=`role_dispatch` + role 不在 catalog → 400 with current catalog list +- mode=`manual` → ignore role +- mode=`role_dispatch_auto` → PR-3 處理 + +**`MarkTaskRoleNotFound` + claim-next-task enforcement**(從 v3 帶過來): +```go +func (s *TaskStore) MarkTaskRoleNotFound( + ctx, taskID, roleID string, +) error +// 條件 update:dispatch_status='queued' → 'failed' +// 同 transaction 寫 execution_result {success:false, error_kind:'role_not_found'} +// + actor_audit row(actor_kind='system') +// 若 task 已被 lease(status=running)→ 0 rows → ErrTaskNotInQueuedState +``` + +```go +// connector_dispatch.go ClaimNextTask 加: +roleID := parseRoleIDFromSource(task.Source) +if !roles.IsKnown(roleID) { + if err := store.MarkTaskRoleNotFound(ctx, task.ID, roleID); err != nil { + log.Printf("mark role_not_found failed: %v", err) + } + continue // 看下一個 task +} +``` + +**`GET /api/roles`**(公開): +```go +type RoleResponse struct { + ID string `json:"id"` + Title string `json:"title"` + Version int `json:"version"` + UseCase string `json:"use_case"` + DefaultTimeoutSec int `json:"default_timeout_sec"` + Category string `json:"category"` // "role" | "meta" +} + +func (h *Handler) ListRoles(w, r) { + roles := roles.All() + // filter category="role" — meta-roles (dispatcher) 不暴露給 apply panel + out := []RoleResponse{} + for _, r := range roles { + if r.Category == "role" { + out = append(out, toResponse(r)) + } + } + writeJSON(w, 200, out) +} +``` + +⚠️ 需要在 `roles/catalog.go` 加 `Role.Category` 欄位(之前 v3 沒有)— PR-2 順便加。dispatcher role(PR-3 加)會用 `Category: "meta"`。 + +#### 3.2.3 Frontend changes + +**新檔 `frontend/src/types/roles.ts`**: +```typescript +export const KNOWN_ROLE_IDS = [ + 'backend-architect', + 'ui-scaffolder', + 'db-schema-designer', + 'api-contract-writer', + 'test-writer', + 'code-reviewer', +] as const; +export type KnownRoleId = typeof KNOWN_ROLE_IDS[number]; + +export interface RoleInfo { + id: KnownRoleId; + title: string; + version: number; + use_case: string; + default_timeout_sec: number; + category: 'role' | 'meta'; +} +``` + +**新檔 `frontend/src/api/roles.ts`**: +```typescript +export async function listRoles(): Promise +export async function suggestRoleForCandidate(candidateID: string): Promise // PR-3 +``` + +**Drift test** `roles.test.ts`:fetch `/api/roles` → assert id 集合 = `KNOWN_ROLE_IDS`。 + +**CandidateReviewPanel 重寫 execution-mode UI**: +```tsx +// Radio 永遠 enabled(不看 candidate.execution_role) +const [chosenRole, setChosenRole] = useState(candidateInitialRole) +const candidateRole = selectedCandidate?.execution_role +const roleStaleWarning = candidateRole && !KNOWN_ROLE_IDS.includes(candidateRole) + + onSelectedExecutionModeChange('role_dispatch')} /> + +{selectedExecutionMode === 'role_dispatch' && ( + <> + + {roleStaleWarning && ( +
+ ⚠ Previously suggested role {candidateRole} is no longer in the catalog. +
+ )} + {/* Suggest 按鈕 在 PR-3 加 */} + +)} + +// Apply button disabled 條件加: +// (selectedExecutionMode === 'role_dispatch' && !chosenRole) +``` + +**新 component `CandidateRoleEditor.tsx`**(在 candidate card 上): +```tsx +
+ {candidate.execution_role ? ( + + [{role.title}] + + ) : ( + — no role set — + )} + + {/* popover with role