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.
- checksums.yaml +4 -4
- data/agents/c3po-operator.md +58 -0
- data/lib/openlanes/adapters/base.rb +7 -0
- data/lib/openlanes/adapters/claude.rb +21 -2
- data/lib/openlanes/adapters.rb +19 -2
- data/lib/openlanes/cli/run/run.rb +47 -15
- data/lib/openlanes/cli/start/start.rb +1 -14
- data/lib/openlanes/cli/web/web.rb +11 -1
- data/lib/openlanes/engine/dispatcher.rb +62 -7
- data/lib/openlanes/engine/narrator.rb +107 -0
- data/lib/openlanes/engine/progress_log.rb +50 -9
- data/lib/openlanes/execution/prompt.rb +13 -0
- data/lib/openlanes/lifecycle/creator.rb +24 -0
- data/lib/openlanes/live/chat.rb +616 -0
- data/lib/openlanes/live/commands.rb +4 -1
- data/lib/openlanes/live/copilot/brief.rb +128 -0
- data/lib/openlanes/live/copilot/cards.rb +226 -0
- data/lib/openlanes/live/copilot/operator.rb +259 -0
- data/lib/openlanes/live/copilot.rb +256 -0
- data/lib/openlanes/live/keys.rb +58 -0
- data/lib/openlanes/live/memory_store.rb +24 -0
- data/lib/openlanes/live/memory_subscriber.rb +40 -0
- data/lib/openlanes/live/session.rb +7 -1
- data/lib/openlanes/live/stations.rb +9 -5
- data/lib/openlanes/live/store.rb +18 -0
- data/lib/openlanes/live/subscriber.rb +73 -0
- data/lib/openlanes/live.rb +5 -0
- data/lib/openlanes/presentation/reporter.rb +2 -2
- data/lib/openlanes/support/ui.rb +75 -1
- data/lib/openlanes/vcs/publisher.rb +62 -0
- data/lib/openlanes/vcs/worktree.rb +44 -9
- data/lib/openlanes/version.rb +1 -1
- data/lib/openlanes/web/app.rb +100 -1
- data/lib/openlanes/web/context.rb +20 -2
- data/lib/openlanes/web/demo.rb +10 -0
- data/lib/openlanes/web/events.rb +51 -2
- data/lib/openlanes/web/launcher.rb +1 -1
- data/lib/openlanes/web/server.rb +2 -0
- data/lib/openlanes/web/stores.rb +11 -0
- data/lib/openlanes/web.rb +13 -0
- data/web/dist/assets/favicon-BFJEKKAE.png +0 -0
- data/web/dist/assets/{index-BdR34cXC.js → index-6MZkmh3k.js} +2 -2
- data/web/dist/assets/{index-uM88Njdj.js → index-B5CKJPm-.js} +2 -2
- data/web/dist/assets/{index-DB4QwuYn.js → index-BF6QYroq.js} +2 -2
- data/web/dist/assets/{index-Bp7W-ZTq.js → index-BFPI-zXI.js} +2 -2
- data/web/dist/assets/{index-CI74IkWP.js → index-BYJCWlk0.js} +49 -49
- data/web/dist/assets/{index-popk9wbW.js → index-C2jwoWVV.js} +2 -2
- data/web/dist/assets/{index-CYxcbpRM.js → index-C3zc4gFe.js} +2 -2
- data/web/dist/assets/index-CjInBi5X.css +1 -0
- data/web/dist/assets/{index-2xPC-eHt.js → index-CnJfB0l2.js} +2 -2
- data/web/dist/assets/{index-P3vSkiU-.js → index-CnvkZu5Y.js} +2 -2
- data/web/dist/assets/{index-OR-odsl9.js → index-DH0gDlee.js} +2 -2
- data/web/dist/assets/{index-D0rmJN3h.js → index-DXgeajd6.js} +2 -2
- data/web/dist/assets/{index-C0VmkrO-.js → index-DaSjQ-H4.js} +2 -2
- data/web/dist/assets/{index-B7-O9ujQ.js → index-DbueqOK5.js} +2 -2
- data/web/dist/assets/{index-DDI7zoAS.js → index-Dbxvk-EC.js} +2 -2
- data/web/dist/assets/{index-8dNhRmiO.js → index-DtA46i32.js} +2 -2
- data/web/dist/assets/{index-DGLUuEXi.js → index-IqD8mBos.js} +2 -2
- data/web/dist/assets/{index-Byyxbh_z.js → index-bpsFIpXE.js} +2 -2
- data/web/dist/assets/{index-mRV4XhJZ.js → index-pvJi-JJO.js} +2 -2
- data/web/dist/assets/logo-DXDCUiD8.png +0 -0
- data/web/dist/index.html +3 -3
- metadata +31 -23
- data/web/dist/assets/favicon-C8pAuqqr.png +0 -0
- data/web/dist/assets/index-a4EHR-u4.css +0 -1
- data/web/dist/assets/logo-on-dark-DI9gCdN4.png +0 -0
- 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:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b480713b45345b2c7477e65702505d70f4da9cbcfe1a099f8125d9333d827ee8
|
|
4
|
+
data.tar.gz: 49c1328895393365f5b4a197e9c0ea7819e79a68757769aa3ce8660851ef78bc
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
-
|
|
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.
|
data/lib/openlanes/adapters.rb
CHANGED
|
@@ -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
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|