From 04b6e7eef473cb6eb69e345a4ea08243a8713077 Mon Sep 17 00:00:00 2001 From: Claude Sonnet 5 Date: Sat, 4 Jul 2026 04:39:28 +0000 Subject: feat(story,scheduler): add epic-proposal tool + AskUser-timeout escalation (Phase 7c) Two independent pieces, completing Phase 7. Epic-proposal tool: AgentChannel gains a 5th method, ProposeEpic(ctx, EpicProposal{Name, Description, StoryIDs}) (epicID, err), implemented on storeChannel -- matches an existing epic by exact name or creates one (DiscoverySource: "agent"), sets epic_id on each resolvable story (skips, doesn't fail, on an unresolved ID), emits KindEpicProposed attached to the epic's own ID with payload {epic_id, name, story_ids}. Wired into both transports exactly like Phase 6 wired role into spawn_subtask: a new propose_epic tool in the native tool-use loop (internal/agentloop/tools.go) and the MCP transport (internal/executor/agentmcp.go). This is the mechanism for a discovery/planner-role agent to act on its own judgment that several stories it's been given form one cohesive initiative -- the judgment itself lives in the calling agent's instructions/model, not in this code. AskUser-timeout escalation: extends the existing Scheduler (Phase 5's retry-then-escalate watcher) rather than adding a new component, since "stuck task needs escalation" is exactly what it already does. Finds role-typed BLOCKED tasks whose question has been outstanding longer than SchedulerConfig.AskUserTimeoutSeconds (default 10 minutes) using task.UpdatedAt as the outstanding-since timestamp -- no new column needed, since UpdateTaskQuestion already stamps it the instant a question is recorded and nothing else touches the row while BLOCKED. Resolves the next ladder tier from the latest execution's EscalationRung, records the system-authored fallback answer as an audit-trail task.Interaction, clears the question, sets the new tasks.needs_review flag, emits KindEscalated (now carrying a trigger field: "failure" vs "ask_user_timeout" for the existing failure-retry path vs this one), and resumes via Pool.SubmitResume at the escalated tier -- degrading to same-tier resume with final:true if the ladder's exhausted or no role config exists, since unblocking the task takes priority over having somewhere higher to escalate to. GET /api/tasks?needs_review=true surfaces auto-decided tasks for human review. go build/vet/test -race -count=1 all pass, full suite (20 packages), run twice to rule out flakiness in the new tests. (One pre-existing, unrelated test -- TestHandleRunTask_CascadesRetryToFailedDeps, a tempdir-cleanup race -- appeared once under full-suite load per the implementing agent's report and did not reproduce in this verification's runs either; not a regression from this work.) Co-Authored-By: Claude Sonnet 5 Claude-Session: https://claude.ai/code/session_01V1moSNCJRcP6kykA4tyUSs --- CLAUDE.md | 59 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++--- 1 file changed, 56 insertions(+), 3 deletions(-) (limited to 'CLAUDE.md') diff --git a/CLAUDE.md b/CLAUDE.md index 32bca69..a728f1f 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -78,7 +78,7 @@ Config defaults to `~/.claudomator/config.toml`. Data is stored in `~/.claudomat | `internal/agentloop` | Provider-neutral tool-use control-flow loop shared by all `NativeRunner`s | | `internal/sandbox` | `Sandbox` interface (`HostSandbox`/`DockerSandbox`) + pre-tool-use guardrail hooks | | `internal/role` | `RoleConfig`/`Tier`/`Rung` — per-role system prompt + provider/model escalation ladder | -| `internal/scheduler` | `Scheduler` — polls role-typed FAILED tasks and retries/escalates them per their role's ladder; `StoryOrchestrator` (Phase 7b) — polls stories and drives Builder → Evaluators → Arbitration → REVIEW_READY | +| `internal/scheduler` | `Scheduler` — polls role-typed FAILED tasks and retries/escalates them per their role's ladder, and (Phase 7c) role-typed BLOCKED tasks whose ask_user question has timed out; `StoryOrchestrator` (Phase 7b) — polls stories and drives Builder → Evaluators → Arbitration → REVIEW_READY | | `internal/story` | `Epic`/`Story` — planning layer above the flat task tree; data model + CRUD only (see `internal/scheduler.StoryOrchestrator` for the orchestration that drives it) | | `web` | Embedded static UI (`embed.go`) | @@ -186,7 +186,7 @@ Schema is auto-migrated additively on `storage.Open()` — new columns are `ALTE tasks: id, name, description, config_json, priority, timeout_ns, retry_json, tags_json, depends_on_json, parent_task_id, state, rejection_comment, question_json, summary, elaboration_input, interactions_json, - created_at, updated_at + needs_review, created_at, updated_at executions: id, task_id, start_time, end_time, exit_code, status, stdout_path, stderr_path, artifact_dir, cost_usd, error_msg, session_id, @@ -469,6 +469,55 @@ spawned here with no matching active `role_configs` row dispatches in the same degraded (no role resolution) mode `Pool.execute()` already logs a warning and falls back to for any other role-typed task. +### Epic-proposal tool (Phase 7c) + +A 5th `AgentChannel` method, `ProposeEpic(ctx, agentchannel.EpicProposal{Name, +Description, StoryIDs})`, lets a discovery/planner-role agent that has been +handed several story IDs act on its own judgment that they form a cohesive +initiative. `storeChannel.ProposeEpic` (`internal/executor/channel.go`) +matches an existing epic by exact `Name` (`Store.GetEpicByName`) or creates a +new one, then sets `epic_id` on each resolved story via `Store.GetStory` + +`Store.UpdateStory` — a story ID that doesn't resolve is skipped, not +fatal to the call. Emits `event.KindEpicProposed` attached to the epic's own +ID (payload `{epic_id, name, story_ids}`, `story_ids` being only the ones +actually grouped), matching the story orchestrator's convention of attaching +planning-layer ceremony events to the entity they're about. Exposed as a +`propose_epic` tool on both transports — `internal/agentloop/tools.go` (native +tool-use loop) and `internal/executor/agentmcp.go` (MCP) — with the same +`{name, description?, story_ids}` schema Phase 6 used for `spawn_subtask`'s +`role` parameter. This phase only builds the mechanism; judging *whether* +stories belong together is the calling agent's job via its instructions/model. + +### AskUser-timeout escalation (Phase 7c) + +`internal/scheduler.Scheduler` (the same retry-then-escalate watcher described +above) also polls BLOCKED role-typed tasks whose `question_json` has been +outstanding longer than `config.SchedulerConfig.AskUserTimeout()` (default 10 +minutes; `[scheduler] ask_user_timeout_seconds` in TOML) — `tickAskUserTimeouts`, +called from `Tick` alongside the existing FAILED-task pass. The "outstanding +since" timestamp is `task.UpdatedAt` itself, not a new column: +`storage.DB.UpdateTaskQuestion` (the last write made to a task's row on its +way into BLOCKED) already stamps `updated_at` the moment the question was +recorded, and nothing else touches the row while it sits BLOCKED. For each +timed-out question, the scheduler resolves the role's active escalation +ladder from the tier the task was dispatched at (`latest execution's +EscalationRung`) and, if a higher tier exists, escalates to its first +candidate (mirroring `processTask`'s existing "always `Candidates[0]`" +simplification); if not (ladder exhausted, no active role config, etc.), it +still resumes the task at its current tier rather than leaving it stuck +forever, marking that escalation `final: true`. Either way it records the +system-authored answer `"[auto-escalated: no human response within timeout; +proceeding with best judgment]"` as a `task.Interaction` (mirroring +`api.answerTaskQuestion`'s audit trail for a real human answer), clears +`question_json`, sets `tasks.needs_review = true`, and resumes the task via +`Pool.SubmitResume` — the same mechanism `POST /api/tasks/{id}/answer` uses, +just at the escalated tier's provider/model instead of the one that asked. +Emits `event.KindEscalated` with a `trigger` field (`"failure"` for the +existing FAILED-task path, `"ask_user_timeout"` for this one) so the two are +distinguishable in the event stream. `GET /api/tasks?needs_review=true` +surfaces flagged tasks for a human to double-check the system's stand-in +answer later. + --- @@ -488,6 +537,10 @@ Same pattern as `task.Priority`/`RetryConfig` below: `internal/role.RoleConfig.T `Scheduler.handled` (keyed by execution ID) prevents re-emitting a `final: true` `KindEscalated` event on every poll tick once a role-typed task's ladder is exhausted, but it's per-process and resets on restart. A restart can produce one extra "reconsideration" (and, if still exhausted/denied, one more `KindEscalated` event) — not an infinite loop, just not persisted. A future phase could persist this via a `tasks` column if that turns out to matter. +### AskUser-timeout escalation resumes at the same tier when the ladder is exhausted + +The phase description for `Scheduler.escalateAskUserTimeout` didn't specify behavior when a BLOCKED task is already at its role's top escalation tier (or has no active role config at all). Leaving the task stuck BLOCKED forever seemed worse than the alternative, so it still resumes the task (unblocking it is the priority) at its *current* provider/model — no `UpdateTaskAgent` call — and marks that `KindEscalated` event `final: true` so it's visible in the event stream as "resumed without escalating" rather than a genuine tier bump. A later phase could reconsider this (e.g. leaving the task BLOCKED and only flagging it) if it turns out best-effort auto-resumption at the same tier isn't the right call for every role. + ### StoryOrchestrator's Arbitration outcome always routes to REVIEW_READY `internal/scheduler.StoryOrchestrator.finalizeArbitration` does not parse the @@ -560,7 +613,7 @@ In `executor.go`, `withFailureHistory` creates a copy of the task struct (`copy | Method | Endpoint | Description | |--------|----------|-------------| -| GET | `/api/tasks` | List tasks; `?state=RUNNING&since=&limit=50` | +| GET | `/api/tasks` | List tasks; `?state=RUNNING&since=&limit=50&needs_review=true` | | POST | `/api/tasks` | Create task (JSON body) | | GET | `/api/tasks/{id}` | Get task | | DELETE | `/api/tasks/{id}` | Delete task + subtasks + executions | -- cgit v1.2.3