Module: Brainiac::Plugins::Basecamp::Orchestrator
- Defined in:
- lib/brainiac/plugins/basecamp/orchestrator.rb
Overview
Manages active epic execution state.
An epic = a Basecamp todolist where each todo is linked to a Fizzy card. The orchestrator drives execution: reads the todolist, builds the dep graph, assigns unblocked Fizzy cards, and advances state as cards complete.
State is persisted to ~/.brainiac/basecamp_epics.json.
Constant Summary collapse
- BRAINIAC_DIR =
ENV.fetch("BRAINIAC_DIR", File.join(Dir.home, ".brainiac"))
- EPICS_FILE =
File.join(BRAINIAC_DIR, "basecamp_epics.json")
Class Method Summary collapse
-
.active_epics ⇒ Array<Hash>
Get all active epics.
-
.all_epics ⇒ Array<Hash>
Get all epics (active and completed).
-
.find_epic(epic_id) ⇒ Hash?
Get a specific epic by ID.
-
.find_epic_by_todolist(todolist_id) ⇒ Hash?
Find an epic by its todolist ID.
-
.find_epic_for_card(card_number) ⇒ Hash?
Find an active epic that contains a given Fizzy card.
-
.on_card_completed(card_number) ⇒ Boolean
Called when an agent completes a Fizzy card session.
-
.start_epic(todolist_id:, project_id:, agent:, title:) ⇒ Hash
Start orchestrating an epic from a todolist.
-
.start_epic_from_todo(todo_id:, todolist_id:, project_id:, agent:, title:) ⇒ Hash
Start an epic from a single "trigger" todo that contains the todolist context.
Class Method Details
.active_epics ⇒ Array<Hash>
Get all active epics.
173 174 175 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 173 def active_epics load_epics.select { |e| e["status"] == "active" } end |
.all_epics ⇒ Array<Hash>
Get all epics (active and completed).
180 181 182 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 180 def all_epics load_epics end |
.find_epic(epic_id) ⇒ Hash?
Get a specific epic by ID.
188 189 190 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 188 def find_epic(epic_id) load_epics.find { |e| e["id"] == epic_id } end |
.find_epic_by_todolist(todolist_id) ⇒ Hash?
Find an epic by its todolist ID.
166 167 168 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 166 def find_epic_by_todolist(todolist_id) load_epics.find { |e| e["basecamp_todolist_id"] == todolist_id.to_s } end |
.find_epic_for_card(card_number) ⇒ Hash?
Find an active epic that contains a given Fizzy card.
155 156 157 158 159 160 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 155 def find_epic_for_card(card_number) load_epics.find do |epic| epic["status"] == "active" && epic["tasks"].any? { |t| t["fizzy_card"] == card_number.to_i } end end |
.on_card_completed(card_number) ⇒ Boolean
Called when an agent completes a Fizzy card session. Checks if the card is part of an active epic and advances the orchestration.
107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 107 def on_card_completed(card_number) card_number = card_number.to_i epic = find_epic_for_card(card_number) return false unless epic # Idempotency check — don't process completion twice task = epic["tasks"].find { |t| t["fizzy_card"] == card_number } if task && task["status"] == "complete" LOG.info "[Basecamp:Orchestrator] Card ##{card_number} already complete, skipping duplicate completion" if defined?(LOG) return true end LOG.info "[Basecamp:Orchestrator] Card ##{card_number} completed, advancing epic '#{epic['title']}'" if defined?(LOG) # Mark the task as complete in our state if task TaskState.transition!(task, :complete, triggered_by: "fizzy_card_completed") task["completed_at"] = Time.now.iso8601 epic["updated_at"] = Time.now.iso8601 log_event(epic, "task_completed", "Card ##{card_number} completed") end # Mark the corresponding Basecamp todo as complete mark_todo_complete(epic, card_number) # Post a status comment on the Basecamp todo post_completion_comment(epic, card_number) # Check if epic is fully done if epic["tasks"].all? { |t| t["status"] == "complete" } complete_epic(epic) else # Dispatch epic review agent before moving to next tasks # This ensures the plan still makes sense after implementation decisions dispatch_epic_review(epic, card_number) do # After review completes, dispatch next unblocked tasks resolve_and_dispatch(epic) end end save_epic(epic) true end |
.start_epic(todolist_id:, project_id:, agent:, title:) ⇒ Hash
Start orchestrating an epic from a todolist.
28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 28 def start_epic(todolist_id:, project_id:, agent:, title:) review_gate = Config.review_gate epic = { "id" => "epic-#{todolist_id}", "basecamp_todolist_id" => todolist_id.to_s, "basecamp_project_id" => project_id.to_s, "agent" => agent, "title" => title, "status" => "active", "review_gate" => review_gate, "started_at" => Time.now.iso8601, "updated_at" => Time.now.iso8601, "tasks" => [], "epic_branches" => {}, "history" => [] } save_epic(epic) log_event(epic, "started", "Epic orchestration started by #{agent} (review_gate: #{review_gate})") LOG.info "[Basecamp:Orchestrator] Started epic '#{title}' (todolist #{todolist_id}) " \ "with agent #{agent}, review_gate: #{review_gate}" if defined?(LOG) # FIRST: Populate tasks with project info from Fizzy card tags # This must happen BEFORE creating epic branches so we know which repos are involved populate_tasks(epic) # THEN: Create epic branches for all projects (now that we know which projects are involved) if review_gate == "epic_branch" create_epic_branches_for(epic) # Sync PR state from work_items for cards that already have open PRs sync_existing_pr_state(epic) end # CRITICAL: Save epic state BEFORE dispatching tasks. # The resolve_pr_target hook reads from disk, so tasks and epic_branches # must be persisted before the agent is dispatched. save_epic(epic) # For tasks with open PRs, dispatch review gates immediately if review_gate == "epic_branch" dispatch_gates_for_existing_prs(epic) end # Finally: Dispatch unblocked work (skips tasks already in_review) dispatch_unblocked_tasks(epic) save_epic(epic) epic end |
.start_epic_from_todo(todo_id:, todolist_id:, project_id:, agent:, title:) ⇒ Hash
Start an epic from a single "trigger" todo that contains the todolist context. This is the webhook entry point — a todo is assigned to the bot account, and its parent todolist becomes the epic.
91 92 93 94 95 96 97 98 99 100 |
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 91 def start_epic_from_todo(todo_id:, todolist_id:, project_id:, agent:, title:) # Check if this epic is already running existing = find_epic_by_todolist(todolist_id) if existing && existing["status"] == "active" LOG.info "[Basecamp:Orchestrator] Epic for todolist #{todolist_id} already active, skipping" if defined?(LOG) return existing end start_epic(todolist_id: todolist_id, project_id: project_id, agent: agent, title: title) end |