brainiac-basecamp 0.0.16 → 0.0.18

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -49,9 +49,6 @@ module Brainiac
49
49
  LOG.info "[Basecamp:Orchestrator] Started epic '#{title}' (todolist #{todolist_id}) " \
50
50
  "with agent #{agent}, review_gate: #{review_gate}" if defined?(LOG)
51
51
 
52
- # Initialize epic memory for cross-task shared knowledge
53
- EpicMemory.initialize_for(epic)
54
-
55
52
  # FIRST: Populate tasks with project info from Fizzy card tags
56
53
  # This must happen BEFORE creating epic branches so we know which repos are involved
57
54
  populate_tasks(epic)
@@ -123,7 +120,7 @@ module Brainiac
123
120
 
124
121
  # Mark the task as complete in our state
125
122
  if task
126
- task["status"] = "complete"
123
+ TaskState.transition!(task, :complete, triggered_by: "fizzy_card_completed")
127
124
  task["completed_at"] = Time.now.iso8601
128
125
  epic["updated_at"] = Time.now.iso8601
129
126
  log_event(epic, "task_completed", "Card ##{card_number} completed")
@@ -192,8 +189,6 @@ module Brainiac
192
189
  load_epics.find { |e| e["id"] == epic_id }
193
190
  end
194
191
 
195
- WEBHOOK_CHECK_COOLDOWN = 300 # 5 minutes
196
-
197
192
  private
198
193
 
199
194
  # Resolve current todolist state from Basecamp and dispatch unblocked cards.
@@ -221,7 +216,7 @@ module Brainiac
221
216
  updated_tasks = new_tasks.map do |task|
222
217
  existing = existing_by_card[task.fizzy_card]
223
218
  status = if task.completed
224
- "complete"
219
+ existing&.dig("status") || "complete"
225
220
  elsif existing&.dig("status")
226
221
  # Preserve existing status (in_flight, in_review, final_decision, etc.)
227
222
  existing["status"]
@@ -249,12 +244,18 @@ module Brainiac
249
244
  # Preserve PR and gate state from existing task
250
245
  if existing
251
246
  %w[dispatched_at pr_number pr_repo gates_dispatched_at gate_approvals
252
- changes_requested_by awaiting_final_decision changes_debounce_started
253
- fizzy_internal_id].each do |key|
247
+ changes_requested_by gate_redispatch_counts gate_states
248
+ awaiting_final_decision changes_debounce_started
249
+ fizzy_internal_id transitions].each do |key|
254
250
  new_task[key] = existing[key] if existing.key?(key)
255
251
  end
256
252
  end
257
253
 
254
+ TaskState.migrate!(new_task, triggered_by: "basecamp_sync")
255
+ if task.completed && !TaskState.in?(new_task, :complete)
256
+ TaskState.transition!(new_task, :complete, triggered_by: "basecamp_todo_completed")
257
+ end
258
+
258
259
  new_task
259
260
  end
260
261
 
@@ -268,28 +269,42 @@ module Brainiac
268
269
  epic["updated_at"] = Time.now.iso8601
269
270
  end
270
271
 
271
- # Dispatch unblocked tasks that aren't already in-flight or complete.
272
- # Ensures Fizzy webhooks are active before dispatching (prevents silent failures).
272
+ # Dispatch unblocked tasks that do not have a live implementation session.
273
273
  def dispatch_unblocked_tasks(epic)
274
- ensure_fizzy_webhooks_active
275
274
  tasks = epic["tasks"].map do |t|
275
+ # A stale in_flight state is diagnostic state, not liveness evidence.
276
+ # It becomes dispatchable again only after the implementation session has
277
+ # died; in_review/final_decision remain intentionally non-dispatchable.
278
+ # Give a newly assigned implementation agent time to register its
279
+ # session. Without this grace period, a recovery pass that just
280
+ # re-assigned a stale card can immediately assign it a second time
281
+ # before Fizzy's normal spawn hook has run.
282
+ dispatch_status = if TaskState.in?(t, :in_flight) &&
283
+ !SessionRegistry.implementation_alive?(t["fizzy_card"]) &&
284
+ Basecamp.send(:stale_dispatch?, t)
285
+ :pending
286
+ else
287
+ t["status"].to_sym
288
+ end
276
289
  Epic::Task.new(
277
290
  todo_id: t["todo_id"],
278
291
  fizzy_card: t["fizzy_card"],
279
292
  title: t["title"],
280
293
  depends_on: t["depends_on"] || [],
281
- status: t["status"].to_sym,
294
+ status: dispatch_status,
282
295
  completed: t["status"] == "complete",
283
296
  project: t["project"]
284
297
  )
285
298
  end
286
299
 
287
- # Find unblocked tasks that aren't already in-flight or complete
300
+ # The registry, rather than Fizzy assignment or a persisted status field,
301
+ # is the authority for whether an implementation dispatch is already live.
288
302
  unblocked = Epic.unblocked_tasks(tasks)
289
- in_flight_cards = epic["tasks"].select { |t| t["status"] == "in_flight" }.map { |t| t["fizzy_card"] }
290
303
  complete_cards = epic["tasks"].select { |t| t["status"] == "complete" }.map { |t| t["fizzy_card"] }
291
304
 
292
- ready_to_dispatch = unblocked.reject { |t| in_flight_cards.include?(t.fizzy_card) || complete_cards.include?(t.fizzy_card) }
305
+ ready_to_dispatch = unblocked.reject do |task|
306
+ complete_cards.include?(task.fizzy_card) || SessionRegistry.implementation_alive?(task.fizzy_card)
307
+ end
293
308
 
294
309
  ready_to_dispatch.each do |task|
295
310
  dispatch_card(epic, task)
@@ -307,6 +322,11 @@ module Brainiac
307
322
  def dispatch_card(epic, task)
308
323
  card_number = task.fizzy_card
309
324
 
325
+ if SessionRegistry.implementation_alive?(card_number)
326
+ LOG.info "[Basecamp:Orchestrator] Skipping Fizzy card ##{card_number} — implementation session is still live" if defined?(LOG)
327
+ return false
328
+ end
329
+
310
330
  # Resolve agent from project config — each project can have its own default agent
311
331
  project_key = task.is_a?(Hash) ? task["project"] : task.project
312
332
  agent = resolve_agent_for_project(project_key) || epic["agent"]
@@ -316,16 +336,14 @@ module Brainiac
316
336
  # Mark as in-flight in our state
317
337
  epic_task = epic["tasks"].find { |t| t["fizzy_card"] == card_number }
318
338
  if epic_task
319
- epic_task["status"] = "in_flight"
339
+ TaskState.transition!(epic_task, :dispatch, triggered_by: "orchestrator_dispatch")
320
340
  epic_task["dispatched_at"] = Time.now.iso8601
321
341
  end
322
342
  epic["updated_at"] = Time.now.iso8601
323
343
 
324
344
  log_event(epic, "dispatched", "Card ##{card_number} dispatched to #{agent}")
325
345
 
326
- # Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow.
327
- # If the agent is already assigned (stale state from a disabled webhook),
328
- # the method will cycle the assignment to re-trigger the webhook.
346
+ # Assign the card in Fizzy via CLI — this triggers the normal Fizzy webhook flow
329
347
  assign_fizzy_card(card_number, agent)
330
348
 
331
349
  # Post a comment on the Basecamp todo
@@ -337,6 +355,8 @@ module Brainiac
337
355
  profile: agent.downcase
338
356
  )
339
357
  end
358
+
359
+ true
340
360
  end
341
361
 
342
362
  # Sync PR state from work_items for cards that already have open PRs.
@@ -366,7 +386,7 @@ module Brainiac
366
386
 
367
387
  task["pr_number"] = pr_number
368
388
  task["pr_repo"] = work_item.dig("sources", "github", "repo")
369
- task["status"] = "in_review"
389
+ TaskState.transition!(task, :submit_for_review, triggered_by: "existing_pr_sync")
370
390
  end
371
391
  rescue StandardError => e
372
392
  LOG.warn "[Basecamp:Orchestrator] Error syncing PR state: #{e.message}" if defined?(LOG)
@@ -387,9 +407,6 @@ module Brainiac
387
407
  pr_number = task["pr_number"]
388
408
  project_key = task["project"]
389
409
 
390
- # Skip if gates already dispatched
391
- next if task["gates_dispatched_at"]
392
-
393
410
  # Get repo info
394
411
  projects_file = File.join(BRAINIAC_DIR, "projects.json")
395
412
  projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
@@ -405,7 +422,7 @@ module Brainiac
405
422
  # Check if all gates already passed
406
423
  if ReviewGate.all_gates_passed?(task)
407
424
  LOG.info "[Basecamp:Orchestrator] Gates already passed for card ##{card_number} — dispatching final decision" if defined?(LOG)
408
- task["status"] = "final_decision"
425
+ TaskState.transition!(task, :approve, triggered_by: "existing_pr_gate_sync", guard: ReviewGate.all_gates_passed?(task))
409
426
  task["awaiting_final_decision"] = true
410
427
  # Final decision dispatch happens in resume logic
411
428
  else
@@ -425,10 +442,7 @@ module Brainiac
425
442
  end
426
443
 
427
444
  # Assign a Fizzy card to an agent via Fizzy CLI.
428
- # If the agent is already assigned, unassigns and reassigns to re-trigger the webhook
429
- # (Fizzy webhooks can silently become disabled, causing the original assignment to be lost).
430
- #
431
- # @return [Symbol] :assigned, :reassigned, or :failed
445
+ # If the agent is already assigned, skip — the webhook should have already fired.
432
446
  def assign_fizzy_card(card_number, agent)
433
447
  agent_config = load_agent_registry[agent.downcase]
434
448
  fizzy_name = agent_config&.dig("fizzy_name") || agent
@@ -437,34 +451,18 @@ module Brainiac
437
451
  fizzy_user_id = resolve_fizzy_user_id(fizzy_name)
438
452
  unless fizzy_user_id
439
453
  LOG.error "[Basecamp:Orchestrator] Could not resolve Fizzy user ID for '#{fizzy_name}'" if defined?(LOG)
440
- return :failed
454
+ return
441
455
  end
442
456
 
443
- # Check if agent is already assigned
457
+ # Check if agent is already assigned — if so, skip (webhook should have fired)
444
458
  stdout, _, status = Open3.capture3("fizzy", "card", "show", card_number.to_s, "--json")
445
459
  if status.success?
446
460
  card_data = JSON.parse(stdout).dig("data") rescue nil
447
461
  if card_data
448
462
  current_assignees = (card_data["assignees"] || []).map { |a| a["id"] }
449
463
  if current_assignees.include?(fizzy_user_id)
450
- # Agent is already assigned — webhook may not have fired (disabled webhook).
451
- # Unassign then reassign to trigger a fresh webhook delivery.
452
- LOG.warn "[Basecamp:Orchestrator] Agent already assigned to ##{card_number} — " \
453
- "cycling assignment to re-trigger webhook" if defined?(LOG)
454
-
455
- # Fizzy assign TOGGLES, so calling it removes the assignment
456
- Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
457
- sleep 1 # Brief pause to ensure the unassign is processed
458
-
459
- # Now reassign — this should trigger the webhook
460
- stdout2, stderr2, status2 = Open3.capture3("fizzy", "card", "assign", card_number.to_s, "--user", fizzy_user_id)
461
- if status2.success?
462
- LOG.info "[Basecamp:Orchestrator] Reassigned Fizzy ##{card_number} to #{fizzy_name} (webhook re-triggered)" if defined?(LOG)
463
- return :reassigned
464
- else
465
- LOG.error "[Basecamp:Orchestrator] Failed to reassign Fizzy ##{card_number}: #{stderr2.strip}" if defined?(LOG)
466
- return :failed
467
- end
464
+ LOG.info "[Basecamp:Orchestrator] Agent already assigned to ##{card_number}, skipping (webhook should have fired)" if defined?(LOG)
465
+ return
468
466
  end
469
467
  end
470
468
  end
@@ -473,77 +471,11 @@ module Brainiac
473
471
 
474
472
  if status.success?
475
473
  LOG.info "[Basecamp:Orchestrator] Assigned Fizzy ##{card_number} to #{fizzy_name} (#{fizzy_user_id})" if defined?(LOG)
476
- :assigned
477
474
  else
478
475
  LOG.error "[Basecamp:Orchestrator] Failed to assign Fizzy ##{card_number}: #{stderr.strip}" if defined?(LOG)
479
- :failed
480
476
  end
481
477
  rescue Errno::ENOENT => e
482
478
  LOG.error "[Basecamp:Orchestrator] fizzy CLI not found: #{e.message}" if defined?(LOG)
483
- :failed
484
- end
485
-
486
- # Ensure Fizzy webhooks are active before dispatching cards.
487
- # Fizzy webhooks can silently become disabled (e.g., after repeated delivery failures),
488
- # causing assigned cards to never trigger agent work. This pre-flight check reactivates
489
- # any disabled brainiac webhooks.
490
- #
491
- # Uses a cooldown to avoid hammering the API on every dispatch batch.
492
- def ensure_fizzy_webhooks_active
493
- now = Time.now
494
- @last_webhook_check ||= Time.at(0)
495
- return if (now - @last_webhook_check) < WEBHOOK_CHECK_COOLDOWN
496
-
497
- @last_webhook_check = now
498
-
499
- # Read fizzy config to get admin_token and board list
500
- fizzy_config_file = File.join(BRAINIAC_DIR, "fizzy.json")
501
- return unless File.exist?(fizzy_config_file)
502
-
503
- fizzy_config = JSON.parse(File.read(fizzy_config_file))
504
- admin_token = fizzy_config["admin_token"]
505
- boards = fizzy_config["boards"] || {}
506
-
507
- unless admin_token
508
- LOG.debug "[Basecamp:Orchestrator] Fizzy webhook check skipped (no admin_token in fizzy.json)" if defined?(LOG)
509
- return
510
- end
511
-
512
- env = { "FIZZY_TOKEN" => admin_token }
513
- reactivated = 0
514
-
515
- boards.each do |board_key, board_config|
516
- board_id = board_config["board_id"]
517
- next unless board_id
518
-
519
- stdout, _, status = Open3.capture3(env, "fizzy", "webhook", "list",
520
- "--board", board_id, "--all", "--json",
521
- chdir: Dir.home)
522
- next unless status.success?
523
-
524
- webhooks = JSON.parse(stdout)["data"] || []
525
- webhooks.each do |webhook|
526
- next unless webhook["name"]&.start_with?("brainiac")
527
- next if webhook["active"]
528
-
529
- wh_name = webhook["name"]
530
- LOG.warn "[Basecamp:Orchestrator] Fizzy webhook '#{wh_name}' on board '#{board_key}' is INACTIVE — reactivating" if defined?(LOG)
531
- _, _, reactivate_status = Open3.capture3(env, "fizzy", "webhook", "reactivate", webhook["id"],
532
- "--board", board_id, chdir: Dir.home)
533
- if reactivate_status.success?
534
- reactivated += 1
535
- LOG.info "[Basecamp:Orchestrator] Reactivated webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
536
- else
537
- LOG.error "[Basecamp:Orchestrator] Failed to reactivate webhook '#{webhook['name']}' on board '#{board_key}'" if defined?(LOG)
538
- end
539
- end
540
- rescue StandardError => e
541
- LOG.warn "[Basecamp:Orchestrator] Webhook check failed for board '#{board_key}': #{e.message}" if defined?(LOG)
542
- end
543
-
544
- LOG.info "[Basecamp:Orchestrator] Fizzy webhook health check: #{reactivated} reactivated" if reactivated.positive?
545
- rescue StandardError => e
546
- LOG.warn "[Basecamp:Orchestrator] Fizzy webhook health check failed: #{e.message}" if defined?(LOG)
547
479
  end
548
480
 
549
481
  # Resolve a Fizzy user ID from their display name.
@@ -636,6 +568,9 @@ module Brainiac
636
568
 
637
569
  LOG.info "[Basecamp:Orchestrator] Dispatching epic review after card ##{completed_card_number}" if defined?(LOG)
638
570
 
571
+ # Ensure epic memory exists (in case this epic started before the feature)
572
+ EpicMemory.ensure_exists_for(epic)
573
+
639
574
  # Build the review prompt
640
575
  completed_tasks = epic["tasks"].select { |t| t["status"] == "complete" }
641
576
  completed_summary = completed_tasks.map { |t| "- ##{t['fizzy_card']}: #{t['title']}" }.join("\n")
@@ -645,18 +580,6 @@ module Brainiac
645
580
  "- ##{t['fizzy_card']}: #{t['title']}#{dep_str}"
646
581
  end.join("\n")
647
582
 
648
- # Include existing epic memory if any
649
- epic_memory_path = EpicMemory.path_for(epic["basecamp_todolist_id"])
650
- epic_memory_section = if EpicMemory.exists?(epic["basecamp_todolist_id"])
651
- <<~MEM
652
-
653
- ### Epic Memory (shared knowledge so far)
654
- Read the epic memory file at `#{epic_memory_path}` for decisions and patterns established in previous tasks.
655
- MEM
656
- else
657
- ""
658
- end
659
-
660
583
  prompt = <<~PROMPT
661
584
  ## Epic Review: #{epic['title']}
662
585
 
@@ -667,7 +590,7 @@ module Brainiac
667
590
 
668
591
  ### Remaining tasks (with current dependencies):
669
592
  #{remaining_summary}
670
- #{epic_memory_section}
593
+
671
594
  ### Your job:
672
595
  1. Read the memory files for completed tasks to understand what was implemented
673
596
  2. Check if remaining tasks still make sense given the implementation decisions
@@ -680,20 +603,10 @@ module Brainiac
680
603
 
681
604
  Memory files are at: `~/.brainiac/brain/memory/#{agent_name&.downcase}/card-<number>.md`
682
605
 
683
- ### Update Epic Memory (IMPORTANT)
684
- After your review, update the epic memory file at `#{epic_memory_path}` with:
685
- - **Architectural decisions** made during task ##{completed_card_number}
686
- - **Patterns established** that should be followed in remaining tasks
687
- - **Gotchas** discovered that future tasks should know about
688
- - **Cross-task notes** about relationships or dependencies
689
-
690
- This is shared knowledge — other agents will read it. Be concise but thorough.
691
- Append a new section, don't replace the existing content.
692
-
693
- Then post a brief summary comment on the Basecamp todolist:
606
+ After reviewing, post a brief summary comment on the Basecamp todolist:
694
607
  `basecamp comments create #{epic['basecamp_todolist_id']} "Epic review after ##{completed_card_number}: <your summary>" --in #{epic['basecamp_project_id']}`
695
608
 
696
- Keep the basecamp comment concise — this is a checkpoint, not a full analysis.
609
+ Keep it concise — this is a checkpoint, not a full analysis.
697
610
  PROMPT
698
611
 
699
612
  # Get project config for the agent
@@ -720,9 +633,15 @@ module Brainiac
720
633
  card_number: completed_card_number)
721
634
  end
722
635
 
723
- # Register session for waybar
724
- if pid && Object.respond_to?(:register_session, true)
725
- Object.send(:register_session, card_key, pid, log_file: log_file, agent_name: agent_name)
636
+ # Register session in SessionRegistry for liveness tracking
637
+ if pid
638
+ SessionRegistry.register_session(
639
+ card_key, pid,
640
+ log_file: log_file,
641
+ agent_name: agent_name,
642
+ epic_id: epic["id"],
643
+ card_number: completed_card_number
644
+ )
726
645
  end
727
646
 
728
647
  # Wait for the review to complete
@@ -747,15 +666,6 @@ module Brainiac
747
666
  profile: epic["agent"]&.downcase)
748
667
  end
749
668
 
750
- # Mark a Basecamp todo as incomplete (undo premature completion).
751
- def uncheck_todo(epic, card_number)
752
- task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
753
- return unless task && task["todo_id"]
754
-
755
- Client.run_safe("todos", "uncomplete", task["todo_id"].to_s, "--json",
756
- profile: epic["agent"]&.downcase)
757
- end
758
-
759
669
  # Post a completion comment on the Basecamp todo.
760
670
  def post_completion_comment(epic, card_number)
761
671
  task = epic["tasks"].find { |t| t["fizzy_card"] == card_number.to_i }
@@ -784,9 +694,6 @@ module Brainiac
784
694
 
785
695
  LOG.info "[Basecamp:Orchestrator] Epic '#{epic['title']}' completed!" if defined?(LOG)
786
696
 
787
- # Archive epic memory (preserves it for future reference)
788
- EpicMemory.cleanup(epic["basecamp_todolist_id"], archive: true)
789
-
790
697
  # If epic_branch mode, open final PRs to main
791
698
  if epic["review_gate"] == "epic_branch" && epic["epic_branches"]&.any?
792
699
  open_final_prs(epic)
@@ -892,43 +799,14 @@ module Brainiac
892
799
  end
893
800
 
894
801
  # Create epic branches for all projects involved in this epic.
895
- # CRITICAL: If this fails, epic_branches will be empty and agents will
896
- # open PRs against the default branch instead of the epic branch.
897
- # We log loudly and retry resolution of project repos if initial attempt fails.
898
802
  def create_epic_branches_for(epic)
899
803
  project_repos = resolve_project_repos(epic)
900
-
901
- if project_repos.empty?
902
- # Fallback: try to resolve from the basecamp project mapping directly
903
- mapped_project = Config.brainiac_project_for(epic["basecamp_project_id"])
904
- if mapped_project
905
- projects_file = File.join(BRAINIAC_DIR, "projects.json")
906
- all_projects = File.exist?(projects_file) ? JSON.parse(File.read(projects_file)) : {}
907
- repo = all_projects.dig(mapped_project, "repo_path")
908
- project_repos = { mapped_project => repo } if repo
909
- end
910
- end
911
-
912
- if project_repos.empty?
913
- LOG.error "[Basecamp:Orchestrator] Cannot create epic branches — no project repos resolved. " \
914
- "Tasks have projects: #{(epic['tasks'] || []).map { |t| t['project'] }.compact.uniq.inspect}. " \
915
- "Epic will proceed WITHOUT epic branches — PRs will target the default branch!" if defined?(LOG)
916
- log_event(epic, "branches_failed", "No project repos resolved — epic branches not created")
917
- return
918
- end
804
+ return if project_repos.empty?
919
805
 
920
806
  epic["epic_branches"] = EpicBranch.create_epic_branches(epic, project_repos)
921
-
922
- if epic["epic_branches"].empty?
923
- LOG.error "[Basecamp:Orchestrator] create_epic_branches returned empty — branch creation failed" if defined?(LOG)
924
- log_event(epic, "branches_failed", "Branch creation returned empty for repos: #{project_repos.keys.join(', ')}")
925
- else
926
- log_event(epic, "branches_created", "Epic branches: #{epic['epic_branches'].values.uniq.join(', ')}")
927
- end
807
+ log_event(epic, "branches_created", "Epic branches: #{epic['epic_branches'].values.uniq.join(', ')}")
928
808
  rescue StandardError => e
929
- LOG.error "[Basecamp:Orchestrator] Failed to create epic branches: #{e.class}: #{e.message}" \
930
- "\n#{e.backtrace.first(3).join("\n")}" if defined?(LOG)
931
- log_event(epic, "branches_failed", "Exception: #{e.message}")
809
+ LOG.error "[Basecamp:Orchestrator] Failed to create epic branches: #{e.message}" if defined?(LOG)
932
810
  end
933
811
 
934
812
  # Open final PRs from epic branches to main.