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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +62 -0
- data/MIT-LICENSE +20 -0
- data/README.md +316 -0
- data/Rakefile +6 -0
- data/app/assets/stylesheets/agentboard/application.css +510 -0
- data/app/controllers/agentboard/agents_controller.rb +10 -0
- data/app/controllers/agentboard/analytics_controller.rb +37 -0
- data/app/controllers/agentboard/application_controller.rb +90 -0
- data/app/controllers/agentboard/approvals_controller.rb +15 -0
- data/app/controllers/agentboard/assets_controller.rb +32 -0
- data/app/controllers/agentboard/board_controller.rb +46 -0
- data/app/controllers/agentboard/captures_controller.rb +17 -0
- data/app/controllers/agentboard/comments_controller.rb +9 -0
- data/app/controllers/agentboard/criteria_controller.rb +17 -0
- data/app/controllers/agentboard/dependencies_controller.rb +20 -0
- data/app/controllers/agentboard/executions_controller.rb +48 -0
- data/app/controllers/agentboard/milestones_controller.rb +23 -0
- data/app/controllers/agentboard/pulse_controller.rb +12 -0
- data/app/controllers/agentboard/sessions_controller.rb +19 -0
- data/app/controllers/agentboard/settings_controller.rb +9 -0
- data/app/controllers/agentboard/tickets_controller.rb +139 -0
- data/app/controllers/agentboard/workflow_runs_controller.rb +16 -0
- data/app/controllers/agentboard/workflows_controller.rb +62 -0
- data/app/helpers/agentboard/application_helper.rb +196 -0
- data/app/javascript/agentboard/application.js +227 -0
- data/app/javascript/agentboard/capture.js +299 -0
- data/app/jobs/agentboard/application_job.rb +4 -0
- data/app/models/agentboard/acceptance_criterion.rb +23 -0
- data/app/models/agentboard/agent_profile.rb +16 -0
- data/app/models/agentboard/agent_session.rb +30 -0
- data/app/models/agentboard/application_record.rb +13 -0
- data/app/models/agentboard/approval.rb +29 -0
- data/app/models/agentboard/artifact.rb +11 -0
- data/app/models/agentboard/comment.rb +9 -0
- data/app/models/agentboard/event.rb +37 -0
- data/app/models/agentboard/execution.rb +51 -0
- data/app/models/agentboard/idempotency_key.rb +5 -0
- data/app/models/agentboard/milestone.rb +7 -0
- data/app/models/agentboard/outbox_message.rb +11 -0
- data/app/models/agentboard/project.rb +43 -0
- data/app/models/agentboard/repository_snapshot.rb +5 -0
- data/app/models/agentboard/session_ticket_link.rb +18 -0
- data/app/models/agentboard/ticket.rb +81 -0
- data/app/models/agentboard/ticket_dependency.rb +34 -0
- data/app/models/agentboard/ticket_lease.rb +11 -0
- data/app/models/agentboard/usage_record.rb +10 -0
- data/app/models/agentboard/verification_result.rb +12 -0
- data/app/models/agentboard/webhook_delivery.rb +7 -0
- data/app/models/agentboard/workflow_definition.rb +20 -0
- data/app/models/agentboard/workflow_run.rb +21 -0
- data/app/models/agentboard/workflow_step.rb +12 -0
- data/app/services/agentboard/approvals.rb +76 -0
- data/app/services/agentboard/captures.rb +113 -0
- data/app/services/agentboard/completion_policy.rb +117 -0
- data/app/services/agentboard/current.rb +5 -0
- data/app/services/agentboard/data_governance.rb +68 -0
- data/app/services/agentboard/doctor.rb +133 -0
- data/app/services/agentboard/event_store.rb +88 -0
- data/app/services/agentboard/evidence.rb +52 -0
- data/app/services/agentboard/executions.rb +198 -0
- data/app/services/agentboard/hook_ingestion.rb +100 -0
- data/app/services/agentboard/idempotency.rb +28 -0
- data/app/services/agentboard/lease_service.rb +107 -0
- data/app/services/agentboard/mcp_server.rb +138 -0
- data/app/services/agentboard/mcp_tools.rb +341 -0
- data/app/services/agentboard/outbox.rb +44 -0
- data/app/services/agentboard/pull_requests.rb +65 -0
- data/app/services/agentboard/runtime/execution_runner.rb +352 -0
- data/app/services/agentboard/runtime/ipc_handler.rb +55 -0
- data/app/services/agentboard/runtime/reconciler.rb +42 -0
- data/app/services/agentboard/runtime/supervisor.rb +96 -0
- data/app/services/agentboard/session_association.rb +44 -0
- data/app/services/agentboard/session_correlation.rb +56 -0
- data/app/services/agentboard/session_credential.rb +44 -0
- data/app/services/agentboard/ticket_advisor.rb +60 -0
- data/app/services/agentboard/tickets.rb +218 -0
- data/app/services/agentboard/verification_service.rb +60 -0
- data/app/services/agentboard/workflow_engine.rb +261 -0
- data/app/views/agentboard/agents/index.html.erb +51 -0
- data/app/views/agentboard/analytics/show.html.erb +78 -0
- data/app/views/agentboard/approvals/_approval.html.erb +27 -0
- data/app/views/agentboard/approvals/index.html.erb +31 -0
- data/app/views/agentboard/board/_card.html.erb +36 -0
- data/app/views/agentboard/board/show.html.erb +73 -0
- data/app/views/agentboard/executions/index.html.erb +33 -0
- data/app/views/agentboard/executions/show.html.erb +126 -0
- data/app/views/agentboard/milestones/index.html.erb +28 -0
- data/app/views/agentboard/settings/show.html.erb +33 -0
- data/app/views/agentboard/shared/_dag.html.erb +24 -0
- data/app/views/agentboard/tickets/_form.html.erb +38 -0
- data/app/views/agentboard/tickets/edit.html.erb +5 -0
- data/app/views/agentboard/tickets/index.html.erb +51 -0
- data/app/views/agentboard/tickets/new.html.erb +6 -0
- data/app/views/agentboard/tickets/show.html.erb +343 -0
- data/app/views/agentboard/tickets/suggestions.html.erb +21 -0
- data/app/views/agentboard/workflow_runs/show.html.erb +28 -0
- data/app/views/agentboard/workflows/index.html.erb +49 -0
- data/app/views/agentboard/workflows/show.html.erb +15 -0
- data/app/views/layouts/agentboard/application.html.erb +93 -0
- data/config/routes.rb +49 -0
- data/db/migrate/20261114000001_create_agentboard_core.rb +144 -0
- data/db/migrate/20261114000002_create_agentboard_runtime.rb +213 -0
- data/db/migrate/20261115000001_create_agentboard_webhook_deliveries.rb +18 -0
- data/exe/agentboard +7 -0
- data/lib/agentboard/agent_container.rb +124 -0
- data/lib/agentboard/authorization.rb +58 -0
- data/lib/agentboard/capture/middleware.rb +54 -0
- data/lib/agentboard/capture.rb +57 -0
- data/lib/agentboard/cli.rb +818 -0
- data/lib/agentboard/command_policy.rb +44 -0
- data/lib/agentboard/configuration.rb +160 -0
- data/lib/agentboard/container_sandbox.rb +65 -0
- data/lib/agentboard/context_builder.rb +243 -0
- data/lib/agentboard/egress_proxy.rb +95 -0
- data/lib/agentboard/engine.rb +27 -0
- data/lib/agentboard/errors.rb +42 -0
- data/lib/agentboard/event_schema.rb +37 -0
- data/lib/agentboard/extensions.rb +58 -0
- data/lib/agentboard/git/repository.rb +92 -0
- data/lib/agentboard/git/worktree_manager.rb +107 -0
- data/lib/agentboard/git.rb +4 -0
- data/lib/agentboard/ids.rb +33 -0
- data/lib/agentboard/json_log_formatter.rb +28 -0
- data/lib/agentboard/path_guard.rb +27 -0
- data/lib/agentboard/process_ancestry.rb +40 -0
- data/lib/agentboard/providers/base.rb +107 -0
- data/lib/agentboard/providers/claude_code.rb +219 -0
- data/lib/agentboard/providers/codex.rb +177 -0
- data/lib/agentboard/providers/compatibility/claude.yml +13 -0
- data/lib/agentboard/providers/compatibility/codex.yml +10 -0
- data/lib/agentboard/providers/config_editor.rb +106 -0
- data/lib/agentboard/providers/cursor.rb +40 -0
- data/lib/agentboard/providers/fake.rb +59 -0
- data/lib/agentboard/providers/fake_agent_script.rb +47 -0
- data/lib/agentboard/providers/generic_cli.rb +35 -0
- data/lib/agentboard/providers/registry.rb +31 -0
- data/lib/agentboard/providers/types.rb +32 -0
- data/lib/agentboard/providers.rb +26 -0
- data/lib/agentboard/rails_introspection.rb +49 -0
- data/lib/agentboard/redactor.rb +62 -0
- data/lib/agentboard/result.rb +46 -0
- data/lib/agentboard/runtime/ipc.rb +193 -0
- data/lib/agentboard/runtime.rb +1 -0
- data/lib/agentboard/state_machines.rb +71 -0
- data/lib/agentboard/subprocess.rb +168 -0
- data/lib/agentboard/templates/Dockerfile.agent +21 -0
- data/lib/agentboard/verification.rb +79 -0
- data/lib/agentboard/version.rb +3 -0
- data/lib/agentboard/workflows/condition.rb +166 -0
- data/lib/agentboard/workflows/definition.rb +208 -0
- data/lib/agentboard/workflows.rb +2 -0
- data/lib/agentboard.rb +73 -0
- data/lib/generators/agentboard/install/install_generator.rb +67 -0
- data/lib/generators/agentboard/install/templates/config.yml +81 -0
- data/lib/generators/agentboard/install/templates/feature_delivery.yml +25 -0
- data/lib/generators/agentboard/install/templates/initializer.rb +23 -0
- data/lib/tasks/agentboard_tasks.rake +6 -0
- 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
|