agentboard 0.2.0

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.
Files changed (159) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +62 -0
  3. data/MIT-LICENSE +20 -0
  4. data/README.md +316 -0
  5. data/Rakefile +6 -0
  6. data/app/assets/stylesheets/agentboard/application.css +510 -0
  7. data/app/controllers/agentboard/agents_controller.rb +10 -0
  8. data/app/controllers/agentboard/analytics_controller.rb +37 -0
  9. data/app/controllers/agentboard/application_controller.rb +90 -0
  10. data/app/controllers/agentboard/approvals_controller.rb +15 -0
  11. data/app/controllers/agentboard/assets_controller.rb +32 -0
  12. data/app/controllers/agentboard/board_controller.rb +46 -0
  13. data/app/controllers/agentboard/captures_controller.rb +17 -0
  14. data/app/controllers/agentboard/comments_controller.rb +9 -0
  15. data/app/controllers/agentboard/criteria_controller.rb +17 -0
  16. data/app/controllers/agentboard/dependencies_controller.rb +20 -0
  17. data/app/controllers/agentboard/executions_controller.rb +48 -0
  18. data/app/controllers/agentboard/milestones_controller.rb +23 -0
  19. data/app/controllers/agentboard/pulse_controller.rb +12 -0
  20. data/app/controllers/agentboard/sessions_controller.rb +19 -0
  21. data/app/controllers/agentboard/settings_controller.rb +9 -0
  22. data/app/controllers/agentboard/tickets_controller.rb +139 -0
  23. data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
  24. data/app/controllers/agentboard/workflows_controller.rb +62 -0
  25. data/app/helpers/agentboard/application_helper.rb +196 -0
  26. data/app/javascript/agentboard/application.js +227 -0
  27. data/app/javascript/agentboard/capture.js +299 -0
  28. data/app/jobs/agentboard/application_job.rb +4 -0
  29. data/app/models/agentboard/acceptance_criterion.rb +23 -0
  30. data/app/models/agentboard/agent_profile.rb +16 -0
  31. data/app/models/agentboard/agent_session.rb +30 -0
  32. data/app/models/agentboard/application_record.rb +13 -0
  33. data/app/models/agentboard/approval.rb +29 -0
  34. data/app/models/agentboard/artifact.rb +11 -0
  35. data/app/models/agentboard/comment.rb +9 -0
  36. data/app/models/agentboard/event.rb +37 -0
  37. data/app/models/agentboard/execution.rb +51 -0
  38. data/app/models/agentboard/idempotency_key.rb +5 -0
  39. data/app/models/agentboard/milestone.rb +7 -0
  40. data/app/models/agentboard/outbox_message.rb +11 -0
  41. data/app/models/agentboard/project.rb +43 -0
  42. data/app/models/agentboard/repository_snapshot.rb +5 -0
  43. data/app/models/agentboard/session_ticket_link.rb +18 -0
  44. data/app/models/agentboard/ticket.rb +81 -0
  45. data/app/models/agentboard/ticket_dependency.rb +34 -0
  46. data/app/models/agentboard/ticket_lease.rb +11 -0
  47. data/app/models/agentboard/usage_record.rb +10 -0
  48. data/app/models/agentboard/verification_result.rb +12 -0
  49. data/app/models/agentboard/webhook_delivery.rb +7 -0
  50. data/app/models/agentboard/workflow_definition.rb +20 -0
  51. data/app/models/agentboard/workflow_run.rb +21 -0
  52. data/app/models/agentboard/workflow_step.rb +12 -0
  53. data/app/services/agentboard/approvals.rb +76 -0
  54. data/app/services/agentboard/captures.rb +113 -0
  55. data/app/services/agentboard/completion_policy.rb +117 -0
  56. data/app/services/agentboard/current.rb +5 -0
  57. data/app/services/agentboard/data_governance.rb +68 -0
  58. data/app/services/agentboard/doctor.rb +133 -0
  59. data/app/services/agentboard/event_store.rb +88 -0
  60. data/app/services/agentboard/evidence.rb +52 -0
  61. data/app/services/agentboard/executions.rb +198 -0
  62. data/app/services/agentboard/hook_ingestion.rb +100 -0
  63. data/app/services/agentboard/idempotency.rb +28 -0
  64. data/app/services/agentboard/lease_service.rb +107 -0
  65. data/app/services/agentboard/mcp_server.rb +138 -0
  66. data/app/services/agentboard/mcp_tools.rb +341 -0
  67. data/app/services/agentboard/outbox.rb +44 -0
  68. data/app/services/agentboard/pull_requests.rb +65 -0
  69. data/app/services/agentboard/runtime/execution_runner.rb +352 -0
  70. data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
  71. data/app/services/agentboard/runtime/reconciler.rb +42 -0
  72. data/app/services/agentboard/runtime/supervisor.rb +96 -0
  73. data/app/services/agentboard/session_association.rb +44 -0
  74. data/app/services/agentboard/session_correlation.rb +56 -0
  75. data/app/services/agentboard/session_credential.rb +44 -0
  76. data/app/services/agentboard/ticket_advisor.rb +60 -0
  77. data/app/services/agentboard/tickets.rb +218 -0
  78. data/app/services/agentboard/verification_service.rb +60 -0
  79. data/app/services/agentboard/workflow_engine.rb +261 -0
  80. data/app/views/agentboard/agents/index.html.erb +51 -0
  81. data/app/views/agentboard/analytics/show.html.erb +78 -0
  82. data/app/views/agentboard/approvals/_approval.html.erb +27 -0
  83. data/app/views/agentboard/approvals/index.html.erb +31 -0
  84. data/app/views/agentboard/board/_card.html.erb +36 -0
  85. data/app/views/agentboard/board/show.html.erb +73 -0
  86. data/app/views/agentboard/executions/index.html.erb +33 -0
  87. data/app/views/agentboard/executions/show.html.erb +126 -0
  88. data/app/views/agentboard/milestones/index.html.erb +28 -0
  89. data/app/views/agentboard/settings/show.html.erb +33 -0
  90. data/app/views/agentboard/shared/_dag.html.erb +24 -0
  91. data/app/views/agentboard/tickets/_form.html.erb +38 -0
  92. data/app/views/agentboard/tickets/edit.html.erb +5 -0
  93. data/app/views/agentboard/tickets/index.html.erb +51 -0
  94. data/app/views/agentboard/tickets/new.html.erb +6 -0
  95. data/app/views/agentboard/tickets/show.html.erb +343 -0
  96. data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
  97. data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
  98. data/app/views/agentboard/workflows/index.html.erb +49 -0
  99. data/app/views/agentboard/workflows/show.html.erb +15 -0
  100. data/app/views/layouts/agentboard/application.html.erb +93 -0
  101. data/config/routes.rb +49 -0
  102. data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
  103. data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
  104. data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
  105. data/exe/agentboard +7 -0
  106. data/lib/agentboard/agent_container.rb +124 -0
  107. data/lib/agentboard/authorization.rb +58 -0
  108. data/lib/agentboard/capture/middleware.rb +54 -0
  109. data/lib/agentboard/capture.rb +57 -0
  110. data/lib/agentboard/cli.rb +818 -0
  111. data/lib/agentboard/command_policy.rb +44 -0
  112. data/lib/agentboard/configuration.rb +160 -0
  113. data/lib/agentboard/container_sandbox.rb +65 -0
  114. data/lib/agentboard/context_builder.rb +243 -0
  115. data/lib/agentboard/egress_proxy.rb +95 -0
  116. data/lib/agentboard/engine.rb +27 -0
  117. data/lib/agentboard/errors.rb +42 -0
  118. data/lib/agentboard/event_schema.rb +37 -0
  119. data/lib/agentboard/extensions.rb +58 -0
  120. data/lib/agentboard/git/repository.rb +92 -0
  121. data/lib/agentboard/git/worktree_manager.rb +107 -0
  122. data/lib/agentboard/git.rb +4 -0
  123. data/lib/agentboard/ids.rb +33 -0
  124. data/lib/agentboard/json_log_formatter.rb +28 -0
  125. data/lib/agentboard/path_guard.rb +27 -0
  126. data/lib/agentboard/process_ancestry.rb +40 -0
  127. data/lib/agentboard/providers/base.rb +107 -0
  128. data/lib/agentboard/providers/claude_code.rb +219 -0
  129. data/lib/agentboard/providers/codex.rb +177 -0
  130. data/lib/agentboard/providers/compatibility/claude.yml +13 -0
  131. data/lib/agentboard/providers/compatibility/codex.yml +10 -0
  132. data/lib/agentboard/providers/config_editor.rb +106 -0
  133. data/lib/agentboard/providers/cursor.rb +40 -0
  134. data/lib/agentboard/providers/fake.rb +59 -0
  135. data/lib/agentboard/providers/fake_agent_script.rb +47 -0
  136. data/lib/agentboard/providers/generic_cli.rb +35 -0
  137. data/lib/agentboard/providers/registry.rb +31 -0
  138. data/lib/agentboard/providers/types.rb +32 -0
  139. data/lib/agentboard/providers.rb +26 -0
  140. data/lib/agentboard/rails_introspection.rb +49 -0
  141. data/lib/agentboard/redactor.rb +62 -0
  142. data/lib/agentboard/result.rb +46 -0
  143. data/lib/agentboard/runtime/ipc.rb +193 -0
  144. data/lib/agentboard/runtime.rb +1 -0
  145. data/lib/agentboard/state_machines.rb +71 -0
  146. data/lib/agentboard/subprocess.rb +168 -0
  147. data/lib/agentboard/templates/Dockerfile.agent +21 -0
  148. data/lib/agentboard/verification.rb +79 -0
  149. data/lib/agentboard/version.rb +3 -0
  150. data/lib/agentboard/workflows/condition.rb +166 -0
  151. data/lib/agentboard/workflows/definition.rb +208 -0
  152. data/lib/agentboard/workflows.rb +2 -0
  153. data/lib/agentboard.rb +73 -0
  154. data/lib/generators/agentboard/install/install_generator.rb +67 -0
  155. data/lib/generators/agentboard/install/templates/config.yml +81 -0
  156. data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
  157. data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
  158. data/lib/tasks/agentboard_tasks.rake +6 -0
  159. metadata +285 -0
@@ -0,0 +1,65 @@
1
+ module Agentboard
2
+ # Pull request evidence shared by the core (manual links) and Pro (GitHub webhooks).
3
+ module PullRequests
4
+ module_function
5
+
6
+ def upsert!(ticket, pr:, state:, head_ref:, head_sha:, match:, merge_verified:, source:)
7
+ url = pr["html_url"].to_s
8
+ artifact = ticket.artifacts.where(kind: "pull_request").find { |a| a.uri == url } ||
9
+ ticket.artifacts.new(kind: "pull_request", uri: url)
10
+ artifact.assign_attributes(
11
+ title: "PR ##{pr["number"]}: #{pr["title"].to_s[0, 200]}", commit_sha: head_sha.presence,
12
+ provenance: match == "branch_name" ? "inferred" : "git_observed",
13
+ metadata: artifact.metadata.to_h.merge(
14
+ "number" => pr["number"], "state" => state, "head_ref" => head_ref, "head_sha" => head_sha,
15
+ "base_ref" => pr.dig("base", "ref"), "merge_commit_sha" => pr["merge_commit_sha"], "merged_at" => pr["merged_at"],
16
+ "merge_verified" => merge_verified, "match" => match, "source" => source
17
+ )
18
+ )
19
+ artifact.save!
20
+ EventStore.append!(type: "git.pull_request_#{state}", aggregate: ticket, ticket: ticket, source: "#{source}.github",
21
+ provenance: artifact.provenance,
22
+ payload: { number: pr["number"], url: url, head_sha: head_sha, match: match, merge_commit_sha: pr["merge_commit_sha"] })
23
+ artifact
24
+ end
25
+
26
+ def attempt_completion!(ticket)
27
+ return unless ticket.reload.status == "review"
28
+
29
+ report = CompletionPolicy.evaluate(ticket)
30
+ if report[:ok]
31
+ Tickets.transition!(ticket: ticket, to: "done", actor: Authorization.system_actor)
32
+ else
33
+ EventStore.append!(type: "ticket.completion_blocked", aggregate: ticket, ticket: ticket, source: "pull_request",
34
+ payload: { missing: report[:missing] })
35
+ end
36
+ end
37
+
38
+ # Manual PR link (core feature, works without GitHub). A merge counts as verified only if
39
+ # the merge commit exists locally and contains the ticket's reviewed head.
40
+ def link_manually!(ticket:, actor:, url:, state:, merge_commit_sha: nil)
41
+ Authorization.authorize!(actor, "ticket.write")
42
+ raise Error.validation_failed("PR URL must be http(s)") unless url.to_s.match?(%r{\Ahttps?://\S+\z})
43
+ raise Error.validation_failed("State must be open, closed or merged") unless %w[open closed merged].include?(state)
44
+
45
+ head = Evidence.head_sha(ticket)
46
+ verified = false
47
+ if state == "merged"
48
+ raise Error.forbidden("Only maintainers can record a merge") unless Authorization.allowed?(actor, "ticket.complete")
49
+
50
+ repo = ticket.project.repository
51
+ full = repo.resolve_commit(merge_commit_sha.to_s)
52
+ raise Error.validation_failed("Merge commit #{merge_commit_sha} is not in the local repository (git fetch first)") unless full
53
+ raise Error.policy_denied("Merge commit does not contain the reviewed head #{head.to_s[0, 10]}") unless head && repo.ancestor?(head, full)
54
+
55
+ merge_commit_sha = full
56
+ verified = true
57
+ end
58
+ pr = { "html_url" => url, "number" => url[%r{/pull/(\d+)}, 1]&.to_i, "title" => "external", "merge_commit_sha" => merge_commit_sha }
59
+ artifact = upsert!(ticket, pr: pr, state: state, head_ref: nil, head_sha: head.to_s, match: "manual", merge_verified: verified, source: "manual")
60
+ artifact.update!(provenance: "confirmed")
61
+ attempt_completion!(ticket) if verified
62
+ artifact
63
+ end
64
+ end
65
+ end
@@ -0,0 +1,352 @@
1
+ module Agentboard
2
+ module Runtime
3
+ # Drives one execution attempt through the pipeline of spec §8:
4
+ # validate → worktree → context → launch → stream → snapshot → verify → review handoff.
5
+ # Runs inside the daemon process (never inside a web worker).
6
+ class ExecutionRunner
7
+ CANCEL_POLL_SECONDS = 0.5
8
+ HEARTBEAT_SECONDS = 5
9
+
10
+ attr_reader :execution
11
+
12
+ def initialize(execution, config: Agentboard.config)
13
+ @execution = execution
14
+ @config = config
15
+ @last_cancel_poll = 0
16
+ @last_heartbeat = 0
17
+ @cancelled = false
18
+ end
19
+
20
+ def call
21
+ prepare!
22
+ return finish_cancelled! if cancel_requested?
23
+
24
+ outcome = launch_and_stream!
25
+ return finish_cancelled! if outcome == :cancelled
26
+ return fail!("execution_timeout", "Execution exceeded #{timeout}s") if outcome == :timeout
27
+
28
+ head = snapshot!
29
+ provider_ok = outcome == :succeeded
30
+ unless provider_ok
31
+ return fail!("provider_failed", "Provider exited with code #{execution.exit_code.inspect}")
32
+ end
33
+
34
+ verify!(head)
35
+ rescue Agentboard::Error => e
36
+ fail!(e.code.downcase, e.message)
37
+ rescue StandardError => e
38
+ Agentboard.logger.error("[agentboard] execution #{execution.id} crashed: #{e.class}: #{Redactor.redact(e.message)}")
39
+ Agentboard.logger.error(e.backtrace.first(8).join("\n")) if e.backtrace
40
+ fail!("internal_error", "#{e.class}: #{Redactor.redact(e.message)[0, 300]}")
41
+ ensure
42
+ @log&.close
43
+ end
44
+
45
+ private
46
+
47
+ def ticket = execution.ticket
48
+ def project = ticket.project
49
+ def provider = @provider ||= Providers.registry.fetch(execution.provider_key)
50
+ # The tighter of the runtime limit and a per-step workflow limit.
51
+ def timeout = [ (@config.runtime["execution_timeout_seconds"] || 3600).to_i, execution.metadata["timeout_seconds"]&.to_i ].compact.min
52
+
53
+ def prepare!
54
+ Executions.transition!(execution, "preparing")
55
+ repo = project.repository
56
+ manager = Git::WorktreeManager.new(repository: repo, workspace: @config.workspace_path)
57
+ resumed = resume_source
58
+ worktree = if resumed
59
+ # Resume continues in the previous attempt's worktree (it is terminal, so not shared).
60
+ Git::WorktreeManager::Worktree.new(path: resumed.worktree_path, branch: resumed.branch, base_sha: resumed.base_sha)
61
+ elsif execution.worktree_path.present? && File.directory?(execution.worktree_path)
62
+ Git::WorktreeManager::Worktree.new(path: execution.worktree_path, branch: execution.branch, base_sha: execution.base_sha)
63
+ else
64
+ manager.create!(ticket_key: ticket.key, attempt: execution.attempt, base_ref: base_ref)
65
+ end
66
+ @manager = manager
67
+ log_dir = @config.root_path.join(".agentboard", "logs", execution.id)
68
+ FileUtils.mkdir_p(log_dir, mode: 0o700)
69
+ context_dir = @config.root_path.join(".agentboard", "cache", "contexts")
70
+ FileUtils.mkdir_p(context_dir, mode: 0o700)
71
+
72
+ package = ContextBuilder.call(ticket: ticket, repository: repo, chdir: worktree.path, action: execution.action,
73
+ handoff: execution.metadata["handoff"],
74
+ max_tokens: (execution.agent_profile&.config || {}).fetch("context_tokens", 12_000))
75
+ record_snapshot(repo, worktree)
76
+ context_path = context_dir.join("#{execution.id}.md")
77
+ File.write(context_path, package.text)
78
+ Artifact.create!(ticket: ticket, execution: execution, kind: "context", uri: "file:#{context_path}", sha256: package.digest,
79
+ title: "Context package (#{package.tokens_estimate} tokens#{package.truncated ? ", truncated" : ""})",
80
+ provenance: "confirmed", metadata: package.to_h.transform_keys(&:to_s))
81
+ execution.update_columns(worktree_path: worktree.path, branch: worktree.branch, base_sha: worktree.base_sha,
82
+ context_path: context_path.to_s, context_digest: package.digest, log_path: log_dir.join("agent.log").to_s)
83
+ EventStore.append!(type: "git.worktree_created", aggregate: execution, ticket: ticket, execution: execution,
84
+ payload: { path: worktree.path, branch: worktree.branch, base_sha: worktree.base_sha })
85
+
86
+ session = execution.agent_session
87
+ SessionAssociation.link!(session: session, ticket: ticket, source: "runtime", actor: Authorization.system_actor) if session
88
+ if ticket.reload.status == "claimed"
89
+ Tickets.transition!(ticket: ticket, to: "in_progress", actor: Authorization.system_actor)
90
+ end
91
+ @prompt = build_prompt(package)
92
+ end
93
+
94
+ def base_ref
95
+ execution.metadata["base_ref"].presence || "HEAD"
96
+ end
97
+
98
+ def record_snapshot(repo, worktree)
99
+ return if RepositorySnapshot.exists?(project_id: project.id, commit_sha: worktree.base_sha)
100
+
101
+ builder = ContextBuilder.new(ticket: ticket, repository: repo, max_tokens: 1_000, chdir: worktree.path, action: "snapshot", handoff: nil)
102
+ RepositorySnapshot.create!(project: project, commit_sha: worktree.base_sha, metadata: builder.snapshot_metadata)
103
+ rescue ActiveRecord::RecordNotUnique
104
+ nil
105
+ end
106
+
107
+ def resume_source
108
+ id = execution.metadata["resume_of"]
109
+ return nil unless id
110
+
111
+ prev = Execution.find(id)
112
+ raise Error.invalid_transition("Cannot resume: previous attempt is still active") unless prev.terminal?
113
+ raise Error.workspace_conflict("Cannot resume: worktree of the previous attempt is gone") unless prev.worktree_path && File.directory?(prev.worktree_path)
114
+ if Execution.active.where(worktree_path: prev.worktree_path).where.not(id: execution.id).exists?
115
+ raise Error.workspace_conflict("Worktree is in use by another execution")
116
+ end
117
+
118
+ prev
119
+ end
120
+
121
+ def build_prompt(package)
122
+ action_text = {
123
+ "plan" => "Produce an implementation plan for the ticket. Do not modify files.",
124
+ "implement" => "Implement the ticket in this working directory and commit your changes with a descriptive message.",
125
+ "review" => "Review the changes between the base commit and HEAD for correctness, security and test coverage. Report findings; do not modify files.",
126
+ "fix_tests" => "Make the failing verification commands pass. Commit your changes.",
127
+ "summarize" => "Summarize the current state of the work for a human reviewer."
128
+ }.fetch(execution.action, "Work on the ticket.")
129
+ <<~PROMPT
130
+ You are working on ticket #{ticket.key} managed by AgentBoard.
131
+ #{action_text}
132
+ AgentBoard runs verification independently; your own claims of success are not evidence.
133
+
134
+ #{package.text}
135
+ PROMPT
136
+ end
137
+
138
+ def launch_and_stream!
139
+ spec = if execution.metadata["resume_of"]
140
+ provider.resume_spec(execution: execution, worktree: execution.worktree_path,
141
+ input: execution.metadata["resume_message"].presence || "Continue the task.")
142
+ else
143
+ provider.launch_spec(execution: execution, worktree: execution.worktree_path, context_path: execution.context_path, prompt: @prompt)
144
+ end
145
+ @log = File.open(execution.log_path, "ab", 0o600)
146
+ max_bytes = @config.runtime["max_log_bytes"].to_i
147
+ argv = spec.argv
148
+ cli_env = {}
149
+ if execution.metadata["isolation"] == "container"
150
+ cli_env = AgentContainer.cli_env(spec)
151
+ AgentContainer.ensure_network!
152
+ @container = "agentboard-exec-#{execution.id.delete("-")[-16..]}"
153
+ argv = AgentContainer.wrap(spec, execution: execution, name: @container)
154
+ execution.update_columns(metadata: execution.metadata.merge("container" => @container, "image" => AgentContainer.image))
155
+ EventStore.append!(type: "agent.execution.container_started", aggregate: execution, ticket: ticket, execution: execution,
156
+ payload: { container: @container, image: AgentContainer.image, network: AgentContainer.network_name,
157
+ egress: AgentContainer.egress_allowlist })
158
+ end
159
+ handle = Subprocess.spawn(argv, chdir: spec.chdir, env: spec.env.to_h.merge(cli_env).merge("AGENTBOARD_EXECUTION_ID" => execution.id),
160
+ passthrough_env: (spec.passthrough_env || []) + %w[DOCKER_HOST DOCKER_CONTEXT DOCKER_CONFIG DOCKER_CERT_PATH DOCKER_TLS_VERIFY],
161
+ stdin_data: spec.stdin_data)
162
+ execution.update_columns(pid: handle[:pid], pgid: handle[:pgid], process_fingerprint: Subprocess.fingerprint(handle[:pid]), heartbeat_at: Time.current)
163
+ Executions.transition!(execution, "running")
164
+
165
+ started = Subprocess.monotonic
166
+ terminal_status = nil
167
+ buffer = +""
168
+ reader = handle[:reader]
169
+ result = nil
170
+ loop do
171
+ if Subprocess.monotonic - started > timeout
172
+ Subprocess.terminate(handle[:pgid], grace: grace)
173
+ AgentContainer.remove(@container)
174
+ result = :timeout
175
+ break
176
+ end
177
+ if cancel_requested?
178
+ Subprocess.terminate(handle[:pgid], grace: grace)
179
+ AgentContainer.remove(@container) # killing the docker CLI does not stop the container
180
+ result = :cancelled
181
+ break
182
+ end
183
+ heartbeat!
184
+ next unless IO.select([ reader ], nil, nil, 0.2)
185
+
186
+ chunk = begin
187
+ reader.read_nonblock(65_536)
188
+ rescue IO::WaitReadable
189
+ next
190
+ rescue EOFError
191
+ break
192
+ end
193
+ buffer << chunk.force_encoding("UTF-8").scrub
194
+ while (idx = buffer.index("\n"))
195
+ line = buffer.slice!(0..idx)
196
+ status = handle_line(line, max_bytes)
197
+ terminal_status = status if status
198
+ end
199
+ end
200
+ handle_line(buffer, max_bytes) if buffer.present? && result.nil?
201
+ @last_heartbeat = 0
202
+ heartbeat! # final resource sample
203
+ reader.close unless reader.closed?
204
+ _, status = ::Process.wait2(handle[:pid])
205
+ execution.update_columns(exit_code: status.exitstatus)
206
+ return result if result
207
+
208
+ terminal_status == "failed" || !status.success? ? :failed : :succeeded
209
+ end
210
+
211
+ def handle_line(line, max_bytes)
212
+ redacted, count = Redactor.redact_with_count(line)
213
+ if execution.log_bytes < max_bytes
214
+ @log.write(redacted)
215
+ @log.flush
216
+ execution.log_bytes += redacted.bytesize
217
+ elsif !execution.log_truncated
218
+ @log.write("\n[agentboard: log truncated at #{max_bytes} bytes]\n")
219
+ execution.log_truncated = true
220
+ end
221
+ if count.positive?
222
+ EventStore.append!(type: "security.secret_redacted", aggregate: execution, ticket: ticket, execution: execution,
223
+ payload: { where: "provider_output", redactions: count })
224
+ end
225
+ status = nil
226
+ provider.parse_event(raw_event: redacted).each do |ev|
227
+ Executions.record_provider_event!(execution, ev)
228
+ status = ev.status if ev.terminal
229
+ if ev.status == "waiting_for_permission"
230
+ # Print-mode providers do not block on permission; the request is recorded and
231
+ # surfaced. Interactive permission bridging requires a provider protocol.
232
+ execution.update_columns(metadata: execution.metadata.merge("permission_requests" => execution.metadata["permission_requests"].to_i + 1))
233
+ end
234
+ end
235
+ status
236
+ ensure
237
+ execution.update_columns(log_bytes: execution.log_bytes, log_truncated: execution.log_truncated)
238
+ end
239
+
240
+ def heartbeat!
241
+ now = Subprocess.monotonic
242
+ return if now - @last_heartbeat < HEARTBEAT_SECONDS
243
+
244
+ @last_heartbeat = now
245
+ attrs = { heartbeat_at: Time.current }
246
+ if execution.pgid && (usage = Subprocess.group_usage(execution.pgid))
247
+ @peak_rss = [ @peak_rss.to_i, usage[:rss_kb] ].max
248
+ @cpu = [ @cpu.to_f, usage[:cpu_seconds] ].max
249
+ attrs[:metadata] = execution.metadata.merge("resources" => { "peak_rss_kb" => @peak_rss, "cpu_seconds" => @cpu.round(2),
250
+ "processes" => usage[:processes] })
251
+ execution.metadata = attrs[:metadata]
252
+ end
253
+ execution.update_columns(attrs)
254
+ end
255
+
256
+ def cancel_requested?
257
+ return true if @cancelled
258
+
259
+ now = Subprocess.monotonic
260
+ return false if now - @last_cancel_poll < CANCEL_POLL_SECONDS
261
+
262
+ @last_cancel_poll = now
263
+ @cancelled = Execution.where(id: execution.id).where.not(cancel_requested_at: nil).exists?
264
+ end
265
+
266
+ def grace = @config.runtime["cancel_grace_seconds"].to_i
267
+
268
+ # Bind whatever the agent produced to an immutable commit (evidence), even on failure.
269
+ def snapshot!
270
+ return nil unless execution.worktree_path && File.directory?(execution.worktree_path)
271
+
272
+ head = @manager.snapshot!(execution.worktree_path, message: "#{ticket.key}: AgentBoard snapshot (#{execution.provider_key} exec-#{execution.attempt})")
273
+ changes = @manager.changes(execution.worktree_path, execution.base_sha)
274
+ execution.update_columns(head_sha: head)
275
+ if changes[:files].any?
276
+ Artifact.create!(ticket: ticket, execution: execution, kind: "commit", uri: "git:#{head}", commit_sha: head,
277
+ title: "#{changes[:stat][:files]} files (+#{changes[:stat][:insertions]}/−#{changes[:stat][:deletions]})",
278
+ provenance: "git_observed",
279
+ metadata: { "verified_in_repo" => true, "branch" => execution.branch, "files" => changes[:files].first(200), "stat" => changes[:stat] })
280
+ Approvals.invalidate_stale!(ticket: ticket, head_sha: head)
281
+ end
282
+ EventStore.append!(type: "git.snapshot", aggregate: execution, ticket: ticket, execution: execution, provenance: "git_observed",
283
+ payload: { head_sha: head, base_sha: execution.base_sha, files: changes[:files].first(50), stat: changes[:stat] })
284
+ @changed = changes[:files].any?
285
+ head
286
+ end
287
+
288
+ def verify!(head)
289
+ if %w[plan review summarize].include?(execution.action) || !@changed
290
+ Executions.transition!(execution, "verifying")
291
+ Executions.transition!(execution, "completed", summary: last_summary)
292
+ return execution
293
+ end
294
+
295
+ Executions.transition!(execution, "verifying")
296
+ if ticket.reload.status == "in_progress"
297
+ Tickets.transition!(ticket: ticket, to: "testing", actor: Authorization.system_actor)
298
+ end
299
+ results = VerificationService.run_catalog!(execution: execution, chdir: execution.worktree_path, cancel_check: -> { cancel_requested? })
300
+ return finish_cancelled! if cancel_requested?
301
+
302
+ required_ok = results.select(&:required).all?(&:passed?)
303
+ if required_ok
304
+ Executions.transition!(execution, "completed", summary: last_summary)
305
+ if ticket.reload.status == "testing"
306
+ Tickets.transition!(ticket: ticket, to: "review", actor: Authorization.system_actor, head_sha: head)
307
+ Approvals.request_review!(ticket: ticket, actor: Authorization.system_actor, head_sha: head, summary: last_summary)
308
+ end
309
+ else
310
+ failed = results.reject(&:passed?).map(&:name)
311
+ Executions.transition!(execution, "failed", failure_code: "verification_failed", failure_message: "Checks failed: #{failed.join(", ")}",
312
+ summary: last_summary)
313
+ Tickets.transition!(ticket: ticket, to: "in_progress", actor: Authorization.system_actor) if ticket.reload.status == "testing"
314
+ end
315
+ release_lease!
316
+ execution
317
+ end
318
+
319
+ def last_summary
320
+ Event.where(execution_id: execution.id, event_type: %w[agent.execution.result agent.execution.message])
321
+ .order(received_at: :desc).limit(1).pick(:payload)&.then { |p| p["summary"].presence || p["text"] }.to_s[0, 4000]
322
+ end
323
+
324
+ def release_lease!
325
+ session = execution.agent_session
326
+ ticket.leases.live.where(agent_session_id: session&.id).update_all(released_at: Time.current, release_reason: "execution_finished", updated_at: Time.current)
327
+ end
328
+
329
+ def finish_cancelled!
330
+ execution.reload
331
+ return execution if execution.terminal?
332
+
333
+ Executions.transition!(execution, "cancelled", cancel_reason: execution.cancel_reason || "cancelled")
334
+ release_lease!
335
+ execution
336
+ end
337
+
338
+ def fail!(code, message)
339
+ execution.reload
340
+ return execution if execution.terminal?
341
+
342
+ to = execution.status == "queued" ? "failed" : "failed"
343
+ Executions.transition!(execution, to, failure_code: code.to_s[0, 64], failure_message: message.to_s[0, 2000])
344
+ release_lease!
345
+ execution
346
+ rescue StandardError => e
347
+ Agentboard.logger.error("[agentboard] could not mark #{execution.id} failed: #{e.class}")
348
+ execution
349
+ end
350
+ end
351
+ end
352
+ end
@@ -0,0 +1,55 @@
1
+ module Agentboard
2
+ module Runtime
3
+ # Maps IPC operations (spec §27.2) to domain services. Runs inside the daemon; every
4
+ # request has already been authenticated by IPC::Server.
5
+ class IpcHandler
6
+ def initialize(supervisor)
7
+ @supervisor = supervisor
8
+ end
9
+
10
+ def call(op, params)
11
+ ActiveRecord::Base.connection_pool.with_connection { dispatch(op, params) }
12
+ end
13
+
14
+ private
15
+
16
+ def actor = Authorization.local_human
17
+
18
+ def dispatch(op, params)
19
+ case op
20
+ when "runtime.health" then @supervisor.health
21
+ when "runtime.reconcile" then Reconciler.new.call
22
+ when "execution.enqueue"
23
+ ticket = find_ticket(params)
24
+ execution = Executions.enqueue!(ticket: ticket, provider_key: params.fetch("provider", "fake"), actor: actor,
25
+ action: params.fetch("action", "implement"), idempotency_key: params["idempotency_key"],
26
+ metadata: params.slice("scenario"))
27
+ { execution_id: execution.id, status: execution.status, attempt: execution.attempt }
28
+ when "execution.status"
29
+ e = Execution.find(params.fetch("execution_id"))
30
+ { execution_id: e.id, state: e.status, attempt: e.attempt, started_at: e.started_at, finished_at: e.finished_at,
31
+ exit_code: e.exit_code, failure_code: e.failure_code, head_sha: e.head_sha }
32
+ when "execution.cancel"
33
+ e = Execution.find(params.fetch("execution_id"))
34
+ Idempotency.call(scope: "execution.cancel", key: params["idempotency_key"], request: params.slice("execution_id", "reason")) do
35
+ e = Executions.request_cancel!(execution: e, actor: actor, reason: params["reason"] || "cancelled via runtime")
36
+ { "accepted" => true, "current_state" => e.status }
37
+ end
38
+ when "execution.input"
39
+ raise Error.capability_unavailable("Interactive input bridging is not supported by the configured providers in print mode")
40
+ when "execution.events"
41
+ e = Execution.find(params.fetch("execution_id"))
42
+ cursor = params["cursor"].to_i
43
+ events = Event.where(aggregate_type: "execution", aggregate_id: e.id).where("sequence > ?", cursor).order(:sequence).limit(500)
44
+ { events: events.map(&:envelope), next_cursor: events.last&.sequence || cursor, terminal: e.reload.terminal? }
45
+ else
46
+ raise Error.not_found("Unknown runtime operation #{op}")
47
+ end
48
+ end
49
+
50
+ def find_ticket(params)
51
+ Ticket.find_by_key!(params.fetch("ticket_key"))
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,42 @@
1
+ module Agentboard
2
+ module Runtime
3
+ # Restart reconciliation (spec §27.4, AT-07). For every non-terminal execution owned by
4
+ # a previous daemon: the stdout pipe is gone, so the attempt cannot be safely reattached.
5
+ # Live orphan processes are terminated; the attempt is marked `interrupted` with worktree
6
+ # and logs preserved. Uncertain operations are never re-run automatically.
7
+ class Reconciler
8
+ def initialize(logger: Agentboard.logger)
9
+ @logger = logger
10
+ end
11
+
12
+ def call
13
+ report = { recovered: [], interrupted: [], orphaned: [] }
14
+ Execution.where(status: StateMachines::Execution::ACTIVE - [ "queued" ]).find_each do |execution|
15
+ AgentContainer.remove(execution.metadata["container"]) if execution.metadata["container"]
16
+ if execution.pid && Subprocess.alive?(execution.pid) && fingerprint_matches?(execution)
17
+ Subprocess.terminate(execution.pgid || execution.pid, grace: Agentboard.config.runtime["cancel_grace_seconds"].to_i)
18
+ report[:orphaned] << execution.id
19
+ end
20
+ Executions.transition!(execution, "interrupted", failure_code: "runtime_restart",
21
+ failure_message: "Runtime restarted while the attempt was #{execution.status}; retry explicitly.")
22
+ execution.ticket.leases.live.where(agent_session_id: execution.agent_session_id)
23
+ .update_all(released_at: Time.current, release_reason: "execution_interrupted", updated_at: Time.current)
24
+ report[:interrupted] << execution.id
25
+ rescue StandardError => e
26
+ @logger.error("[agentboard] reconcile #{execution.id} failed: #{e.class}")
27
+ end
28
+ if report.values.any?(&:any?)
29
+ EventStore.append!(type: "agent.runtime.reconciled", aggregate: [ "runtime", "local" ], payload: report)
30
+ end
31
+ report
32
+ end
33
+
34
+ private
35
+
36
+ # PIDs are reused by the OS; only signal a process whose identity still matches.
37
+ def fingerprint_matches?(execution)
38
+ execution.process_fingerprint.present? && Subprocess.fingerprint(execution.pid) == execution.process_fingerprint
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,96 @@
1
+ module Agentboard
2
+ module Runtime
3
+ # Daemon main loop (spec §27.3). Claims queued executions up to the concurrency limit,
4
+ # runs each in a bounded worker thread, drains the outbox, advances workflows and expires
5
+ # approvals. Never blocks the loop on a single process.
6
+ class Supervisor
7
+ attr_reader :started_at, :workers
8
+
9
+ def initialize(config: Agentboard.config, logger: Agentboard.logger)
10
+ @config = config
11
+ @logger = logger
12
+ @workers = {}
13
+ @mutex = Mutex.new
14
+ @stopping = false
15
+ @started_at = Time.current
16
+ end
17
+
18
+ def run(once: false)
19
+ Reconciler.new(logger: @logger).call
20
+ loop do
21
+ tick
22
+ break if once || @stopping
23
+
24
+ sleep @config.runtime["poll_interval"].to_f.clamp(0.05, 10)
25
+ end
26
+ shutdown
27
+ end
28
+
29
+ def stop! = (@stopping = true)
30
+ def active_count = @mutex.synchronize { @workers.count { |_, t| t.alive? } }
31
+
32
+ def tick
33
+ reap
34
+ start_queued
35
+ WorkflowEngine.tick
36
+ Outbox.drain(limit: 200)
37
+ Approvals.expire_due!
38
+ Extensions.run(:runtime_tick)
39
+ rescue StandardError => e
40
+ @logger.error("[agentboard] supervisor tick failed: #{e.class}: #{Redactor.redact(e.message)}")
41
+ end
42
+
43
+ # Fair queue: explicit priority, then age; one active execution per ticket.
44
+ def start_queued
45
+ capacity = @config.max_concurrent_executions - active_count
46
+ return if capacity <= 0
47
+
48
+ candidates = Execution.where(status: "queued").joins(:ticket)
49
+ .order("agentboard_tickets.priority ASC, agentboard_executions.created_at ASC").limit(capacity * 4)
50
+ candidates.each do |execution|
51
+ break if capacity <= 0
52
+ next if @workers.key?(execution.id)
53
+ next if Execution.active.where(ticket_id: execution.ticket_id).where.not(id: execution.id).where.not(status: "queued").exists?
54
+
55
+ claimed = Execution.where(id: execution.id, status: "queued").update_all(updated_at: Time.current, heartbeat_at: Time.current)
56
+ next if claimed.zero?
57
+
58
+ capacity -= 1
59
+ spawn_worker(execution.id)
60
+ end
61
+ end
62
+
63
+ def spawn_worker(execution_id)
64
+ thread = Thread.new do
65
+ Thread.current.name = "agentboard-exec-#{execution_id[0, 8]}"
66
+ ActiveRecord::Base.connection_pool.with_connection do
67
+ ExecutionRunner.new(Execution.find(execution_id), config: @config).call
68
+ end
69
+ rescue StandardError => e
70
+ @logger.error("[agentboard] worker #{execution_id} failed: #{e.class}")
71
+ end
72
+ @mutex.synchronize { @workers[execution_id] = thread }
73
+ end
74
+
75
+ def reap
76
+ @mutex.synchronize { @workers.delete_if { |_, t| !t.alive? } }
77
+ end
78
+
79
+ # Graceful shutdown: request cancellation of running work and wait briefly. Anything
80
+ # still running is reconciled as interrupted on the next start.
81
+ def shutdown
82
+ ids = @mutex.synchronize { @workers.keys }
83
+ return if ids.empty?
84
+
85
+ Execution.where(id: ids).active.update_all(cancel_requested_at: Time.current, cancel_reason: "daemon shutdown")
86
+ deadline = Subprocess.monotonic + @config.runtime["cancel_grace_seconds"].to_i + 5
87
+ sleep 0.1 while Subprocess.monotonic < deadline && active_count.positive?
88
+ end
89
+
90
+ def health
91
+ { version: Agentboard::VERSION, pid: ::Process.pid, uptime: (Time.current - @started_at).round, active: active_count,
92
+ queued: Execution.where(status: "queued").count, max_concurrent: @config.max_concurrent_executions }
93
+ end
94
+ end
95
+ end
96
+ end
@@ -0,0 +1,44 @@
1
+ module Agentboard
2
+ # Associates an agent session with (at most) one active ticket (spec §7).
3
+ # Precedence: MCP selection > CLI attach > dashboard > branch heuristic > provider metadata.
4
+ # An automatic association never replaces an explicit human choice.
5
+ module SessionAssociation
6
+ module_function
7
+
8
+ def link!(session:, ticket:, source:, actor:, confidence: "confirmed")
9
+ raise Error.validation_failed("Unknown association source") unless SessionTicketLink::SOURCES.key?(source)
10
+
11
+ SessionTicketLink.transaction do
12
+ current = session.ticket_links.active.lock.first
13
+ if current
14
+ return current if current.ticket_id == ticket.id && current.precedence <= SessionTicketLink::SOURCES[source]
15
+ if current.explicit? && !SessionTicketLink::EXPLICIT.include?(source)
16
+ raise Error.policy_denied("Session is explicitly attached to #{current.ticket.key}; automatic association ignored")
17
+ end
18
+
19
+ current.update!(ended_at: Time.current)
20
+ end
21
+ link = session.ticket_links.create!(ticket: ticket, source: source, confidence: confidence, started_at: Time.current)
22
+ EventStore.append!(type: "agent.session.attached", aggregate: session, project: session.project, ticket: ticket, session: session,
23
+ actor: actor, provenance: (source == "branch_heuristic" ? "inferred" : "confirmed"),
24
+ payload: { ticket_key: ticket.key, source: source, confidence: confidence, previous_ticket_id: current&.ticket_id })
25
+ link
26
+ end
27
+ end
28
+
29
+ def unlink!(session:, actor:)
30
+ link = session.ticket_links.active.first
31
+ return unless link
32
+
33
+ link.update!(ended_at: Time.current)
34
+ EventStore.append!(type: "agent.session.detached", aggregate: session, project: session.project, ticket: link.ticket,
35
+ session: session, actor: actor, payload: { ticket_key: link.ticket.key })
36
+ end
37
+
38
+ # Branch heuristic: `agentboard/AB-142/...`, `feature/AB-142-foo`, etc. Requires confirmation.
39
+ def ticket_from_branch(branch, project:)
40
+ match = branch.to_s.match(/(?:\A|[\/_-])(#{Regexp.escape(project.key)}-\d+)(?:\z|[\/_-])/i)
41
+ match && Ticket.find_by_key(match[1].upcase, project: project)
42
+ end
43
+ end
44
+ end