cardinal-ai 0.0.1 → 0.2.3
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 +4 -4
- data/LICENSE +21 -0
- data/README.md +50 -29
- data/Rakefile +6 -0
- data/app/assets/stylesheets/application.css +10 -0
- data/app/assets/stylesheets/cardinal.css +514 -0
- data/app/controllers/application_controller.rb +7 -0
- data/app/controllers/boards_controller.rb +5 -0
- data/app/controllers/cards_controller.rb +129 -0
- data/app/controllers/columns_controller.rb +95 -0
- data/app/controllers/messages_controller.rb +25 -0
- data/app/controllers/runs_controller.rb +58 -0
- data/app/helpers/application_helper.rb +35 -0
- data/app/javascript/application.js +2 -0
- data/app/javascript/controllers/application.js +7 -0
- data/app/javascript/controllers/autosave_controller.js +28 -0
- data/app/javascript/controllers/board_column_controller.js +96 -0
- data/app/javascript/controllers/clipboard_controller.js +18 -0
- data/app/javascript/controllers/composer_controller.js +10 -0
- data/app/javascript/controllers/index.js +3 -0
- data/app/javascript/controllers/modal_controller.js +43 -0
- data/app/javascript/controllers/scroll_controller.js +44 -0
- data/app/javascript/controllers/tags_controller.js +49 -0
- data/app/javascript/controllers/theme_controller.js +43 -0
- data/app/javascript/controllers/tooltip_controller.js +37 -0
- data/app/jobs/ai_task_job.rb +26 -0
- data/app/jobs/application_job.rb +7 -0
- data/app/jobs/assistant_reply_job.rb +132 -0
- data/app/jobs/mark_pr_ready_job.rb +18 -0
- data/app/jobs/merge_pr_job.rb +27 -0
- data/app/jobs/resume_run_job.rb +30 -0
- data/app/jobs/start_run_job.rb +13 -0
- data/app/mailers/application_mailer.rb +4 -0
- data/app/models/agent_session.rb +8 -0
- data/app/models/application_record.rb +3 -0
- data/app/models/artifact.rb +8 -0
- data/app/models/board.rb +60 -0
- data/app/models/card.rb +83 -0
- data/app/models/column.rb +83 -0
- data/app/models/event.rb +44 -0
- data/app/models/run.rb +28 -0
- data/app/services/agent/runner.rb +379 -0
- data/app/services/agent/workspace.rb +138 -0
- data/app/services/card_transition.rb +97 -0
- data/app/services/claude_cli.rb +89 -0
- data/app/services/rules/compiler.rb +55 -0
- data/app/services/rules.rb +67 -0
- data/app/services/run_sweeper.rb +52 -0
- data/app/views/boards/show.html.erb +79 -0
- data/app/views/cards/_card.html.erb +48 -0
- data/app/views/cards/_detail.html.erb +190 -0
- data/app/views/cards/_tag_picker.html.erb +12 -0
- data/app/views/cards/new.html.erb +35 -0
- data/app/views/cards/show.html.erb +3 -0
- data/app/views/columns/_column.html.erb +25 -0
- data/app/views/columns/edit.html.erb +126 -0
- data/app/views/events/_event.html.erb +29 -0
- data/app/views/layouts/application.html.erb +46 -0
- data/app/views/layouts/mailer.html.erb +13 -0
- data/app/views/layouts/mailer.text.erb +1 -0
- data/app/views/pwa/manifest.json.erb +22 -0
- data/app/views/pwa/service-worker.js +26 -0
- data/bin/rails +4 -0
- data/bin/rake +4 -0
- data/cardinal.md +686 -0
- data/config/application.rb +60 -0
- data/config/boot.rb +13 -0
- data/config/bundler-audit.yml +5 -0
- data/config/cable.yml +13 -0
- data/config/ci.rb +20 -0
- data/config/credentials.yml.enc +1 -0
- data/config/database.yml +31 -0
- data/config/environment.rb +5 -0
- data/config/environments/development.rb +78 -0
- data/config/environments/production.rb +89 -0
- data/config/environments/test.rb +53 -0
- data/config/importmap.rb +6 -0
- data/config/initializers/assets.rb +7 -0
- data/config/initializers/cardinal_bootstrap.rb +12 -0
- data/config/initializers/cardinal_instance.rb +20 -0
- data/config/initializers/content_security_policy.rb +29 -0
- data/config/initializers/filter_parameter_logging.rb +8 -0
- data/config/initializers/inflections.rb +16 -0
- data/config/initializers/run_sweeper.rb +17 -0
- data/config/locales/en.yml +31 -0
- data/config/puma.rb +42 -0
- data/config/routes.rb +22 -0
- data/config/storage.yml +27 -0
- data/config.ru +6 -0
- data/db/migrate/20260703000001_create_cardinal_schema.rb +78 -0
- data/db/migrate/20260703000002_add_agent_runner_fields.rb +7 -0
- data/db/migrate/20260704000001_add_parent_to_cards.rb +5 -0
- data/db/migrate/20260704000002_add_assistant_session_to_cards.rb +5 -0
- data/db/seeds.rb +19 -0
- data/docker/agent/Dockerfile +16 -0
- data/exe/cardinal +111 -0
- data/lib/cardinal/version.rb +1 -1
- data/public/400.html +135 -0
- data/public/404.html +135 -0
- data/public/406-unsupported-browser.html +135 -0
- data/public/422.html +135 -0
- data/public/500.html +135 -0
- data/public/icon.png +0 -0
- data/public/icon.svg +3 -0
- data/public/robots.txt +1 -0
- data/vendor/javascript/sortablejs.js +3378 -0
- metadata +235 -9
data/app/models/run.rb
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
class Run < ApplicationRecord
|
|
2
|
+
STATUSES = %w[queued running needs_input succeeded failed cancelled].freeze
|
|
3
|
+
|
|
4
|
+
belongs_to :agent_session
|
|
5
|
+
has_one :card, through: :agent_session
|
|
6
|
+
has_many :artifacts, dependent: :destroy
|
|
7
|
+
has_many :events, dependent: :nullify
|
|
8
|
+
|
|
9
|
+
enum :status, STATUSES.index_by(&:itself)
|
|
10
|
+
|
|
11
|
+
# A budget/timeout outcome, whether the segment parked (needs_input) or was
|
|
12
|
+
# recorded as a failure. The parked message ("…turn budget mid-work…") lives
|
|
13
|
+
# on the last question event; the failure message (failure_reason) lives on
|
|
14
|
+
# result_summary. Either signals "try again with a fresh budget," not a bug.
|
|
15
|
+
EXHAUSTION = /turn budget|max-turns budget|timed out|timeout/i
|
|
16
|
+
|
|
17
|
+
def finished? = %w[succeeded failed cancelled].include?(status)
|
|
18
|
+
|
|
19
|
+
def exhausted?
|
|
20
|
+
text = needs_input? ? events.where(kind: "question").order(:id).last&.text : result_summary
|
|
21
|
+
text.to_s.match?(EXHAUSTION)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
# A run the user can relaunch from the work panel: an execution-column run
|
|
25
|
+
# that parked or failed on its budget/timeout. Restart resumes the surviving
|
|
26
|
+
# session (fresh budget) or starts a clean run when no session remains.
|
|
27
|
+
def restartable? = card.column.execution? && (needs_input? || failed?) && exhausted?
|
|
28
|
+
end
|
|
@@ -0,0 +1,379 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
# Drives one Run through its phases (cardinal.md §4, §11, §17):
|
|
3
|
+
#
|
|
4
|
+
# start → plan phase (read-only, --permission-mode plan) when the column
|
|
5
|
+
# requires approval, else straight to execute
|
|
6
|
+
# park → plan_proposed or QUESTION: → run + card go needs_input
|
|
7
|
+
# resume → same claude session (--resume) with the user's answer,
|
|
8
|
+
# approval, or plan feedback
|
|
9
|
+
# finish → push branch, ensure draft PR, final report, work_complete
|
|
10
|
+
#
|
|
11
|
+
# The subprocess is the Claude Agent runtime (`claude -p`, stream-json).
|
|
12
|
+
# Heartbeats are written while streaming; RunSweeper reaps silent runs.
|
|
13
|
+
class Runner
|
|
14
|
+
STRIP_ENV = %w[ANTHROPIC_API_KEY CLAUDECODE CLAUDE_CODE_ENTRYPOINT GH_TOKEN GITHUB_TOKEN].freeze
|
|
15
|
+
HEARTBEAT_EVERY = 10 # seconds
|
|
16
|
+
PLAN_TURNS = 20
|
|
17
|
+
DEFAULT_EXECUTE_TURNS = 80 # turn caps are runaway guards, not work limits
|
|
18
|
+
|
|
19
|
+
EXECUTE_RULES = <<~RULES.freeze
|
|
20
|
+
## Rules
|
|
21
|
+
- You have the FULL toolset now: shell (bash, git), file editing, everything. Run
|
|
22
|
+
commands yourself — never ask who should run them.
|
|
23
|
+
- Work only inside this repository checkout (you are already on the card's branch).
|
|
24
|
+
- If the branch conflicts with origin's default branch, merge it into the card
|
|
25
|
+
branch yourself and resolve the conflicts as part of the work.
|
|
26
|
+
- Commit your work as you go with clear messages. Do NOT push — the runner pushes for you.
|
|
27
|
+
- Stay strictly within the card's scope. Prefer the smallest reasonable interpretation and note assumptions.
|
|
28
|
+
- If you are blocked on a decision only the user can make, output a single line starting with
|
|
29
|
+
"QUESTION:" followed by the question, then stop immediately. Do not guess on genuinely ambiguous choices.
|
|
30
|
+
- Finish with a concise report: what you did, what to check, any open questions.
|
|
31
|
+
RULES
|
|
32
|
+
|
|
33
|
+
def self.start(run) = new(run).start
|
|
34
|
+
def self.resume(run, message, approve: false) = new(run).resume(message, approve: approve)
|
|
35
|
+
|
|
36
|
+
attr_reader :run, :card, :column
|
|
37
|
+
|
|
38
|
+
def initialize(run)
|
|
39
|
+
@run = run
|
|
40
|
+
@card = run.card
|
|
41
|
+
@column = card.column
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def start
|
|
45
|
+
begin_segment!(first: true)
|
|
46
|
+
if plan_gated?
|
|
47
|
+
run.update!(phase: "plan")
|
|
48
|
+
stream_agent(prompt: plan_prompt, mode: "plan")
|
|
49
|
+
else
|
|
50
|
+
stream_agent(prompt: briefing_prompt, mode: "execute")
|
|
51
|
+
end
|
|
52
|
+
rescue => e
|
|
53
|
+
record_failure(e)
|
|
54
|
+
ensure
|
|
55
|
+
column.kick_queue if column.execution?
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def resume(message, approve: false)
|
|
59
|
+
begin_segment!
|
|
60
|
+
if run.phase == "plan" && approve
|
|
61
|
+
run.update!(phase: "execute")
|
|
62
|
+
stream_agent(prompt: "Your plan is approved — execute it now.\n\n#{EXECUTE_RULES}",
|
|
63
|
+
mode: "execute", resuming: true)
|
|
64
|
+
elsif run.phase == "plan"
|
|
65
|
+
stream_agent(prompt: "Feedback on your plan:\n\n#{message}\n\nRevise the plan accordingly, present it, and stop again for approval. Stay in read-only mode.",
|
|
66
|
+
mode: "plan", resuming: true)
|
|
67
|
+
else
|
|
68
|
+
stream_agent(prompt: "Answer from the user:\n\n#{message}\n\nContinue the work. The same rules apply (commit, don't push, QUESTION: if blocked again, final report when done).",
|
|
69
|
+
mode: "execute", resuming: true)
|
|
70
|
+
end
|
|
71
|
+
rescue => e
|
|
72
|
+
record_failure(e)
|
|
73
|
+
ensure
|
|
74
|
+
column.kick_queue if column.execution?
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
private
|
|
78
|
+
|
|
79
|
+
def plan_gated?
|
|
80
|
+
ActiveModel::Type::Boolean.new.cast(column.plan_approval)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def begin_segment!(first: false)
|
|
84
|
+
run.update!(status: "running", started_at: run.started_at || Time.current, heartbeat_at: Time.current)
|
|
85
|
+
card.update!(status: "working")
|
|
86
|
+
if first
|
|
87
|
+
card.log!("run_started", run: run, text: "Run ##{run.id} started")
|
|
88
|
+
else
|
|
89
|
+
card.log!("progress", actor: "agent", run: run, text: "Run resumed")
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stream_agent(prompt:, mode:, resuming: false)
|
|
94
|
+
workspace = resuming ? Workspace.attach(card) : Workspace.provision(card)
|
|
95
|
+
remember_base_sha(workspace) if mode == "execute"
|
|
96
|
+
|
|
97
|
+
cmd = ["claude", "-p", prompt, "--output-format", "stream-json", "--verbose",
|
|
98
|
+
"--permission-mode", "bypassPermissions"]
|
|
99
|
+
case mode
|
|
100
|
+
when "plan"
|
|
101
|
+
# Read-only exploration for the plan phase. (--permission-mode plan
|
|
102
|
+
# hangs headless: ExitPlanMode waits for an approval that never comes.)
|
|
103
|
+
cmd += ["--max-turns", PLAN_TURNS.to_s, "--tools", "Read,Glob,Grep"]
|
|
104
|
+
when "plan_wrap"
|
|
105
|
+
# Turn-capped plan: force the plan out of the context already gathered.
|
|
106
|
+
cmd += ["--max-turns", "3", "--tools", ""]
|
|
107
|
+
else
|
|
108
|
+
cmd += ["--max-turns", (column.max_turns.presence || DEFAULT_EXECUTE_TURNS).to_s]
|
|
109
|
+
end
|
|
110
|
+
cmd += ["--model", column.model] if column.model.present?
|
|
111
|
+
cmd += ["--effort", column.effort] if column.effort.present?
|
|
112
|
+
cmd += ["--resume", run.external_session_id] if resuming && run.external_session_id.present?
|
|
113
|
+
|
|
114
|
+
result = {}
|
|
115
|
+
@base_in, @base_out = run.input_tokens, run.output_tokens
|
|
116
|
+
@seg_in = @seg_out = 0
|
|
117
|
+
env = STRIP_ENV.index_with { nil }
|
|
118
|
+
spawn_cmd, spawn_opts = workspace.agent_spawn(cmd)
|
|
119
|
+
Open3.popen3(env, *spawn_cmd, **spawn_opts) do |stdin, stdout, stderr, wait|
|
|
120
|
+
stdin.close
|
|
121
|
+
run.agent_session.update!(status: "ready", config: run.agent_session.config.merge("pid" => wait.pid))
|
|
122
|
+
timeout_min = (column.timeout_minutes.presence || 30).to_i
|
|
123
|
+
watchdog = Thread.new do
|
|
124
|
+
sleep timeout_min * 60
|
|
125
|
+
@timed_out = true
|
|
126
|
+
Process.kill("TERM", wait.pid) rescue nil
|
|
127
|
+
end
|
|
128
|
+
err_lines = []
|
|
129
|
+
drain = Thread.new { stderr.each_line { |l| err_lines << l.strip; err_lines.shift while err_lines.size > 4 } }
|
|
130
|
+
last_beat = Time.current
|
|
131
|
+
stdout.each_line do |line|
|
|
132
|
+
if Time.current - last_beat > HEARTBEAT_EVERY
|
|
133
|
+
# Heartbeat + live token tally: tokens survive even if this
|
|
134
|
+
# segment is killed before its result event.
|
|
135
|
+
run.update_columns(heartbeat_at: Time.current,
|
|
136
|
+
input_tokens: @base_in + @seg_in,
|
|
137
|
+
output_tokens: @base_out + @seg_out)
|
|
138
|
+
last_beat = Time.current
|
|
139
|
+
end
|
|
140
|
+
begin
|
|
141
|
+
handle_stream_event(JSON.parse(line), result)
|
|
142
|
+
rescue JSON::ParserError
|
|
143
|
+
next
|
|
144
|
+
end
|
|
145
|
+
end
|
|
146
|
+
drain.join(1)
|
|
147
|
+
watchdog.kill
|
|
148
|
+
result[:exit_status] = wait.value
|
|
149
|
+
result[:stderr] = err_lines.join(" | ")
|
|
150
|
+
result[:timed_out] = @timed_out
|
|
151
|
+
result[:timeout_min] = timeout_min
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
mode == "execute" ? conclude_execute(workspace, result) : conclude_plan(result)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def handle_stream_event(json, result)
|
|
158
|
+
case json["type"]
|
|
159
|
+
when "system"
|
|
160
|
+
if json["subtype"] == "init"
|
|
161
|
+
run.update_columns(external_session_id: json["session_id"]) if json["session_id"].present?
|
|
162
|
+
card.log!("progress", actor: "agent", run: run, text: "Agent session started (#{json["model"]})")
|
|
163
|
+
end
|
|
164
|
+
when "assistant"
|
|
165
|
+
if (usage = json.dig("message", "usage"))
|
|
166
|
+
@seg_in += usage["input_tokens"].to_i
|
|
167
|
+
@seg_out += usage["output_tokens"].to_i
|
|
168
|
+
end
|
|
169
|
+
Array(json.dig("message", "content")).each do |block|
|
|
170
|
+
case block["type"]
|
|
171
|
+
when "text"
|
|
172
|
+
card.log!("progress", actor: "agent", run: run, text: block["text"].to_s.truncate(400)) if block["text"].present?
|
|
173
|
+
when "tool_use"
|
|
174
|
+
card.log!("tool_call", actor: "agent", run: run,
|
|
175
|
+
text: "#{block["name"]}: #{block["input"].to_json.truncate(160)}")
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
when "result"
|
|
179
|
+
result[:success] = json["subtype"] == "success" && !json["is_error"]
|
|
180
|
+
result[:subtype] = json["subtype"]
|
|
181
|
+
result[:report] = json["result"].to_s
|
|
182
|
+
result[:cost] = json["total_cost_usd"]
|
|
183
|
+
result[:turns] = json["num_turns"]
|
|
184
|
+
result[:input_tokens] = json.dig("usage", "input_tokens")
|
|
185
|
+
result[:output_tokens] = json.dig("usage", "output_tokens")
|
|
186
|
+
end
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def conclude_plan(result)
|
|
190
|
+
accumulate_usage(result)
|
|
191
|
+
unless result[:success] && result[:report].present?
|
|
192
|
+
# Turn-capped mid-exploration: one tool-less wrap-up pass to force the
|
|
193
|
+
# plan out of the context it already gathered.
|
|
194
|
+
if result[:subtype] == "error_max_turns" && run.external_session_id.present? && !@plan_wrap_attempted
|
|
195
|
+
@plan_wrap_attempted = true
|
|
196
|
+
card.log!("progress", actor: "agent", run: run, text: "Hit the exploration budget — wrapping up the plan from what was learned")
|
|
197
|
+
return stream_agent(prompt: "You have hit your exploration limit. Present your best plan-of-attack now, using only what you have already learned. Do not use any tools.",
|
|
198
|
+
mode: "plan_wrap", resuming: true)
|
|
199
|
+
end
|
|
200
|
+
return record_failure(RuntimeError.new("plan phase failed — #{failure_reason(result)}"))
|
|
201
|
+
end
|
|
202
|
+
park!("plan_proposed", result[:report],
|
|
203
|
+
note: "Plan proposed — approve it in the work panel, or reply to redirect.")
|
|
204
|
+
end
|
|
205
|
+
|
|
206
|
+
def conclude_execute(workspace, result)
|
|
207
|
+
accumulate_usage(result)
|
|
208
|
+
unless result[:success]
|
|
209
|
+
# Budget exhaustion isn't failure — park and offer to continue (§8).
|
|
210
|
+
# The session survives; an answer resumes it with a fresh turn budget.
|
|
211
|
+
if result[:subtype] == "error_max_turns" && run.external_session_id.present?
|
|
212
|
+
commits = salvage_commits(workspace)
|
|
213
|
+
return park!("question",
|
|
214
|
+
"I've used this segment's turn budget mid-work#{" — #{commits} commit(s) so far are saved to the branch" if commits.to_i.positive?}. Reply (anything) to continue with a fresh budget, or cancel the run.",
|
|
215
|
+
note: "Agent paused at the turn budget — reply on the card to continue.")
|
|
216
|
+
end
|
|
217
|
+
salvage_commits(workspace)
|
|
218
|
+
return record_failure(RuntimeError.new(failure_reason(result)))
|
|
219
|
+
end
|
|
220
|
+
|
|
221
|
+
report = result[:report].to_s
|
|
222
|
+
if report.lstrip.start_with?("QUESTION:")
|
|
223
|
+
return park!("question", report.lstrip.delete_prefix("QUESTION:").strip,
|
|
224
|
+
note: "Agent is waiting on your answer — reply on the card.")
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
commits = workspace.commits_since(base_sha)
|
|
228
|
+
if commits.any?
|
|
229
|
+
workspace.push!
|
|
230
|
+
ensure_pull_request(workspace)
|
|
231
|
+
run.artifacts.create!(kind: "pull_request", name: "PR for #{card.branch_name}",
|
|
232
|
+
payload: { url: card.pr_url, commits: commits })
|
|
233
|
+
elsif card.pr_url.blank? && workspace.ahead_of_default?
|
|
234
|
+
# No new commits this run, but the branch carries earlier work (e.g. a
|
|
235
|
+
# salvage commit) that still needs a PR.
|
|
236
|
+
workspace.push!
|
|
237
|
+
ensure_pull_request(workspace)
|
|
238
|
+
end
|
|
239
|
+
|
|
240
|
+
run.update!(status: "succeeded", finished_at: Time.current,
|
|
241
|
+
result_summary: report.presence&.truncate(2000))
|
|
242
|
+
card.log!("final_report", actor: "agent", run: run,
|
|
243
|
+
text: [report.presence || "Run finished with no report.",
|
|
244
|
+
commits.any? ? "\n**Commits (#{commits.size}):**\n#{commits.map { |c| "- #{c}" }.join("\n")}" : "\n_No commits were made._"].join("\n"))
|
|
245
|
+
card.update!(status: "work_complete")
|
|
246
|
+
card.log!("run_finished", run: run,
|
|
247
|
+
text: "Run succeeded — #{result[:turns]} turns, $#{run.cost.round(2)} total")
|
|
248
|
+
end
|
|
249
|
+
|
|
250
|
+
def park!(kind, text, note:)
|
|
251
|
+
run.update!(status: "needs_input")
|
|
252
|
+
card.log!(kind, actor: "agent", run: run, text: text)
|
|
253
|
+
card.update!(status: "needs_input")
|
|
254
|
+
card.log!("status_change", run: run, text: note)
|
|
255
|
+
end
|
|
256
|
+
|
|
257
|
+
# Say WHY, not just that it died: timeout vs turn cap vs error vs crash.
|
|
258
|
+
def failure_reason(result)
|
|
259
|
+
return "timed out after #{result[:timeout_min]} minutes and was stopped — raise the column's timeout for bigger tasks, or split the card" if result[:timed_out]
|
|
260
|
+
return "hit this segment's max-turns budget — raise Max turns in the column's gear settings, or split the card" if result[:subtype] == "error_max_turns"
|
|
261
|
+
parts = ["agent did not finish cleanly (exit #{result[:exit_status]&.exitstatus || "?"})"]
|
|
262
|
+
parts << "last output: #{result[:report].truncate(300)}" if result[:report].present?
|
|
263
|
+
parts << "stderr: #{result[:stderr].truncate(300)}" if result[:stderr].present?
|
|
264
|
+
parts.join(" — ")
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
# A failed/timed-out segment may still hold real local commits; push them
|
|
268
|
+
# so the branch (and any PR) keeps the partial progress instead of the
|
|
269
|
+
# next provision's reset wiping it.
|
|
270
|
+
def salvage_commits(workspace)
|
|
271
|
+
commits = workspace.commits_since(base_sha)
|
|
272
|
+
return 0 if commits.empty?
|
|
273
|
+
workspace.push!
|
|
274
|
+
card.log!("progress", run: run, text: "Partial work preserved: #{commits.size} commit(s) pushed to #{card.branch_name}")
|
|
275
|
+
commits.size
|
|
276
|
+
rescue => e
|
|
277
|
+
card.log!("progress", run: run, text: "Could not preserve partial work: #{e.message.truncate(120)}")
|
|
278
|
+
0
|
|
279
|
+
end
|
|
280
|
+
|
|
281
|
+
def record_failure(error)
|
|
282
|
+
run.update!(status: "failed", finished_at: Time.current,
|
|
283
|
+
result_summary: error.message.truncate(500))
|
|
284
|
+
card.update!(status: "failed")
|
|
285
|
+
card.log!("error", run: run, text: "Run failed: #{error.message.truncate(500)}")
|
|
286
|
+
end
|
|
287
|
+
|
|
288
|
+
# Cost/tokens accumulate across segments of the same run (plan + execute +
|
|
289
|
+
# resumes). Tokens were live-tallied during the stream; the result event's
|
|
290
|
+
# figures are authoritative when present, the live tally is the fallback
|
|
291
|
+
# for killed segments (which never emit a result).
|
|
292
|
+
def accumulate_usage(result)
|
|
293
|
+
base_in = @base_in || run.input_tokens
|
|
294
|
+
base_out = @base_out || run.output_tokens
|
|
295
|
+
run.update!(cost: run.cost + (result[:cost] || 0),
|
|
296
|
+
input_tokens: base_in + (result[:input_tokens] || @seg_in || 0),
|
|
297
|
+
output_tokens: base_out + (result[:output_tokens] || @seg_out || 0))
|
|
298
|
+
@base_in = @base_out = @seg_in = @seg_out = nil
|
|
299
|
+
end
|
|
300
|
+
|
|
301
|
+
def remember_base_sha(workspace)
|
|
302
|
+
return if run.briefing["base_sha"].present?
|
|
303
|
+
run.update!(briefing: run.briefing.merge("base_sha" => workspace.head))
|
|
304
|
+
end
|
|
305
|
+
|
|
306
|
+
def base_sha = run.briefing.fetch("base_sha")
|
|
307
|
+
|
|
308
|
+
def ensure_pull_request(workspace)
|
|
309
|
+
return if card.pr_url.present?
|
|
310
|
+
out, status = Open3.capture2e(
|
|
311
|
+
"gh", "pr", "create", "--draft",
|
|
312
|
+
"--head", card.branch_name,
|
|
313
|
+
"--title", "##{card.number} #{card.title}",
|
|
314
|
+
"--body", "Automated work by Cardinal card ##{card.number}'s agent.\n\n#{card.description}",
|
|
315
|
+
chdir: workspace.path.to_s
|
|
316
|
+
)
|
|
317
|
+
if status.success? && (url = out[%r{https://github\.com/\S+/pull/\d+}])
|
|
318
|
+
card.update!(pr_url: url, pr_state: "draft")
|
|
319
|
+
card.log!("artifact_created", run: run, text: "Draft PR opened: #{url}")
|
|
320
|
+
else
|
|
321
|
+
card.log!("progress", run: run, text: "Branch pushed (PR not created: #{out.truncate(120)})")
|
|
322
|
+
end
|
|
323
|
+
end
|
|
324
|
+
|
|
325
|
+
def briefing_prompt
|
|
326
|
+
<<~PROMPT
|
|
327
|
+
You are the dedicated worker agent for card ##{card.number} of the Cardinal board: "#{card.title}".
|
|
328
|
+
|
|
329
|
+
## Brief
|
|
330
|
+
#{card.description.presence || "(no description — infer scope from the title and conversation)"}
|
|
331
|
+
|
|
332
|
+
#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
333
|
+
## Card conversation so far
|
|
334
|
+
#{conversation_excerpt.presence || "(none)"}
|
|
335
|
+
|
|
336
|
+
#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
337
|
+
#{EXECUTE_RULES}
|
|
338
|
+
PROMPT
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def plan_prompt
|
|
342
|
+
<<~PROMPT
|
|
343
|
+
You are the dedicated worker agent for card ##{card.number} of the Cardinal board: "#{card.title}".
|
|
344
|
+
You are in READ-ONLY PLAN MODE. Do not modify anything yet.
|
|
345
|
+
|
|
346
|
+
## Brief
|
|
347
|
+
#{card.description.presence || "(no description — infer scope from the title and conversation)"}
|
|
348
|
+
|
|
349
|
+
#{"## Brief from planning (authoritative — refined with the user)\n#{planning_brief}\n" if planning_brief}
|
|
350
|
+
## Card conversation so far
|
|
351
|
+
#{conversation_excerpt.presence || "(none)"}
|
|
352
|
+
|
|
353
|
+
#{"## Column instructions\n#{column.instructions}\n" if column.instructions.present?}
|
|
354
|
+
Explore the repository as needed, then present a short numbered plan-of-attack
|
|
355
|
+
(files you'll touch, approach, how you'll verify) and stop. The user will approve
|
|
356
|
+
or redirect before any changes are made.
|
|
357
|
+
|
|
358
|
+
IMPORTANT: you are read-only ONLY during this planning pass. Once the plan is
|
|
359
|
+
approved you will have the full toolset — shell, git, file editing — in this same
|
|
360
|
+
session. Plan every step as something YOU will do (including git merges and
|
|
361
|
+
running commands); never ask who should run a command or plan around not having
|
|
362
|
+
a shell.
|
|
363
|
+
PROMPT
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
# The planning assistant's distilled "Ready for execution" brief, if the
|
|
367
|
+
# conversation produced one — the most load-bearing artifact of planning.
|
|
368
|
+
def planning_brief
|
|
369
|
+
return @planning_brief if defined?(@planning_brief)
|
|
370
|
+
@planning_brief = card.events.where(kind: "assistant_message")
|
|
371
|
+
.order(:id).filter_map(&:text).reverse
|
|
372
|
+
.find { |t| t.match?(/ready for execution/i) }
|
|
373
|
+
end
|
|
374
|
+
|
|
375
|
+
def conversation_excerpt
|
|
376
|
+
card.events.conversation.filter_map { |e| "#{e.actor}: #{e.text}" if e.text }.last(30).join("\n")
|
|
377
|
+
end
|
|
378
|
+
end
|
|
379
|
+
end
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
module Agent
|
|
2
|
+
# The worker agent's isolated checkout, behind a strategy factory
|
|
3
|
+
# (cardinal.md §13, §17):
|
|
4
|
+
#
|
|
5
|
+
# Local — clone under .cardinal/workspaces/; the agent process runs on
|
|
6
|
+
# the host with chdir into the checkout. Process-level
|
|
7
|
+
# isolation only. The default.
|
|
8
|
+
# Container — same host-side checkout, but the agent runs inside a
|
|
9
|
+
# cage-style Docker container that mounts ONLY the checkout.
|
|
10
|
+
# Opt in with CARDINAL_WORKSPACE=container (experimental —
|
|
11
|
+
# requires a Docker daemon and CARDINAL_AGENT_IMAGE with the
|
|
12
|
+
# claude CLI installed; ANTHROPIC_API_KEY is passed through).
|
|
13
|
+
#
|
|
14
|
+
# Both strategies share git provisioning: the runner owns clone, branch,
|
|
15
|
+
# and push — the agent only ever commits.
|
|
16
|
+
module Workspace
|
|
17
|
+
def self.provision(card) = strategy.provision(card)
|
|
18
|
+
def self.attach(card) = strategy.attach(card)
|
|
19
|
+
|
|
20
|
+
def self.strategy
|
|
21
|
+
ENV["CARDINAL_WORKSPACE"] == "container" ? Container : Local
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
class Local
|
|
25
|
+
ROOT = Rails.root.join(".cardinal", "workspaces")
|
|
26
|
+
|
|
27
|
+
attr_reader :card, :path
|
|
28
|
+
|
|
29
|
+
def self.provision(card) = new(card).tap(&:provision)
|
|
30
|
+
|
|
31
|
+
# Reattach without resetting — used when resuming a parked run whose
|
|
32
|
+
# local commits aren't pushed yet.
|
|
33
|
+
def self.attach(card)
|
|
34
|
+
ws = new(card)
|
|
35
|
+
File.directory?(ws.path.join(".git")) ? ws : ws.tap(&:provision)
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def initialize(card)
|
|
39
|
+
@card = card
|
|
40
|
+
@path = ROOT.join("card-#{card.number}")
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def provision
|
|
44
|
+
FileUtils.mkdir_p(ROOT)
|
|
45
|
+
unless File.directory?(path.join(".git"))
|
|
46
|
+
git!(ROOT, "clone", "--quiet", (card.board.local_path.presence || Rails.root).to_s, path.to_s)
|
|
47
|
+
git!(path, "remote", "set-url", "origin", card.board.repo_url) if card.board.repo_url.present?
|
|
48
|
+
end
|
|
49
|
+
salvage_dirty_tree!
|
|
50
|
+
git!(path, "fetch", "--quiet", "origin")
|
|
51
|
+
if git?(path, "rev-parse", "--verify", "origin/#{card.branch_name}")
|
|
52
|
+
git!(path, "checkout", "--quiet", card.branch_name)
|
|
53
|
+
git!(path, "reset", "--quiet", "--hard", "origin/#{card.branch_name}")
|
|
54
|
+
elsif git?(path, "rev-parse", "--verify", card.branch_name)
|
|
55
|
+
# Local-only branch (e.g. WIP salvaged but never pushed): keep it.
|
|
56
|
+
git!(path, "checkout", "--quiet", card.branch_name)
|
|
57
|
+
else
|
|
58
|
+
git!(path, "checkout", "--quiet", "-B", card.branch_name, "origin/#{card.board.default_branch}")
|
|
59
|
+
end
|
|
60
|
+
self
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# A killed run can leave uncommitted edits that block checkout and would
|
|
64
|
+
# otherwise be silently destroyed. Commit them as WIP on the branch and
|
|
65
|
+
# push (best effort) so the interrupted work survives onto the PR.
|
|
66
|
+
def salvage_dirty_tree!
|
|
67
|
+
return if git_out(path, "status", "--porcelain").strip.empty?
|
|
68
|
+
git!(path, "add", "-A")
|
|
69
|
+
git!(path, "commit", "--quiet", "-m", "WIP: salvage uncommitted work from an interrupted run")
|
|
70
|
+
begin
|
|
71
|
+
push!
|
|
72
|
+
rescue RuntimeError
|
|
73
|
+
nil # offline is fine — the local-branch checkout path keeps the WIP
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
# How the runner should spawn the agent process for this workspace.
|
|
78
|
+
def agent_spawn(cmd) = [cmd, { chdir: path.to_s }]
|
|
79
|
+
|
|
80
|
+
def head = git_out(path, "rev-parse", "HEAD").strip
|
|
81
|
+
|
|
82
|
+
def commits_since(sha)
|
|
83
|
+
git_out(path, "log", "--oneline", "#{sha}..HEAD").lines.map(&:strip)
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def ahead_of_default?
|
|
87
|
+
git_out(path, "rev-list", "--count", "origin/#{card.board.default_branch}..HEAD").strip.to_i.positive?
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def push!
|
|
91
|
+
git!(path, "push", "--quiet", "-u", "origin", card.branch_name)
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
private
|
|
95
|
+
|
|
96
|
+
def git!(dir, *args)
|
|
97
|
+
out, status = Open3.capture2e("git", "-C", dir.to_s, *args)
|
|
98
|
+
raise "git #{args.first} failed: #{out.truncate(300)}" unless status.success?
|
|
99
|
+
out
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def git?(dir, *args)
|
|
103
|
+
_, status = Open3.capture2e("git", "-C", dir.to_s, *args)
|
|
104
|
+
status.success?
|
|
105
|
+
end
|
|
106
|
+
|
|
107
|
+
def git_out(dir, *args) = git!(dir, *args)
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
# EXPERIMENTAL — written against the cage model; needs a host with Docker
|
|
111
|
+
# to exercise. Git stays host-side; only the agent process is jailed.
|
|
112
|
+
class Container < Local
|
|
113
|
+
WORKDIR = "/workspace/repo"
|
|
114
|
+
|
|
115
|
+
def image = ENV.fetch("CARDINAL_AGENT_IMAGE", "cardinal-agent:latest")
|
|
116
|
+
def container_name = "cardinal-card-#{card.number}"
|
|
117
|
+
|
|
118
|
+
def agent_spawn(cmd)
|
|
119
|
+
docker = ["docker", "run", "--rm", "-i",
|
|
120
|
+
"--name", container_name,
|
|
121
|
+
"--label", "cardinal=agent",
|
|
122
|
+
"-v", "#{path}:#{WORKDIR}",
|
|
123
|
+
"-w", WORKDIR]
|
|
124
|
+
# Value-embedded because the runner nils the key in the client env
|
|
125
|
+
# (visible in ps on the host — acceptable for the experimental tier).
|
|
126
|
+
# Instance OAuth token (cardinal up account link) or raw API key —
|
|
127
|
+
# whichever this instance runs on.
|
|
128
|
+
docker += ["-e", "ANTHROPIC_API_KEY=#{ENV["ANTHROPIC_API_KEY"]}"] if ENV["ANTHROPIC_API_KEY"].present?
|
|
129
|
+
docker += ["-e", "CLAUDE_CODE_OAUTH_TOKEN=#{ENV["CLAUDE_CODE_OAUTH_TOKEN"]}"] if ENV["CLAUDE_CODE_OAUTH_TOKEN"].present?
|
|
130
|
+
[docker + [image] + cmd, {}]
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def teardown
|
|
134
|
+
Open3.capture2e("docker", "rm", "-f", container_name)
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# The only code path that moves a card between columns. Validates legality,
|
|
2
|
+
# runs the old column's leave policy and the new column's enter policy, and
|
|
3
|
+
# emits the transition events (§3, §11). Controllers and future automations
|
|
4
|
+
# all call this — never Card#update(column:) directly.
|
|
5
|
+
class CardTransition
|
|
6
|
+
Result = Data.define(:success?, :card, :error)
|
|
7
|
+
|
|
8
|
+
def initialize(card, to_column:, position: nil, actor: "user")
|
|
9
|
+
@card = card
|
|
10
|
+
@from = card.column
|
|
11
|
+
@to = to_column
|
|
12
|
+
@position = position
|
|
13
|
+
@actor = actor
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
return reposition! if @from == @to
|
|
18
|
+
return failure("Column belongs to a different board") if @to.board_id != @card.board_id
|
|
19
|
+
if @card.working? && @from.execution?
|
|
20
|
+
# An agent process is live — no silent kills (§3). Cancel it first.
|
|
21
|
+
return failure("##{@card.number} has an active run — cancel it before moving the card")
|
|
22
|
+
end
|
|
23
|
+
# Accept policy (card #15): the destination decides which columns may feed
|
|
24
|
+
# it, forcing cards through a defined workflow rather than any-to-any drops.
|
|
25
|
+
return rejected! unless @to.accepts?(@from)
|
|
26
|
+
|
|
27
|
+
Card.transaction do
|
|
28
|
+
leave_policy!
|
|
29
|
+
place_in_column!
|
|
30
|
+
enter_policy!
|
|
31
|
+
end
|
|
32
|
+
Result.new(success?: true, card: @card, error: nil)
|
|
33
|
+
rescue ActiveRecord::RecordInvalid => e
|
|
34
|
+
failure(e.message)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
# Same-column drag = prioritization (§8): top of the column runs first, so
|
|
40
|
+
# reordering queued cards IS the priority UI. No policies fire, no events.
|
|
41
|
+
def reposition!
|
|
42
|
+
ids = @to.cards.where.not(id: @card.id).order(:position).pluck(:id)
|
|
43
|
+
ids.insert([@position || ids.size, ids.size].min, @card.id)
|
|
44
|
+
Card.transaction do
|
|
45
|
+
ids.each_with_index { |id, index| Card.where(id: id).update_all(position: index) }
|
|
46
|
+
@card.touch # update_all skips callbacks; touch broadcasts to other windows
|
|
47
|
+
end
|
|
48
|
+
Result.new(success?: true, card: @card.reload, error: nil)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def leave_policy!
|
|
52
|
+
return unless @from.execution?
|
|
53
|
+
# Dequeue / abandon parked runs — nothing live is killed (working cards
|
|
54
|
+
# were already blocked above).
|
|
55
|
+
@card.runs.where(status: %w[queued needs_input]).each do |run|
|
|
56
|
+
run.update!(status: "cancelled", finished_at: Time.current)
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def place_in_column!
|
|
61
|
+
# Column arrivals policy: force where newcomers land, regardless of the
|
|
62
|
+
# drop position. (Reordering within the column stays free-form.)
|
|
63
|
+
case @to.arrivals
|
|
64
|
+
when "top" then @position = 0
|
|
65
|
+
when "bottom" then @position = nil
|
|
66
|
+
end
|
|
67
|
+
@position ||= (@to.cards.maximum(:position) || -1) + 1
|
|
68
|
+
@to.cards.where("position >= ?", @position).update_all("position = position + 1")
|
|
69
|
+
@card.update!(column: @to, position: @position, status: entry_status)
|
|
70
|
+
@card.log!("column_move", actor: @actor,
|
|
71
|
+
from: @from.name, to: @to.name, text: "Moved from #{@from.name} to #{@to.name}")
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
def enter_policy!
|
|
75
|
+
Rules.fire_entry(@card, @to)
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def entry_status
|
|
79
|
+
case @to.archetype
|
|
80
|
+
when "inbox" then "draft"
|
|
81
|
+
when "planning" then "discussing"
|
|
82
|
+
when "execution" then "queued"
|
|
83
|
+
when "review" then "in_review"
|
|
84
|
+
when "terminal" then "done"
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
# A drop the destination's accept policy forbids: nothing moves, but the
|
|
89
|
+
# attempt is logged to the card's timeline so the bounce isn't silent.
|
|
90
|
+
def rejected!
|
|
91
|
+
@card.log!("move_rejected", actor: @actor, from: @from.name, to: @to.name,
|
|
92
|
+
text: "Blocked: #{@from.name} can't move directly to #{@to.name}")
|
|
93
|
+
failure("#{@from.name} cannot move directly to #{@to.name}")
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def failure(message) = Result.new(success?: false, card: @card, error: message)
|
|
97
|
+
end
|