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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a7658c5f19c6cee3a0fdbf6b199439cbd0bf94a5c4c62380909ee389d3ed3592
4
- data.tar.gz: 0c58d76a809b55ec3cbf6be45083bde5971d2fa76f0b3cc891a8f60467a8808a
3
+ metadata.gz: b480713b45345b2c7477e65702505d70f4da9cbcfe1a099f8125d9333d827ee8
4
+ data.tar.gz: 49c1328895393365f5b4a197e9c0ea7819e79a68757769aa3ce8660851ef78bc
5
5
  SHA512:
6
- metadata.gz: ad1b3ea0ab150357c0b20e8e19db6c5a930d65d7405854dea60e4b59272c224bacab5ea238be2efe09cd19ddf4ff0b52619e15daa5387bf87876bb77f2ada76f
7
- data.tar.gz: f733b50907984ac3fa0228e8815cd8cf04d610625451dbf6022ca26c1eff3cfc6ceb6dc659aca181b2af2a7d7b2552a3b0fd8ffd919134681654a8c505af1c64
6
+ metadata.gz: aba10c8c3eb5a3c955014e9b2a262204f9917d3c9eace688a69ef8c10be11a68546866cbb3ba0b2c4f49ff88f682728bafb771bd72db5730b372f02b1eb617d8
7
+ data.tar.gz: 94cdc0e5cae5136c799cbd41f473cd268f0de018057d8f49e681ef334893c91941d3ad9aa7c948e51e43ebc4784b7e060089ce099c9d448e004cd94dcc317293
@@ -0,0 +1,58 @@
1
+ ---
2
+ name: c3po-operator
3
+ description: The control room's copilot. Talks to the person about the run, drafts answers to blocked agents, and proposes stops for the person to confirm; edits nothing and works no plan.
4
+ handles: []
5
+ model: haiku
6
+ effort: low
7
+ timeout: 180
8
+ allowed_tools:
9
+ - Read
10
+ - Grep
11
+ - Glob
12
+ - "Bash(openlanes list-plans:*)"
13
+ - "Bash(openlanes status:*)"
14
+ - "Bash(openlanes state show:*)"
15
+ - "Bash(openlanes mail read:*)"
16
+ - "Bash(openlanes agents list:*)"
17
+ - "Bash(openlanes agents describe:*)"
18
+ - "Bash(openlanes describe:*)"
19
+ - "Bash(openlanes states:*)"
20
+ ---
21
+
22
+ You are c3po-operator, the copilot in the openlanes control room. A person watching a run asks you about it, and you answer in a small chat panel. You work no plan: `openlanes run` never hands you one.
23
+
24
+ ## What you know
25
+
26
+ Every message arrives with the board as it stands: each plan on this machine, its state, the agents working it (cars), what they spent, the questions waiting on a person, and the recent log. Trust it over anything you remember from earlier in the conversation, because the run moves between two messages.
27
+
28
+ Read further when the board does not answer the question:
29
+
30
+ - the plan folders it names: `spec.md`, `plan.md`, `blocked.md`, and `state.json` with the signatures and the mailbox;
31
+ - the agents' transcripts in the trace directory it names, one NDJSON file per invocation, named by time, plan and agent. They are large: `Grep` them for what you need rather than reading them whole;
32
+ - the read-only `openlanes` commands: `list-plans`, `state show --plan NNN`, `mail read --plan NNN --for <agent>`, `agents list`, `describe <agent>`, `states`.
33
+
34
+ You have nothing else. You cannot edit a file, run any other command, or reach the network, and you never try to: changing code and plan documents is the other agents' job.
35
+
36
+ ## How you answer
37
+
38
+ Short, plain and specific. The panel is narrow; three short paragraphs is a long answer. Name plans by number and agents by name. Say what the evidence is ("luke-backend's last tool call was 14 minutes ago, an `rspec` run"). When you do not know, say so and say what would tell you.
39
+
40
+ ## Cards
41
+
42
+ You never act. When the person should act, you write a card, and the page draws a button the person presses or does not. A card is a fenced block whose info string is exactly `c3po`, holding one JSON object, and nothing else:
43
+
44
+ ```c3po
45
+ {"card": "confirm", "text": "luke-backend has made no progress in 20 minutes.", "command": {"kind": "stop", "plan": "002", "agent": "luke-backend"}, "confirm": "Stop luke-backend", "done": "luke-backend was asked to stop."}
46
+ ```
47
+
48
+ ```c3po
49
+ {"card": "draft", "question": "002/B1", "text": "A draft from what spec.md already says:", "draft": "Ninety days from the cart's last change."}
50
+ ```
51
+
52
+ - `confirm` proposes a command that cannot be undone. `command.kind` is one of `stop` (one agent: needs `plan` and `agent`), `discard` (a whole plan's work in progress: needs `plan`) or `stop_all` (the whole run). `confirm` is the button's label, at most 60 characters; `done` is what the card says once the engine has accepted it.
53
+ - `draft` drafts an answer to a question waiting on the board. `question` is its id exactly as the board writes it (`002/B1`). The page puts the draft in the answer box; the person edits and sends it.
54
+ - `text` is the line above the card. Prose outside the cards is shown as it is.
55
+
56
+ A drafted answer to a waiting question is always a `draft` card, never prose: the card is what puts it in the answer box. A proposal to stop or discard is always a `confirm` card. Offer a card only for a plan, agent or question that is on the board now, at most three in one answer, and only when the person asked for it or it plainly helps. A card the control room cannot take is shown to the person as plain text, and nothing is sent.
57
+
58
+ Nothing else is a card. To message an agent, hold it, extend its time, answer a question or change its model, tell the person where to do it on the page (the car's drawer, the question's answer box) and draft the words if that helps.
@@ -58,6 +58,13 @@ module OpenLanes
58
58
  # carried on in the same session
59
59
  def compacts? = !compact_prompt.nil?
60
60
 
61
+ # Whether {#argv} honours {Invocation#resume}. The copilot (W17) keeps
62
+ # one conversation across messages only on a CLI that says yes; on any
63
+ # other each message starts afresh, and says so.
64
+ #
65
+ # @return [Boolean]
66
+ def resumes? = false
67
+
61
68
  # Whether the CLI itself refuses the denied tools, rather than only being
62
69
  # asked not to use them. The prompt says which, because telling an agent
63
70
  # a guard exists when it does not is how it stops being careful.
@@ -64,10 +64,14 @@ module OpenLanes
64
64
  # it the stream reports a settled input count and a placeholder output
65
65
  # count — 2 for a four-thousand-token answer — and a spinner counting
66
66
  # what came back would read zero all run.
67
- argv = ["claude", "-p", invocation.prompt, "--add-dir", invocation.root,
68
- "--output-format", "stream-json", "--verbose", "--include-partial-messages", "--brief"]
67
+ argv = ["claude", "-p", invocation.prompt, "--add-dir", invocation.root]
68
+ invocation.dirs.each { argv += ["--add-dir", it] }
69
+ argv += ["--output-format", "stream-json", "--verbose", "--include-partial-messages"]
70
+ argv << "--brief" if invocation.brief
69
71
  argv += ["--resume", invocation.resume] if invocation.resume
70
72
  argv += LEAN.reject { invocation.slash_commands && it == SLASH_COMMANDS_OFF } if invocation.lean
73
+ argv += restricted(invocation.tools) if invocation.tools
74
+ argv += ["--append-system-prompt", invocation.system] if invocation.system
71
75
  argv += ["--disallowedTools", invocation.denied_tools.join(",")] unless invocation.denied_tools.empty?
72
76
  argv += ["--allowedTools", invocation.allowed_tools.join(",")] unless invocation.allowed_tools.empty?
73
77
  model = model(invocation.model)
@@ -79,6 +83,18 @@ module OpenLanes
79
83
  # @return [OpenLanes::Execution::Transcript]
80
84
  def transcript(trace: nil, &) = Execution::Transcript.new(trace:, &)
81
85
 
86
+ # A fixed tool set. `--tools` takes every other built-in tool away, and
87
+ # `--restricted` stops the project's and the operator's settings files
88
+ # from granting more: an allow rule in `.claude/settings.json` would
89
+ # otherwise widen what `--allowedTools` permits, and `--disallowedTools`
90
+ # only removes what somebody thought to name. `dontAsk` refuses whatever
91
+ # the allow list does not cover, rather than waiting on a prompt that
92
+ # nobody under `-p` can answer.
93
+ #
94
+ # @param tools [Array<String>]
95
+ # @return [Array<String>]
96
+ def restricted(tools) = ["--restricted", "--tools", tools.join(","), "--permission-mode", "dontAsk"]
97
+
82
98
  # @param model [String, nil] an alias (`sonnet`) or a full id
83
99
  # (`claude-fable-5-1`)
84
100
  # @return [String, nil]
@@ -92,6 +108,9 @@ module OpenLanes
92
108
  # @return [Boolean]
93
109
  def enforces_tool_denial? = true
94
110
 
111
+ # @return [Boolean] `--resume <session id>` carries a conversation on
112
+ def resumes? = true
113
+
95
114
  # `claude -p --resume <id> "/compact"` compacts the session and prints a
96
115
  # `compact_boundary` event; the agent cannot run it on itself, because
97
116
  # `/compact` is the caller's command, not a tool.
@@ -44,6 +44,18 @@ module OpenLanes
44
44
  # @!attribute [r] slash_commands
45
45
  # @return [Boolean] keep the CLI's slash commands even when lean, which
46
46
  # only the `/compact` sent into a resumed session needs
47
+ # @!attribute [r] dirs
48
+ # @return [Array<String>] directories beyond {#root} the agent may read,
49
+ # such as the trace directory the copilot reads transcripts from
50
+ # @!attribute [r] brief
51
+ # @return [Boolean] whether the agent reports through the CLI's
52
+ # one-line status tool, which only a car on the board has a use for
53
+ # @!attribute [r] tools
54
+ # @return [Array<String>, nil] the only built-in tools the agent has, with
55
+ # no settings file able to add one; nil leaves the CLI's own set
56
+ # @!attribute [r] system
57
+ # @return [String, nil] standing instructions, sent as system prompt on
58
+ # every invocation so a compacted conversation does not lose them
47
59
  Invocation = Data.define(:prompt,
48
60
  :root,
49
61
  :model,
@@ -54,9 +66,14 @@ module OpenLanes
54
66
  :network,
55
67
  :lean,
56
68
  :resume,
57
- :slash_commands) do
69
+ :slash_commands,
70
+ :dirs,
71
+ :brief,
72
+ :tools,
73
+ :system) do
58
74
  def initialize(allowed_tools: [], denied_tools: [], denied_commands: [], network: false, lean: true,
59
- model: nil, effort: nil, resume: nil, slash_commands: false, **rest)
75
+ model: nil, effort: nil, resume: nil, slash_commands: false, dirs: [], brief: true, tools: nil, system: nil,
76
+ **rest)
60
77
  super
61
78
  end
62
79
  end
@@ -120,9 +120,8 @@ module OpenLanes
120
120
  # whether a round prints anything at all) is read the whole time the
121
121
  # loop runs, not just when `report` prints its closing summary.
122
122
  quiet?(options)
123
- UI.log_path = options[:log] ||
124
- File.join(Dir.tmpdir, "openlanes-#{File.basename(root)}.log")
125
- info("Progress: #{UI.log_path}") unless quiet?(options)
123
+ UI.log_path = options[:log] || Engine::ProgressLog.default_path(root)
124
+ startup("Progress", UI.log_path)
126
125
  credentials_warning if !dry_run?(options) && !quiet?(options)
127
126
  spec_warning(tree, plans) unless quiet?(options)
128
127
 
@@ -165,11 +164,18 @@ module OpenLanes
165
164
  claims: live&.claims,
166
165
  **staying(options, web)
167
166
  )
167
+ startup_box(options)
168
168
 
169
+ # The terminal hears the run as it happens, one appended line per
170
+ # event, whether or not a control room is up: the page is the board,
171
+ # and the terminal is its log. Nothing here redraws, so nothing can
172
+ # draw over a box or over another line.
173
+ narrator = UI.tap_log(Engine::Narrator.new) unless quiet?(options)
169
174
  started = UI.monotonic
170
175
  attempts = begin
171
176
  Execution.on_interrupt(signals:) { runner.call { |dispatcher| live&.attach(dispatcher) } }
172
177
  ensure
178
+ UI.untap_log(narrator) if narrator
173
179
  live&.close
174
180
  web&.stop
175
181
  end
@@ -190,7 +196,7 @@ module OpenLanes
190
196
  def staying(options, web)
191
197
  return {} if web.nil? || options[:until_settled]
192
198
 
193
- info("The engine stays up with its control room: Stop All Local or Ctrl-C ends it.") unless quiet?(options)
199
+ @stays = "The engine stays up with its control room: Stop All Local or Ctrl-C ends it."
194
200
  { stay: true, rescan: Engine::Dispatcher::RESCAN }
195
201
  end
196
202
 
@@ -268,7 +274,7 @@ module OpenLanes
268
274
  config = Config.current
269
275
  store = Live.connect(Live.redis_url(option: options[:redis_url], config:))
270
276
  Live.session(store:, root:, tree:, agents:, project: config.project).tap do |session|
271
- info("Live board: #{session.describe}") unless quiet?(options)
277
+ startup("Live board", session.described)
272
278
  end
273
279
  rescue Live::Unavailable => e
274
280
  without_live(e.message, options)
@@ -297,11 +303,31 @@ module OpenLanes
297
303
  return without_web(web, live, options)
298
304
  end
299
305
 
300
- info("Control room: #{web.url}") unless quiet?(options)
306
+ startup("Control room", web.url)
301
307
  OpenLanes::Web.open(browser_url(web, tree)) unless options[:open] == false
302
308
  web
303
309
  end
304
310
 
311
+ # One line of the startup box, kept until {#startup_box} draws it.
312
+ #
313
+ # @param label [String]
314
+ # @param value [String, Array<String>] parts a narrow box may break between
315
+ # @return [void]
316
+ def startup(label, value) = (@startup ||= []) << [label, value]
317
+
318
+ # Where the run writes, what it publishes to, and where its page is:
319
+ # one Info box, drawn once all three are known. Each used to be a box
320
+ # of its own, three frames for three lines. A line that does not apply
321
+ # (no Redis, no control room) is left out, not drawn empty.
322
+ #
323
+ # @param options [Hash]
324
+ # @return [void]
325
+ def startup_box(options)
326
+ return if quiet?(options) || @startup.to_a.empty?
327
+
328
+ info([UI.labelled(@startup), @stays].compact.join("\n\n"))
329
+ end
330
+
305
331
  # The attempts list, padded into columns.
306
332
  #
307
333
  # Tabs used to separate these, which put every column on the next
@@ -461,6 +487,20 @@ module OpenLanes
461
487
  # @return [Array<OpenLanes::Engine::Runner::Attempt>]
462
488
  def failures(attempts) = attempts.reject(&:ok)
463
489
 
490
+ # The attempts and the bill, one blank line before each part that has
491
+ # anything in it. `puts []` prints an empty line, so a run with no
492
+ # attempts used to open its report with two blank lines and a third
493
+ # before the tally.
494
+ #
495
+ # @param attempts [Array<OpenLanes::Engine::Runner::Attempt>]
496
+ # @param tally [String, nil] nil prints none
497
+ # @return [void]
498
+ def puts_report(attempts, tally)
499
+ lines = attempt_lines(attempts)
500
+ puts("", *lines) unless lines.empty?
501
+ puts("", tally) if tally
502
+ end
503
+
464
504
  # The one thing this harness does that leaves the machine — pushing a
465
505
  # branch and opening its pull request — so it takes one more thing to
466
506
  # turn on than everything else here: not being a dry run is not enough,
@@ -497,18 +537,10 @@ module OpenLanes
497
537
  # @param seconds [Float] wall clock for the whole loop
498
538
  # @return [void]
499
539
  def report(runner, attempts, options, seconds: 0.0)
500
- # A leading blank line, because the spinners this replaces write to
501
- # STDERR and the last of them may have left the cursor mid-line:
502
- # without it the first attempt starts wherever that line stopped,
503
- # indented by however much it had drawn.
504
- puts ""
505
- puts attempt_lines(attempts)
506
-
507
540
  # What the run cost, on STDOUT with the attempts it belongs to, so a run
508
541
  # redirected to a file keeps its bill. A dry run spent nothing and gets
509
542
  # none of this.
510
- tally = Engine.tally(attempts:, seconds:)
511
- puts("", tally.render) unless dry_run?(options)
543
+ puts_report(attempts, (Engine.tally(attempts:, seconds:).render unless dry_run?(options)))
512
544
 
513
545
  return if quiet?(options)
514
546
 
@@ -36,24 +36,13 @@ module OpenLanes
36
36
  # Exit status when Redis or the control room is not there to serve.
37
37
  UNAVAILABLE = 69
38
38
 
39
- # The progress log, echoed to the terminal: the terminal is where a
40
- # person watches `start` from when the page is closed, and it takes
41
- # no input.
42
- #
43
39
  # @param options [Hash]
44
40
  # @return [void]
45
41
  def call(**options)
46
42
  @rescan = Integer(options.fetch(:rescan, Engine::Dispatcher::RESCAN), exception: false)
47
43
  refuse("--rescan takes a whole number of seconds, at least 1.", 64) unless @rescan&.positive?
48
44
 
49
- tap = UI.tap_log(lambda { |message, fields|
50
- next if UI.quiet
51
-
52
- Kernel.warn(Engine::ProgressLog.render(message, **fields.slice(:plan, :status, :agent, :seconds, :round)))
53
- })
54
45
  super
55
- ensure
56
- UI.untap_log(tap) if tap
57
46
  end
58
47
 
59
48
  private
@@ -93,9 +82,7 @@ module OpenLanes
93
82
  #
94
83
  # @return [void]
95
84
  def finish(runner, attempts, options, seconds:)
96
- puts ""
97
- puts attempt_lines(attempts)
98
- puts("", Engine.tally(attempts:, seconds:).render)
85
+ puts_report(attempts, Engine.tally(attempts:, seconds:).render)
99
86
  unless quiet?(options)
100
87
  blocked = runner.blocked.map { it.feature.ordinal }
101
88
  lines = ["Stopped. Unfinished plans keep their state; openlanes start picks them up again."]
@@ -41,11 +41,13 @@ module OpenLanes
41
41
  keys = Live.keys(root:, node:)
42
42
  keys = keys.with(repo: "demo/#{keys.repo}") if options[:demo]
43
43
  demo = OpenLanes::Web.demo(store: stores.current, keys:).start if options[:demo]
44
- context = OpenLanes::Web.context(stores:, keys:, plans_dir: tree.dir, url: stores.current.url, node:, demo:)
44
+ copilot = demo ? OpenLanes::Web::Demo::COPILOT : copilot_for(root:, tree:, stores:, keys:)
45
+ context = OpenLanes::Web.context(stores:, keys:, plans_dir: tree.dir, url: stores.current.url, node:, demo:, copilot:)
45
46
  server = OpenLanes::Web.server(app: OpenLanes::Web.app(context), context:, port: options[:port])
46
47
  serve(server, options, keys:, node:, url: stores.current.url)
47
48
  ensure
48
49
  demo&.stop
50
+ copilot.stop if copilot.respond_to?(:stop)
49
51
  end
50
52
 
51
53
  private
@@ -76,6 +78,14 @@ module OpenLanes
76
78
  OpenLanes::Web::Stores.shared(Live.memory_store)
77
79
  end
78
80
 
81
+ # c3po-operator, answering in this server (W17). It runs beside the
82
+ # page rather than in the engine so it answers with no run going too.
83
+ #
84
+ # @return [OpenLanes::Live::Copilot::Operator]
85
+ def copilot_for(root:, tree:, stores:, keys:)
86
+ Live::Copilot::Operator.new(root:, plans_dir: tree.dir, stores:, keys:)
87
+ end
88
+
79
89
  # Listen until INT or TERM.
80
90
  #
81
91
  # @return [void]
@@ -703,14 +703,21 @@ module OpenLanes
703
703
  def next_dispatchable
704
704
  reconcile
705
705
  @runner.in_scope.each do |subject|
706
+ working = @running.select { plan_of(it) == subject.feature.ordinal.to_s }.map { it.task.agent }
706
707
  # After {#reconcile} this is the folder's name. On a dry run, which
707
- # renames nothing, it is what the name would have become.
708
- state = Lifecycle::Lanes.target(subject) || Lifecycle::Resync::Dirs.target(subject)
708
+ # renames nothing, it is what the name would have become. A plan an
709
+ # agent is in right now is never reconciled, so its contents are
710
+ # not asked: see {#partners_only}.
711
+ state = if working.empty?
712
+ Lifecycle::Lanes.target(subject) || Lifecycle::Resync::Dirs.target(subject)
713
+ else
714
+ subject.status
715
+ end
709
716
  next if Plans::StateMachine::SETTLED.include?(state.key)
710
717
  next if @finished_plans.include?(subject.feature.ordinal.to_s)
711
718
  next if @runner.foreign?(subject) || @discarding.key?(subject.feature.ordinal.to_s)
712
719
 
713
- candidates = Agents::Routing.filter(@runner.agents.for_status(state), subject)
720
+ candidates = partners_only(Agents::Routing.filter(@runner.agents.for_status(state), subject), working, state)
714
721
  preferred = @preferred[subject.feature.ordinal.to_s]
715
722
  candidates = candidates.sort_by { |a| a.name == preferred ? 0 : 1 } if preferred
716
723
  agent = candidates.find { |a| eligible?(a, subject) }
@@ -719,6 +726,30 @@ module OpenLanes
719
726
  nil
720
727
  end
721
728
 
729
+ # One agent at a time on a plan, except a pair working the same state.
730
+ #
731
+ # A plan with an agent in it is judged by its folder's name, never by
732
+ # what its contents would justify. Asking the contents is how the
733
+ # minesweeper trial ran its whole chain inside ⚪️ New: leah wrote her
734
+ # `## Research` chapter, the next tick saw a Researched plan and started
735
+ # yoda beside her, and leah's own Completed then found a partner running
736
+ # and held the folder where it was. Every later agent repeated it, so
737
+ # the folder never moved and luke and rey were building on a plan whose
738
+ # name still said nobody had researched it.
739
+ #
740
+ # @param candidates [Array<OpenLanes::Agents::Agent>]
741
+ # @param working [Array<OpenLanes::Agents::Agent>] already on the plan
742
+ # @param state [OpenLanes::Plans::Status] the folder's state
743
+ # @return [Array<OpenLanes::Agents::Agent>]
744
+ def partners_only(candidates, working, state)
745
+ return candidates if working.empty?
746
+
747
+ team = @runner.agents.team_for(state).map(&:name)
748
+ return [] unless working.all? { team.include?(it.name) }
749
+
750
+ candidates.select { team.include?(it.name) }
751
+ end
752
+
722
753
  # A folder's name can lag its contents: a run killed before its rename,
723
754
  # a plan.md written by hand, a resync nobody ran. So the names are
724
755
  # reconciled before every dispatch, the way `resync dirs` would, and a
@@ -889,7 +920,11 @@ module OpenLanes
889
920
  remaining: nil,
890
921
  phase: nil,
891
922
  bold: !attempt.ok) }
892
- UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}", **job.task.log_fields)
923
+ # `to:` is for the terminal's line; the log file keeps its columns.
924
+ moved = Plans::STATUS_BY_KEY[attempt.to]&.to_s if attempt.advanced?
925
+ UI.log(attempt.ok ? "finished: #{attempt.note}" : "failed: #{attempt.note}",
926
+ **job.task.log_fields,
927
+ **{ to: moved }.compact)
893
928
  end
894
929
  end
895
930
 
@@ -926,11 +961,15 @@ module OpenLanes
926
961
  end
927
962
  attempt = attempt.with(note: "#{attempt.note}; #{problems.join(", ")}") unless problems.empty?
928
963
  attempt = attempt.with(to: fresh(task)&.status&.key || attempt.to)
964
+ # One outcome, written to both records. The engine's state file once
965
+ # defaulted a failed round to Completed while state.json said
966
+ # Interrupted, so the page and the plan disagreed about the same round.
967
+ outcome = attempt.status || (attempt.ok ? "Completed" : "Interrupted")
929
968
  if (settled = fresh(task))
930
969
  stage!(settled,
931
970
  agent,
932
971
  task.round,
933
- status: attempt.status || (attempt.ok ? "Completed" : "Interrupted"),
972
+ status: outcome,
934
973
  to: attempt.to.to_s,
935
974
  finished_at: Time.now.iso8601,
936
975
  up: attempt.up.to_i,
@@ -941,7 +980,7 @@ module OpenLanes
941
980
  @state&.record(ordinal,
942
981
  agent: agent.name,
943
982
  round: task.round,
944
- status: attempt.status || "Completed",
983
+ status: outcome,
945
984
  exit: attempt.ok ? "ok" : attempt.note,
946
985
  ended_at: Time.now.iso8601,
947
986
  up: attempt.up,
@@ -1063,7 +1102,7 @@ module OpenLanes
1063
1102
 
1064
1103
  attempt = published.attempt
1065
1104
  subject = published.subject
1066
- return attempt.with(ok: false, note: "ledger says Completed but #{Plans::STATUS_BY_KEY.fetch(target)} is not justified: #{Plans::STATUS_BY_KEY.fetch(target).violation(subject)}") unless subject.machine.may?(target)
1105
+ return unjustified(job, attempt, subject, target) unless subject.machine.may?(target)
1067
1106
 
1068
1107
  subject.machine.promote!(target)
1069
1108
  attempt = attempt.with(to: target)
@@ -1074,6 +1113,22 @@ module OpenLanes
1074
1113
  attempt
1075
1114
  end
1076
1115
 
1116
+ # The agent signed Completed, but what it left does not justify the
1117
+ # state it advances to. The folder stays, its `next:` is not honoured,
1118
+ # and the round is recorded as Interrupted, which is what the page
1119
+ # lists under Needs you once the agent's rounds run out. Taking the
1120
+ # signature at its word instead parked the plan: nobody else handles
1121
+ # the state it is still in, and its own agent counted itself done.
1122
+ #
1123
+ # @return [OpenLanes::Engine::Runner::Attempt]
1124
+ def unjustified(job, attempt, subject, target)
1125
+ status = Plans::STATUS_BY_KEY.fetch(target)
1126
+ remember(plan_of(job), job.task.agent.name, job.state, "Interrupted", job.task.round)
1127
+ attempt.with(ok: false,
1128
+ status: "Interrupted",
1129
+ note: "ledger says Completed but #{status} is not justified: #{status.violation(subject)}")
1130
+ end
1131
+
1077
1132
  # A build finished in a run that opens no pull request: 🟢 needs one,
1078
1133
  # so the folder cannot move, and failing the attempt would only start
1079
1134
  # the builder again on work that is done. Only the missing pull request
@@ -0,0 +1,107 @@
1
+ # frozen_string_literal: true
2
+
3
+ module OpenLanes
4
+ module Engine
5
+ # What a run says on the terminal while it works: one plain line per
6
+ # event, appended, never redrawn.
7
+ #
8
+ # It is a {UI.tap_log} tap, so it hears exactly what the progress log
9
+ # file hears (an agent started or finished, a plan changed state, a
10
+ # command came from the page, the reason the engine idles) and nothing
11
+ # else. The file keeps its fixed columns for `awk`; the terminal gets
12
+ # only the fields a line actually has. Echoing the file's format there
13
+ # is what drew an idle line as a row of empty columns,
14
+ # `[13:39:10 | | … | 83054 | ] idle: …`.
15
+ #
16
+ # Nothing here moves the cursor or colours anything. A line is written
17
+ # whole, under a lock, with any carriage return or escape sequence in any
18
+ # field (an agent's note is free text, a page command's plan is unchecked)
19
+ # taken out first, so two threads logging at once
20
+ # cannot splice their lines, and no message can rewrite the one above.
21
+ # The same lines serve a terminal, a pipe and a CI log alike.
22
+ #
23
+ # @example
24
+ # narrator = Narrator.new
25
+ # UI.tap_log(narrator)
26
+ # UI.log("started", plan: "003.00", status: "⭐️ Planned", agent: "yoda-writer", round: "01")
27
+ # # 13:39:10 003.00 ⭐️ Planned yoda-writer r01 started
28
+ class Narrator
29
+ # Between the parts of a line. Two spaces read as columns without
30
+ # holding open the ones a line does not have.
31
+ GAP = " "
32
+
33
+ # A carriage return, an escape sequence, or any other control
34
+ # character but a tab: what would let one message overwrite another.
35
+ CONTROL = %r{\e\[[0-9;?]*[ -/]*[@-~]|[\x00-\x08\x0a-\x1f\x7f]}
36
+
37
+ # @param stream [#puts, nil] where the lines go; nil means whatever
38
+ # `$stderr` is when a line is written, so a spec swapping it is heard
39
+ # @param clock [Proc] wall time, injectable for a fixed timestamp
40
+ def initialize(stream: nil, clock: -> { Time.now })
41
+ @stream = stream
42
+ @clock = clock
43
+ @lock = Mutex.new
44
+ @last = nil
45
+ end
46
+
47
+ # The tap itself. A line identical to the previous one is dropped, so
48
+ # whatever repeats itself tick after tick (an idle reason, a store
49
+ # that keeps refusing) is said once per change rather than once a
50
+ # second.
51
+ #
52
+ # @param message [String]
53
+ # @param fields [Hash] the log line's fields: plan, status, agent, round,
54
+ # and `to:` when the event moved the plan
55
+ # @return [void]
56
+ def call(message, fields = {})
57
+ text = render(message, **fields)
58
+ @lock.synchronize do
59
+ next if text == @last
60
+
61
+ @last = text
62
+ # Unpainted, so a terminal gets the same bytes as a pipe or a CI log.
63
+ (@stream || $stderr).puts("#{@clock.call.strftime(ProgressLog::TIME_FORMAT)}#{GAP}#{text}")
64
+ end
65
+ rescue StandardError
66
+ # A tap runs on the logging thread and must never take it down;
67
+ # a terminal that went away is no reason to stop the agents.
68
+ nil
69
+ end
70
+
71
+ # @return [Proc] for {UI.tap_log}
72
+ def to_proc = method(:call).to_proc
73
+
74
+ # One event, without its timestamp.
75
+ #
76
+ # @param message [String]
77
+ # @param plan [String, nil]
78
+ # @param status [String, nil] emoji and label
79
+ # @param agent [String, nil]
80
+ # @param round [String, nil] e.g. "01"
81
+ # @param to [String, nil] the state the plan moved to, emoji and label
82
+ # @return [String] e.g. `003.00 ⭐️ Planned yoda-writer r01 finished → 🟡 Building`
83
+ def render(message, plan: nil, status: nil, agent: nil, round: nil, to: nil, **)
84
+ # Every field, not just the message: a page command's plan and agent
85
+ # are logged before anything checks that they name real ones.
86
+ plan, status, agent, round = [plan, status, agent, round].map { clean(it) if it }
87
+ said = clean(message)
88
+ said = "#{said} → #{self.class.status_cell(clean(to))}" if to
89
+ [[plan, (self.class.status_cell(status) if status)].compact.join(" "), [agent, ("r#{round}" if round)].compact.join(" "), said]
90
+ .reject(&:empty?).join(GAP)
91
+ end
92
+
93
+ # The emoji fitted to two cells and followed by two spaces, then the
94
+ # label, exactly as the progress log draws it.
95
+ #
96
+ # @param status [String] emoji and label, e.g. "⭐️ Planned"
97
+ # @return [String] e.g. "⭐️ Planned"
98
+ def self.status_cell(status) = ProgressLog.status_cell(status).to_s
99
+
100
+ private
101
+
102
+ # @param text [Object]
103
+ # @return [String] on one line, with nothing that moves the cursor
104
+ def clean(text) = text.to_s.gsub(CONTROL, " ").squeeze(" ").strip
105
+ end
106
+ end
107
+ end