openlanes 3.0.0 → 3.0.1

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 (67) hide show
  1. checksums.yaml +4 -4
  2. data/agents/c3po-operator.md +58 -0
  3. data/lib/openlanes/adapters/base.rb +7 -0
  4. data/lib/openlanes/adapters/claude.rb +21 -2
  5. data/lib/openlanes/adapters.rb +19 -2
  6. data/lib/openlanes/cli/run/run.rb +47 -15
  7. data/lib/openlanes/cli/start/start.rb +1 -14
  8. data/lib/openlanes/cli/web/web.rb +11 -1
  9. data/lib/openlanes/engine/dispatcher.rb +62 -7
  10. data/lib/openlanes/engine/narrator.rb +107 -0
  11. data/lib/openlanes/engine/progress_log.rb +50 -9
  12. data/lib/openlanes/execution/prompt.rb +13 -0
  13. data/lib/openlanes/lifecycle/creator.rb +24 -0
  14. data/lib/openlanes/live/chat.rb +616 -0
  15. data/lib/openlanes/live/commands.rb +4 -1
  16. data/lib/openlanes/live/copilot/brief.rb +128 -0
  17. data/lib/openlanes/live/copilot/cards.rb +226 -0
  18. data/lib/openlanes/live/copilot/operator.rb +259 -0
  19. data/lib/openlanes/live/copilot.rb +256 -0
  20. data/lib/openlanes/live/keys.rb +58 -0
  21. data/lib/openlanes/live/memory_store.rb +24 -0
  22. data/lib/openlanes/live/memory_subscriber.rb +40 -0
  23. data/lib/openlanes/live/session.rb +7 -1
  24. data/lib/openlanes/live/stations.rb +9 -5
  25. data/lib/openlanes/live/store.rb +18 -0
  26. data/lib/openlanes/live/subscriber.rb +73 -0
  27. data/lib/openlanes/live.rb +5 -0
  28. data/lib/openlanes/presentation/reporter.rb +2 -2
  29. data/lib/openlanes/support/ui.rb +75 -1
  30. data/lib/openlanes/vcs/publisher.rb +62 -0
  31. data/lib/openlanes/vcs/worktree.rb +44 -9
  32. data/lib/openlanes/version.rb +1 -1
  33. data/lib/openlanes/web/app.rb +100 -1
  34. data/lib/openlanes/web/context.rb +20 -2
  35. data/lib/openlanes/web/demo.rb +10 -0
  36. data/lib/openlanes/web/events.rb +51 -2
  37. data/lib/openlanes/web/launcher.rb +1 -1
  38. data/lib/openlanes/web/server.rb +2 -0
  39. data/lib/openlanes/web/stores.rb +11 -0
  40. data/lib/openlanes/web.rb +13 -0
  41. data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
  42. data/web/dist/assets/{index-BdR34cXC.js → index-6MZkmh3k.js} +2 -2
  43. data/web/dist/assets/{index-uM88Njdj.js → index-B5CKJPm-.js} +2 -2
  44. data/web/dist/assets/{index-DB4QwuYn.js → index-BF6QYroq.js} +2 -2
  45. data/web/dist/assets/{index-Bp7W-ZTq.js → index-BFPI-zXI.js} +2 -2
  46. data/web/dist/assets/{index-CI74IkWP.js → index-BYJCWlk0.js} +49 -49
  47. data/web/dist/assets/{index-popk9wbW.js → index-C2jwoWVV.js} +2 -2
  48. data/web/dist/assets/{index-CYxcbpRM.js → index-C3zc4gFe.js} +2 -2
  49. data/web/dist/assets/index-CjInBi5X.css +1 -0
  50. data/web/dist/assets/{index-2xPC-eHt.js → index-CnJfB0l2.js} +2 -2
  51. data/web/dist/assets/{index-P3vSkiU-.js → index-CnvkZu5Y.js} +2 -2
  52. data/web/dist/assets/{index-OR-odsl9.js → index-DH0gDlee.js} +2 -2
  53. data/web/dist/assets/{index-D0rmJN3h.js → index-DXgeajd6.js} +2 -2
  54. data/web/dist/assets/{index-C0VmkrO-.js → index-DaSjQ-H4.js} +2 -2
  55. data/web/dist/assets/{index-B7-O9ujQ.js → index-DbueqOK5.js} +2 -2
  56. data/web/dist/assets/{index-DDI7zoAS.js → index-Dbxvk-EC.js} +2 -2
  57. data/web/dist/assets/{index-8dNhRmiO.js → index-DtA46i32.js} +2 -2
  58. data/web/dist/assets/{index-DGLUuEXi.js → index-IqD8mBos.js} +2 -2
  59. data/web/dist/assets/{index-Byyxbh_z.js → index-bpsFIpXE.js} +2 -2
  60. data/web/dist/assets/{index-mRV4XhJZ.js → index-pvJi-JJO.js} +2 -2
  61. data/web/dist/assets/logo-DXDCUiD8.png +0 -0
  62. data/web/dist/index.html +3 -3
  63. metadata +31 -23
  64. data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
  65. data/web/dist/assets/index-a4EHR-u4.css +0 -1
  66. data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
  67. data/web/dist/assets/logo-on-light-BciwqxcJ.png +0 -0
@@ -0,0 +1,128 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Live
5
+ class Copilot
6
+ # What c3po-operator is told with each message: the board as it stands,
7
+ # where to read further, and the person's words.
8
+ #
9
+ # The board lives in Redis, which the agent cannot read: its Bash runs
10
+ # only `openlanes` commands that read state, and none of them prints the
11
+ # live board. So the server reads the snapshot and writes it in here,
12
+ # fresh with every message, since the run moves between two questions.
13
+ # It is written as lines rather than the snapshot's JSON because the
14
+ # agent pays for every token of it on every message.
15
+ class Brief
16
+ # Recent log lines carried.
17
+ LOG_LINES = 20
18
+
19
+ # Characters of a question's body carried; the agent reads the rest
20
+ # from blocked.md when it needs it.
21
+ BODY = 400
22
+
23
+ # @param text [String] what the person typed
24
+ # @param board [Hash, nil] this node's `Snapshot` as parsed JSON
25
+ # @param others [Array<Hash>] other machines' snapshots
26
+ # @param plans_dir [String]
27
+ # @param trace_dir [String]
28
+ def initialize(text:, board:, plans_dir:, trace_dir:, others: [])
29
+ @text = text
30
+ @board = board
31
+ @others = others
32
+ @plans_dir = plans_dir
33
+ @trace_dir = trace_dir
34
+ end
35
+
36
+ # @return [String]
37
+ def to_s
38
+ [board, others, where, "## The person says\n\n#{@text}"].compact.join("\n\n")
39
+ end
40
+
41
+ private
42
+
43
+ # @return [String]
44
+ def board
45
+ return "## The board\n\nNo engine is publishing a board for this checkout: no run is going, or it has ended." unless @board
46
+
47
+ run = @board["run"] || {}
48
+ lines = ["## The board at #{@board["at"]}",
49
+ "",
50
+ "Run #{run["status"]}, #{run["agents_working"].to_i} agents working, ↑ #{run["up"].to_i} ↓ #{run["down"].to_i} tokens, " \
51
+ "$#{format("%.2f", run["cost"].to_f)}."]
52
+ plans = Array(@board["plans"])
53
+ lines << (plans.empty? ? "No plans on this machine's board." : "")
54
+ plans.each { lines.concat(plan(it)) }
55
+ backlog = Array(@board["backlog"])
56
+ lines << "Backlog: #{backlog.map { "#{it["ordinal"]} #{it["title"]} (#{it.dig("status", "label")})" }.join("; ")}" unless backlog.empty?
57
+ lines.concat(log)
58
+ lines.join("\n")
59
+ end
60
+
61
+ # @param plan [Hash]
62
+ # @return [Array<String>]
63
+ def plan(plan)
64
+ lines = ["- #{plan["ordinal"]} #{plan["title"]}: #{plan.dig("status", "emoji")} #{plan.dig("status", "label")}, " \
65
+ "#{plan["lane"]} lane, #{minutes(plan["elapsed"])} worked, $#{format("%.2f", plan["cost"].to_f)}" \
66
+ "#{", PR ##{plan.dig("pr", "number")} #{plan.dig("pr", "state")}" if plan["pr"]}"]
67
+ Array(plan["cars"]).each { lines << car(it) }
68
+ Array(plan["questions"]).each { lines << question(it) }
69
+ if (stop = plan["interrupted"])
70
+ lines << " - interrupted: #{stop["agent"]} at #{stop["at"]}: #{stop["note"]}"
71
+ end
72
+ lines
73
+ end
74
+
75
+ # @param car [Hash]
76
+ # @return [String]
77
+ def car(car)
78
+ " - car #{car["key"]}: #{car["state"]}#{", #{car["phase"]}" if car["phase"]}, round #{car["round"]}/#{car["rounds"]}, " \
79
+ "#{car["model"]}, ↑ #{car["up"].to_i} ↓ #{car["down"].to_i}, #{minutes(car["elapsed"])} elapsed" \
80
+ "#{", #{minutes(car["remaining"])} left" if car["remaining"]}#{", held" if car["held"]}: #{car["message"].to_s.inspect}"
81
+ end
82
+
83
+ # @param question [Hash]
84
+ # @return [String]
85
+ def question(question)
86
+ body = question["body"].to_s.gsub(/\s+/, " ").strip
87
+ body = "#{body[0, BODY]}…" if body.length > BODY
88
+ " - question #{question["id"]} (#{question["owner"]}, asked by #{question["agent"] || "unknown"}" \
89
+ "#{", held" if question["held"]}): #{question["heading"]}: #{body}"
90
+ end
91
+
92
+ # @return [Array<String>]
93
+ def log
94
+ lines = Array(@board["log"]).last(LOG_LINES)
95
+ return [] if lines.empty?
96
+
97
+ ["", "Recent log, newest last:"] + lines.map { " #{it["at"]} #{it["source"]} #{it["level"]}: #{it["text"]}" }
98
+ end
99
+
100
+ # @return [String, nil]
101
+ def others
102
+ return nil if @others.empty?
103
+
104
+ machines = @others.map do |snapshot|
105
+ node = snapshot["node"] || {}
106
+ plans = Array(snapshot["plans"]).map { "#{it["ordinal"]} #{it.dig("status", "label")}" }
107
+ "- #{node["user"]}@#{node["host"]}: #{plans.empty? ? "no plans" : plans.join(", ")}"
108
+ end
109
+ "## Other machines (read only)\n\n#{machines.join("\n")}"
110
+ end
111
+
112
+ # @return [String]
113
+ def where
114
+ <<~TEXT.strip
115
+ ## Where to read more
116
+
117
+ - Plan folders: #{@plans_dir}. Each holds spec.md, plan.md, blocked.md when blocked, and state.json with its signatures and mailbox.
118
+ - Agents' transcripts: #{@trace_dir}, one NDJSON file per invocation, named by time, plan and agent.
119
+ TEXT
120
+ end
121
+
122
+ # @param seconds [Numeric, nil]
123
+ # @return [String]
124
+ def minutes(seconds) = "#{(seconds.to_f / 60).round}m"
125
+ end
126
+ end
127
+ end
128
+ end
@@ -0,0 +1,226 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ class Copilot
8
+ # How what c3po-operator wrote becomes the turns the page draws.
9
+ #
10
+ # The agent writes prose, and wherever it wants the page to draw a card
11
+ # it writes one fenced block whose info string is `c3po`, holding one
12
+ # JSON object:
13
+ #
14
+ # ```c3po
15
+ # {"card": "confirm", "text": "luke has not moved in 20 minutes.",
16
+ # "command": {"kind": "stop", "plan": "002", "agent": "luke-backend"},
17
+ # "confirm": "Stop luke-backend", "done": "luke-backend was asked to stop"}
18
+ # ```
19
+ #
20
+ # ```c3po
21
+ # {"card": "draft", "question": "002/B1", "draft": "Ninety days.", "text": "A draft:"}
22
+ # ```
23
+ #
24
+ # The agent proposes; this module decides. A card is drawn only when
25
+ # every check passes: the JSON parses, the card is one of the two kinds,
26
+ # a confirm card's command is one of {CONFIRMABLE} and one the command
27
+ # gate itself would take ({Command.parse}, W13), and what it names is on
28
+ # this node's board. Anything else comes back as plain text with the
29
+ # reason, so a card the agent got wrong is something the person reads,
30
+ # never something the page can send. Nothing here sends anything: even a
31
+ # good card only reaches the engine when the person presses its button.
32
+ module Cards
33
+ # A card: a fence opened with exactly `c3po` and closed on a line of its
34
+ # own. An unclosed one does not match, and so stays prose.
35
+ FENCE = /^```c3po[ \t]*\r?\n(.*?)^```[ \t]*$/m
36
+
37
+ # The commands a confirm card may carry: W17's "stop, discard and stop
38
+ # run", the ones that cannot be undone and so wait for a person.
39
+ # Message and hold are left out on purpose: the engine files a
40
+ # `message` in the plan's mailbox as from `human`, and a card the
41
+ # person confirms without reading would put the copilot's words there
42
+ # under the person's name.
43
+ CONFIRMABLE = %w[stop discard stop_all].freeze
44
+
45
+ # Cards one answer may offer. More is a model listing every action it
46
+ # can think of, and a wall of red buttons is not a question.
47
+ MAX = 3
48
+
49
+ # Characters a confirm button's label may hold.
50
+ LABEL = 60
51
+
52
+ # Characters the line a confirm card shows once accepted may hold.
53
+ DONE = 200
54
+
55
+ # Characters one prose turn keeps. A model that pastes a whole
56
+ # transcript back is cut here rather than sent to every open page.
57
+ PROSE = 20_000
58
+
59
+ # A question's id, as the board writes it: `<ordinal>/B<n>`.
60
+ QUESTION = %r{\A[0-9]{3}(?:\.[0-9]{2})?/B[1-9][0-9]*\z}
61
+
62
+ # What an answer with nothing in it reads as.
63
+ SILENT = "c3po-operator finished without saying anything."
64
+
65
+ module_function
66
+
67
+ # @param answer [String, nil] the agent's final message
68
+ # @param id [String] the person's message id; the turns are `<id>.1`, `<id>.2`, …
69
+ # @param usage [Hash, nil] `{model, up, down}`, carried by every turn
70
+ # @param board [Hash, nil] this node's `Snapshot` as parsed JSON; nil
71
+ # when no engine is publishing one, and then no card names anything
72
+ # @return [Array<OpenLanes::Live::Copilot::Turn>]
73
+ def turns(answer, id:, usage: nil, board: nil)
74
+ offered = 0
75
+ said = pieces(answer.to_s).filter_map do |kind, body|
76
+ next prose(body) if kind == :prose
77
+
78
+ offered += 1
79
+ offered > MAX ? refused(body, "an answer offers at most #{MAX} cards") : card(body, board)
80
+ end
81
+ said = [{ kind: "text", text: SILENT }] if said.empty?
82
+ said.each_with_index.map do |piece, index|
83
+ Turn.new(id: "#{id}.#{index + 1}", from: "copilot", usage:, fields: {}, **piece)
84
+ end
85
+ end
86
+
87
+ # The answer cut into prose and card bodies, in the order written.
88
+ #
89
+ # @param answer [String]
90
+ # @return [Array<Array(Symbol, String)>]
91
+ def pieces(answer)
92
+ found = []
93
+ at = 0
94
+ while (match = FENCE.match(answer, at))
95
+ found << [:prose, answer[at...match.begin(0)]] << [:card, match[1]]
96
+ at = match.end(0)
97
+ end
98
+ found << [:prose, answer[at..]]
99
+ end
100
+
101
+ # @param body [String]
102
+ # @return [Hash, nil] nil for whitespace between cards
103
+ def prose(body)
104
+ text = body.strip
105
+ return nil if text.empty?
106
+
107
+ { kind: "text", text: text.length > PROSE ? "#{text[0, PROSE]}…" : text }
108
+ end
109
+
110
+ # @param body [String] the JSON between the fences
111
+ # @param board [Hash, nil]
112
+ # @return [Hash] a card, or the text it degrades to
113
+ def card(body, board)
114
+ data = JSON.parse(body)
115
+ raise Invalid, "a card is one JSON object" unless data.is_a?(Hash)
116
+
117
+ data = data.transform_keys(&:to_s)
118
+ case data["card"]
119
+ when "confirm" then confirm(data, board)
120
+ when "draft" then draft(data, board)
121
+ else raise Invalid, "#{data["card"].inspect} is not a card; there are confirm and draft"
122
+ end
123
+ rescue JSON::ParserError
124
+ refused(body, "it is not JSON")
125
+ rescue Invalid, Command::Invalid => e
126
+ refused(body, e.message)
127
+ end
128
+
129
+ # @param data [Hash]
130
+ # @param board [Hash, nil]
131
+ # @return [Hash]
132
+ # @raise [OpenLanes::Live::Copilot::Invalid, OpenLanes::Live::Command::Invalid]
133
+ def confirm(data, board)
134
+ raw = data["command"]
135
+ raise Invalid, "a confirm card needs a command object" unless raw.is_a?(Hash)
136
+
137
+ raw = raw.transform_keys(&:to_s)
138
+ kind = raw["kind"].to_s
139
+ raise Invalid, "#{kind.inspect} is not a command c3po-operator may offer; it offers #{CONFIRMABLE.join(", ")}" unless CONFIRMABLE.include?(kind)
140
+
141
+ # The payload is dropped: none of these kinds reads one, and a card
142
+ # carries exactly what its button says and nothing the person cannot see.
143
+ command = Command.parse(raw.slice("kind", "plan", "agent").merge("id" => "card", "payload" => {}))
144
+ on_board!(command, board)
145
+ { kind: "confirm",
146
+ text: words(data["text"], LIMIT) || "This needs you to confirm it.",
147
+ fields: { "command" => command.wire.except(:id).transform_keys(&:to_s),
148
+ "confirm" => words(data["confirm"], LABEL) || raise(Invalid, "a confirm card needs its button's label"),
149
+ "done" => words(data["done"], DONE) || raise(Invalid, "a confirm card needs what it says once done") } }
150
+ end
151
+
152
+ # @param data [Hash]
153
+ # @param board [Hash, nil]
154
+ # @return [Hash]
155
+ # @raise [OpenLanes::Live::Copilot::Invalid]
156
+ def draft(data, board)
157
+ question = data["question"].to_s
158
+ raise Invalid, "#{question.inspect} is not a question id like 002/B1" unless question.match?(QUESTION)
159
+
160
+ # The board writes ids in full (`002.00/B1`); the agent may write `002/B1`.
161
+ plan, number = question.split("/")
162
+ question = "#{ordinal(plan)}/#{number}"
163
+ raise Invalid, "no question #{question} is waiting on this board" unless questions(board).include?(question)
164
+
165
+ draft = words(data["draft"], LIMIT) or raise Invalid, "a draft card needs the draft"
166
+ { kind: "draft", text: words(data["text"], LIMIT) || "A draft answer to #{question}.",
167
+ fields: { "question" => question, "draft" => draft } }
168
+ end
169
+
170
+ # The engine refuses a command for a plan or a car it does not have,
171
+ # but only once the person has pressed the button. Checking here keeps
172
+ # a card for something that is not running off the page altogether.
173
+ #
174
+ # @param command [OpenLanes::Live::Command]
175
+ # @param board [Hash, nil]
176
+ # @return [void]
177
+ # @raise [OpenLanes::Live::Copilot::Invalid]
178
+ def on_board!(command, board)
179
+ raise Invalid, "no run is publishing a board here, so there is nothing to act on" if board.nil?
180
+ return unless Command::PLAN_KINDS.include?(command.kind)
181
+
182
+ plan = Array(board["plans"]).find { ordinal(it["ordinal"]) == ordinal(command.plan) }
183
+ raise Invalid, "plan #{command.plan} is not on this board" unless plan
184
+ return unless Command::AGENT_KINDS.include?(command.kind)
185
+ return if Array(plan["cars"]).any? { it["agent"] == command.agent }
186
+
187
+ raise Invalid, "#{command.agent} is not working #{command.plan}"
188
+ end
189
+
190
+ # @param board [Hash, nil]
191
+ # @return [Array<String>] every question id waiting on the board
192
+ def questions(board) = Array(board&.dig("plans")).flat_map { Array(it["questions"]) }.map { it["id"].to_s }
193
+
194
+ # @param value [Object]
195
+ # @return [String, nil]
196
+ def ordinal(value) = Plans::Ordinal.parse(value.to_s)&.to_s || value.to_s
197
+
198
+ # @param value [Object]
199
+ # @param limit [Integer]
200
+ # @return [String, nil] stripped, nil when empty
201
+ # @raise [OpenLanes::Live::Copilot::Invalid] when longer than +limit+
202
+ def words(value, limit)
203
+ return nil unless value.is_a?(String)
204
+
205
+ text = value.strip
206
+ return nil if text.empty?
207
+ raise Invalid, "#{text[0, 24].inspect}… is longer than #{limit} characters" if text.length > limit
208
+
209
+ text
210
+ end
211
+
212
+ # What a card the gate would not take becomes: its body as text, with
213
+ # the reason, so the person sees what was meant and nothing is sent.
214
+ #
215
+ # @param body [String]
216
+ # @param reason [String]
217
+ # @return [Hash]
218
+ def refused(body, reason)
219
+ { kind: "text",
220
+ text: "I wrote a card the control room does not accept (#{reason}), so nothing was offered. " \
221
+ "What I wrote:\n\n#{body.strip[0, PROSE]}" }
222
+ end
223
+ end
224
+ end
225
+ end
226
+ end
@@ -0,0 +1,259 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module OpenLanes
6
+ module Live
7
+ class Copilot
8
+ # c3po-operator itself (W17): the responder the API server hands
9
+ # {Copilot}. Each message the person sends becomes one `claude -p`,
10
+ # resumed from the conversation's session, so the agent remembers what
11
+ # was said before; it answers in prose and `c3po` cards, which {Cards}
12
+ # turns into the turns the page draws.
13
+ #
14
+ # It runs in the API server because that is where the conversation
15
+ # already arrives and is stored: the server takes the message, answers
16
+ # it, and writes the answer to the stream the page follows. It needs no
17
+ # engine, so it also answers under `openlanes web` alone, after a run
18
+ # has ended. The server calls it through {Copilot#answer} on its
19
+ # background path ({Web::Context#background}), apart from the request,
20
+ # and on that thread's own Redis connection; a failure that escapes
21
+ # here is answered for by {Copilot#answer}. Messages are answered one
22
+ # at a time, so two `claude` processes never resume one session at once.
23
+ #
24
+ # The boundary is the plan agents' boundary, narrowed. The tools are
25
+ # fixed to {TOOLS} and the Bash commands to the agent's `allowed_tools`,
26
+ # the read-only `openlanes` queries; {Execution::Executor#denied_for}
27
+ # adds the network tools and every command that leaves the machine, and
28
+ # {WRITE_TOOLS} goes on top. And it acts on nothing: a card is a
29
+ # proposal, sent only when the person presses its button.
30
+ class Operator
31
+ # The agent's definition, in `agents/`.
32
+ NAME = "c3po-operator"
33
+
34
+ # The only built-in tools it has: reading files, and Bash for the
35
+ # `openlanes` queries its definition allows.
36
+ TOOLS = %w[Read Grep Glob Bash].freeze
37
+
38
+ # Denied as well, should a CLI or a settings file offer them anyway:
39
+ # it never edits code or plan documents (W17), and a sub-agent would
40
+ # start with tools of its own.
41
+ WRITE_TOOLS = %w[Edit Write MultiEdit NotebookEdit Task Agent].freeze
42
+
43
+ # What `claude -p --resume` prints, on stderr, for a session it does not have.
44
+ MISSING = /No conversation found with session ID/
45
+
46
+ # Seconds an answer may take when the definition names none.
47
+ TIMEOUT = 180
48
+
49
+ # @param root [String] the checkout
50
+ # @param plans_dir [String]
51
+ # @param stores [#current] a store per thread; the answering thread
52
+ # takes its own, which the server's background path releases
53
+ # @param keys [OpenLanes::Live::Keys]
54
+ # @param agent [OpenLanes::Agents::Agent]
55
+ # @param spawn [Proc] `argv, chdir:, env:` → {Execution::Child}; the suite's seam
56
+ # @param trace_dir [String] where each answer's raw stream is kept, and
57
+ # where the agents' transcripts are read from
58
+ def initialize(root:, plans_dir:, stores:, keys:, agent: Agents.find(NAME), spawn: Execution::Child.method(:spawn),
59
+ trace_dir: Execution::Executor::TRACE_DIR)
60
+ raise OpenLanes::Error, "no agent definition named #{NAME}" unless agent
61
+
62
+ @root = File.expand_path(root)
63
+ @plans_dir = plans_dir
64
+ @stores = stores
65
+ @keys = keys
66
+ @agent = agent
67
+ @spawn = spawn
68
+ @trace_dir = trace_dir
69
+ @mutex = Mutex.new
70
+ @turns = Mutex.new
71
+ @child = nil
72
+ @stopped = false
73
+ end
74
+
75
+ # Answer one message, the responder's side of {Copilot#answer}: run
76
+ # the agent, keep its session and what it spent, and turn what it
77
+ # said into turns. A run that fails is answered as text saying why; a
78
+ # message that waits while another is answered waits its turn.
79
+ #
80
+ # @param turn [OpenLanes::Live::Copilot::Turn] the person's
81
+ # @return [Array<OpenLanes::Live::Copilot::Turn>]
82
+ # @raise [OpenLanes::Error] when the server is stopping
83
+ def call(turn) = @turns.synchronize { answer(turn) }
84
+
85
+ # @param turn [OpenLanes::Live::Copilot::Turn]
86
+ # @return [Array<OpenLanes::Live::Copilot::Turn>]
87
+ def answer(turn)
88
+ raise OpenLanes::Error, "the server is stopping" if @stopped
89
+
90
+ board = Live.board(store:, keys: @keys)
91
+ brief = Brief.new(text: turn.text, board: board[:me], others: board[:others], plans_dir: @plans_dir, trace_dir: @trace_dir)
92
+ record = session
93
+ run = invoke(brief.to_s, record["session"])
94
+ # A session `claude` no longer has (its history was cleared, or the
95
+ # conversation was started on another machine) fails at once; the
96
+ # conversation starts again rather than staying broken.
97
+ if stale?(run, record)
98
+ store.hdel(@keys.copilot_session, "session")
99
+ run = invoke(brief.to_s, nil)
100
+ end
101
+ usage = spend(run, record)
102
+ return [failure(turn, run, usage)] unless run.ok
103
+
104
+ Cards.turns(run.transcript.result, id: turn.id, usage:, board: board[:me])
105
+ end
106
+
107
+ # Stop answering: the message under way is cut short, and any still
108
+ # waiting is answered with why. The server calls it on its way down.
109
+ #
110
+ # @return [void]
111
+ def stop
112
+ child = @mutex.synchronize do
113
+ @stopped = true
114
+ @child
115
+ end
116
+ child&.terminate(grace: 1, drain: 1)
117
+ end
118
+
119
+ # The argv one answer runs, exposed so a spec can check the boundary
120
+ # without starting anything.
121
+ #
122
+ # @param prompt [String]
123
+ # @param session [String, nil] the conversation to carry on
124
+ # @return [Array<String>]
125
+ def argv(prompt, session)
126
+ profile.adapter.argv(
127
+ Adapters::Invocation.new(prompt:,
128
+ root: @root,
129
+ model: profile.model,
130
+ effort: profile.effort,
131
+ allowed_tools: @agent.allowed_tools,
132
+ denied_tools: executor.denied_for(@agent) + WRITE_TOOLS,
133
+ denied_commands: executor.denied_commands(@agent),
134
+ network: @agent.network,
135
+ resume: (session if profile.adapter.resumes?),
136
+ dirs: [@trace_dir],
137
+ brief: false,
138
+ tools: TOOLS,
139
+ system: @agent.prompt)
140
+ )
141
+ end
142
+
143
+ # @return [OpenLanes::Agents::Profile]
144
+ def profile = @profile ||= Agents::Profile.resolve(@agent)
145
+
146
+ private
147
+
148
+ # What one `claude -p` did.
149
+ Run = Data.define(:ok, :transcript, :note)
150
+
151
+ # @return [OpenLanes::Live::Store, OpenLanes::Live::MemoryStore] this thread's
152
+ def store = @stores.current
153
+
154
+ # @param prompt [String]
155
+ # @param session [String, nil]
156
+ # @return [Run]
157
+ def invoke(prompt, session)
158
+ FileUtils.mkdir_p(@trace_dir)
159
+ trace = File.join(@trace_dir,
160
+ format("%s-copilot-%s-%d-%04x.ndjson",
161
+ Time.now.strftime("%Y%m%d-%H%M%S"),
162
+ NAME,
163
+ Process.pid,
164
+ rand(0x10000)))
165
+ transcript = profile.adapter.transcript(trace:)
166
+ child = @spawn.call(argv(prompt, session), chdir: @root, env: profile.adapter.environment)
167
+ # {#stop} may have run while this was starting, and seen no child to end.
168
+ stopping = @mutex.synchronize do
169
+ @child = child
170
+ @stopped
171
+ end
172
+ child.terminate(grace: 1, drain: 1) if stopping
173
+ status = watched(child) { child.each_chunk { transcript.push(it) } }
174
+ transcript.finish
175
+ Run.new(ok: status.success? && !transcript.failed? && !@timed_out, transcript:, note: note(status, transcript, trace))
176
+ ensure
177
+ transcript&.finish
178
+ @mutex.synchronize { @child = nil }
179
+ end
180
+
181
+ # Wait for the child, ending it once {#timeout} has passed. The
182
+ # answer is advisory; a copilot that hangs must not hold the next
183
+ # message for ever.
184
+ #
185
+ # @param child [OpenLanes::Execution::Child]
186
+ # @return [Process::Status]
187
+ def watched(child)
188
+ @timed_out = false
189
+ watchdog = Thread.new do
190
+ sleep(timeout)
191
+ @timed_out = true
192
+ child.terminate
193
+ end
194
+ yield
195
+ child.wait
196
+ ensure
197
+ watchdog&.kill
198
+ end
199
+
200
+ # @return [Boolean] whether `claude` said it has no such session, on one
201
+ # it was asked to resume; any other failure is reported, not retried
202
+ def stale?(run, record)
203
+ !run.ok && record["session"] && [run.transcript.error, *run.transcript.plain].any? { it.to_s.match?(MISSING) }
204
+ end
205
+
206
+ # @return [Integer] seconds
207
+ def timeout = @agent.timeout || TIMEOUT
208
+
209
+ # @return [String] why an answer failed, for the person
210
+ def note(status, transcript, trace)
211
+ return "it took longer than #{timeout} seconds" if @timed_out
212
+ return "#{profile.adapter.executable} reported: #{transcript.error}" if transcript.failed?
213
+ return "#{profile.adapter.executable} exited #{status.exitstatus}: #{transcript.plain.last(3).join(" ")}" unless status.success?
214
+
215
+ "trace: #{trace}"
216
+ end
217
+
218
+ # The session, the model and the running total, kept in Redis
219
+ # ({Keys#copilot_session}) so the next message, from this server or
220
+ # one started after it, carries on the same conversation.
221
+ #
222
+ # @param run [Run]
223
+ # @param record [Hash{String => String}]
224
+ # @return [Hash] `{model, up, down}`
225
+ def spend(run, record)
226
+ usage = { model: model, up: record["up"].to_i + run.transcript.up, down: record["down"].to_i + run.transcript.down }
227
+ key = @keys.copilot_session
228
+ store.hset(key, "session", run.transcript.session) if run.transcript.session
229
+ store.hset(key, "model", usage[:model].to_s)
230
+ store.hset(key, "up", usage[:up])
231
+ store.hset(key, "down", usage[:down])
232
+ usage
233
+ end
234
+
235
+ # @return [Hash{String => String}]
236
+ def session = store.hgetall(@keys.copilot_session)
237
+
238
+ # The full id the header shows, as a car's does.
239
+ #
240
+ # @return [String, nil]
241
+ def model = profile.adapter.model_entry(profile.model)&.id || profile.model
242
+
243
+ # @return [OpenLanes::Live::Copilot::Turn]
244
+ def failure(turn, run, usage)
245
+ Turn.new(id: "#{turn.id}.1",
246
+ from: "copilot",
247
+ kind: "text",
248
+ usage:,
249
+ text: "c3po-operator could not answer: #{run.note}. Ask again, or look at the trace.")
250
+ end
251
+
252
+ # For its boundary only: who may run what, as plan agents are told.
253
+ #
254
+ # @return [OpenLanes::Execution::Executor]
255
+ def executor = @executor ||= Execution::Executor.new(root: @root)
256
+ end
257
+ end
258
+ end
259
+ end