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

Class Method Details

.active_epicsArray<Hash>

Get all active epics.

Returns:

  • (Array<Hash>)


173
174
175
# File 'lib/brainiac/plugins/basecamp/orchestrator.rb', line 173

def active_epics
  load_epics.select { |e| e["status"] == "active" }
end

.all_epicsArray<Hash>

Get all epics (active and completed).

Returns:

  • (Array<Hash>)


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.

Parameters:

  • epic_id (String)

    Epic ID

Returns:

  • (Hash, nil)


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.

Parameters:

  • todolist_id (String, Integer)

    Basecamp todolist ID

Returns:

  • (Hash, nil)


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.

Parameters:

  • card_number (Integer)

    Fizzy card number

Returns:

  • (Hash, nil)

    Epic state or nil



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.

Parameters:

  • card_number (Integer, String)

    Fizzy card number that was completed

Returns:

  • (Boolean)

    Whether this card was part of an epic



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.

Parameters:

  • todolist_id (String, Integer)

    Basecamp todolist ID

  • project_id (String, Integer)

    Basecamp project/bucket ID

  • agent (String)

    Agent name to orchestrate

  • title (String)

    Epic/todolist title

Returns:

  • (Hash)

    The created epic run state



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.

Parameters:

  • todo_id (String, Integer)

    The trigger todo ID

  • todolist_id (String, Integer)

    Parent todolist ID

  • project_id (String, Integer)

    Basecamp project/bucket ID

  • agent (String)

    Agent name

  • title (String)

    Todolist title

Returns:

  • (Hash)

    The created epic run state



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