agentilda 2.0.1 → 2.0.4
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/lib/agentilda/agents/agent.rb +79 -0
- data/lib/agentilda/agents/registry.rb +121 -0
- data/lib/agentilda/agents/roster.rb +92 -0
- data/lib/agentilda/agents.rb +11 -113
- data/lib/agentilda/cli/agents/subcommands/describe.rb +3 -3
- data/lib/agentilda/cli/agents/subcommands/list.rb +1 -1
- data/lib/agentilda/cli/base.rb +3 -3
- data/lib/agentilda/cli/create/create.rb +17 -18
- data/lib/agentilda/cli/docs/docs.rb +1 -1
- data/lib/agentilda/cli/index/index.rb +1 -1
- data/lib/agentilda/cli/linear/subcommands/import.rb +1 -1
- data/lib/agentilda/cli/list_plans/list_plans.rb +1 -1
- data/lib/agentilda/cli/mail/mail.rb +4 -4
- data/lib/agentilda/cli/mail/subcommands/send.rb +1 -1
- data/lib/agentilda/cli/resync/subcommands/dirs.rb +2 -2
- data/lib/agentilda/cli/resync/subcommands/prs.rb +3 -3
- data/lib/agentilda/cli/run/run.rb +40 -40
- data/lib/agentilda/cli/states/states.rb +1 -1
- data/lib/agentilda/cli/unblock/unblock.rb +17 -17
- data/lib/agentilda/cli/worktree/worktree.rb +4 -4
- data/lib/agentilda/cli.rb +1 -1
- data/lib/agentilda/engine/board.rb +93 -0
- data/lib/agentilda/engine/dispatcher.rb +646 -0
- data/lib/agentilda/engine/progress_log.rb +125 -0
- data/lib/agentilda/engine/runner.rb +141 -0
- data/lib/agentilda/engine/state_file.rb +176 -0
- data/lib/agentilda/engine/tally.rb +177 -0
- data/lib/agentilda/engine.rb +19 -0
- data/lib/agentilda/execution/child.rb +94 -0
- data/lib/agentilda/execution/clock.rb +116 -0
- data/lib/agentilda/execution/control.rb +180 -0
- data/lib/agentilda/execution/executor.rb +699 -0
- data/lib/agentilda/execution/transcript.rb +468 -0
- data/lib/agentilda/execution.rb +18 -0
- data/lib/agentilda/lifecycle/adoption.rb +197 -0
- data/lib/agentilda/lifecycle/brief.rb +285 -0
- data/lib/agentilda/lifecycle/creator.rb +125 -0
- data/lib/agentilda/lifecycle/resync.rb +428 -0
- data/lib/agentilda/lifecycle/unblocker.rb +223 -0
- data/lib/agentilda/lifecycle.rb +28 -0
- data/lib/agentilda/linear/api.rb +1 -1
- data/lib/agentilda/linear/attribution.rb +7 -7
- data/lib/agentilda/linear/import.rb +18 -18
- data/lib/agentilda/linear/mapping.rb +6 -6
- data/lib/agentilda/linear/push.rb +5 -5
- data/lib/agentilda/linear/survey.rb +7 -7
- data/lib/agentilda/linear/unit.rb +5 -5
- data/lib/agentilda/plans/dev_work.rb +56 -0
- data/lib/agentilda/plans/feature.rb +131 -0
- data/lib/agentilda/plans/index.rb +208 -0
- data/lib/agentilda/plans/ledger.rb +235 -0
- data/lib/agentilda/plans/mailbox.rb +143 -0
- data/lib/agentilda/plans/ordinal.rb +92 -0
- data/lib/agentilda/plans/pull_request.rb +29 -0
- data/lib/agentilda/plans/pull_requests.rb +229 -0
- data/lib/agentilda/plans/state_machine.rb +383 -0
- data/lib/agentilda/plans/status.rb +365 -0
- data/lib/agentilda/plans/subject.rb +131 -0
- data/lib/agentilda/plans/tree.rb +79 -0
- data/lib/agentilda/plans.rb +39 -0
- data/lib/agentilda/presentation/console.rb +159 -0
- data/lib/agentilda/presentation/dashboard.rb +221 -0
- data/lib/agentilda/presentation/diagram.rb +149 -0
- data/lib/agentilda/presentation/documentation.rb +439 -0
- data/lib/agentilda/presentation/keyboard.rb +158 -0
- data/lib/agentilda/presentation/reporter.rb +181 -0
- data/lib/agentilda/presentation/screen/ratatui/bar.rb +59 -0
- data/lib/agentilda/presentation/screen/ratatui/key_translator.rb +31 -0
- data/lib/agentilda/presentation/screen/ratatui.rb +320 -0
- data/lib/agentilda/presentation/viewer.rb +62 -0
- data/lib/agentilda/presentation.rb +23 -0
- data/lib/agentilda/{ui.rb → support/ui.rb} +5 -5
- data/lib/agentilda/vcs/github.rb +170 -0
- data/lib/agentilda/vcs/publisher.rb +177 -0
- data/lib/agentilda/vcs/worktree.rb +209 -0
- data/lib/agentilda/vcs.rb +19 -0
- data/lib/agentilda/version.rb +1 -1
- data/lib/agentilda.rb +6 -5
- metadata +56 -49
- data/lib/agentilda/adoption.rb +0 -197
- data/lib/agentilda/agent.rb +0 -77
- data/lib/agentilda/board.rb +0 -91
- data/lib/agentilda/brief.rb +0 -283
- data/lib/agentilda/child.rb +0 -92
- data/lib/agentilda/clock.rb +0 -114
- data/lib/agentilda/console.rb +0 -157
- data/lib/agentilda/control.rb +0 -178
- data/lib/agentilda/creator.rb +0 -125
- data/lib/agentilda/dashboard.rb +0 -219
- data/lib/agentilda/dev_work.rb +0 -56
- data/lib/agentilda/diagram.rb +0 -149
- data/lib/agentilda/dispatcher.rb +0 -646
- data/lib/agentilda/documentation.rb +0 -437
- data/lib/agentilda/executor.rb +0 -697
- data/lib/agentilda/feature.rb +0 -132
- data/lib/agentilda/github.rb +0 -168
- data/lib/agentilda/index.rb +0 -206
- data/lib/agentilda/keyboard.rb +0 -156
- data/lib/agentilda/ledger.rb +0 -233
- data/lib/agentilda/mailbox.rb +0 -141
- data/lib/agentilda/ordinal.rb +0 -90
- data/lib/agentilda/progress_log.rb +0 -126
- data/lib/agentilda/publisher.rb +0 -175
- data/lib/agentilda/pull_request.rb +0 -27
- data/lib/agentilda/pull_requests.rb +0 -230
- data/lib/agentilda/reporter.rb +0 -181
- data/lib/agentilda/resync.rb +0 -426
- data/lib/agentilda/roster.rb +0 -93
- data/lib/agentilda/runner.rb +0 -139
- data/lib/agentilda/screen/ratatui/bar.rb +0 -57
- data/lib/agentilda/screen/ratatui/key_translator.rb +0 -29
- data/lib/agentilda/screen/ratatui.rb +0 -318
- data/lib/agentilda/state_file.rb +0 -174
- data/lib/agentilda/state_machine.rb +0 -383
- data/lib/agentilda/status.rb +0 -363
- data/lib/agentilda/subject.rb +0 -133
- data/lib/agentilda/tally.rb +0 -175
- data/lib/agentilda/transcript.rb +0 -466
- data/lib/agentilda/tree.rb +0 -81
- data/lib/agentilda/unblocker.rb +0 -221
- data/lib/agentilda/viewer.rb +0 -60
- data/lib/agentilda/worktree.rb +0 -207
- /data/lib/agentilda/{config.rb → support/config.rb} +0 -0
- /data/lib/agentilda/{frontmatter.rb → support/frontmatter.rb} +0 -0
- /data/lib/agentilda/{markdown.rb → support/markdown.rb} +0 -0
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Execution
|
|
5
|
+
# One `claude` process the harness started and therefore owns.
|
|
6
|
+
#
|
|
7
|
+
# `TTY::Command` never exposes the pid it spawned, so the executor used to
|
|
8
|
+
# find its child by scraping `ps` for a fresh `claude` under this process
|
|
9
|
+
# and could never safely signal it. Kill (`k`) and the sixty-second
|
|
10
|
+
# backstop after STOP both need a pid that is certainly ours, so the
|
|
11
|
+
# process is spawned here and the pid kept.
|
|
12
|
+
#
|
|
13
|
+
# stderr is folded into the same pipe as stdout: `claude` prints the cause
|
|
14
|
+
# of a failure there, and the transcript keeps non-JSON lines for exactly
|
|
15
|
+
# that reason.
|
|
16
|
+
class Child
|
|
17
|
+
# @param argv [Array<String>]
|
|
18
|
+
# @param chdir [String, nil]
|
|
19
|
+
# @return [Agentilda::Execution::Child]
|
|
20
|
+
def self.spawn(argv, chdir: nil)
|
|
21
|
+
reader, writer = IO.pipe
|
|
22
|
+
options = { out: writer, err: writer, in: File::NULL, unsetenv_others: true }
|
|
23
|
+
options[:chdir] = chdir if chdir
|
|
24
|
+
pid = Process.spawn(environment, *argv, **options)
|
|
25
|
+
writer.close
|
|
26
|
+
new(pid:, reader:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
# The environment the child starts in: the one Bundler was loaded under,
|
|
30
|
+
# less what Bundler itself put there. `exe/agentilda` pins `BUNDLE_GEMFILE`
|
|
31
|
+
# to its own Gemfile and `bundler/setup` adds `-rbundler/setup` to
|
|
32
|
+
# `RUBYOPT`; a child that inherited them would run every `bundle exec`,
|
|
33
|
+
# `standardrb` and `alock` against the harness's checkout instead of the
|
|
34
|
+
# worktree it was handed, and be told the gems that worktree's Gemfile adds
|
|
35
|
+
# are "not currently included in the bundle". Bundler knows exactly what it
|
|
36
|
+
# changed, so it is asked; without Bundler there is nothing to strip.
|
|
37
|
+
#
|
|
38
|
+
# @return [Hash{String => String}]
|
|
39
|
+
def self.environment
|
|
40
|
+
defined?(::Bundler) ? ::Bundler.unbundled_env : ENV.to_h
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
# @param pid [Integer]
|
|
44
|
+
# @param reader [IO]
|
|
45
|
+
def initialize(pid:, reader:)
|
|
46
|
+
@pid = pid
|
|
47
|
+
@reader = reader
|
|
48
|
+
@status = nil
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
# @return [Integer]
|
|
52
|
+
attr_reader :pid
|
|
53
|
+
|
|
54
|
+
# Read until the process closes its end. Runs on the caller's thread;
|
|
55
|
+
# the executor reads on the same thread it always did.
|
|
56
|
+
#
|
|
57
|
+
# @yieldparam text [String] whatever arrived, possibly a partial line
|
|
58
|
+
# @return [void]
|
|
59
|
+
def each_chunk
|
|
60
|
+
loop { yield @reader.readpartial(65_536) }
|
|
61
|
+
rescue IOError
|
|
62
|
+
@reader.close unless @reader.closed?
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
# @return [Process::Status]
|
|
66
|
+
def wait
|
|
67
|
+
@status ||= begin
|
|
68
|
+
_, status = Process.wait2(pid)
|
|
69
|
+
status
|
|
70
|
+
end
|
|
71
|
+
rescue Errno::ECHILD
|
|
72
|
+
@status
|
|
73
|
+
end
|
|
74
|
+
|
|
75
|
+
# @param signal [String]
|
|
76
|
+
# @return [void]
|
|
77
|
+
def kill(signal = "KILL")
|
|
78
|
+
Process.kill(signal, pid)
|
|
79
|
+
rescue Errno::ESRCH
|
|
80
|
+
# Already gone, which is what was wanted.
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# @return [Boolean]
|
|
84
|
+
def alive?
|
|
85
|
+
return false if @status
|
|
86
|
+
|
|
87
|
+
Process.kill(0, pid)
|
|
88
|
+
true
|
|
89
|
+
rescue Errno::ESRCH
|
|
90
|
+
false
|
|
91
|
+
end
|
|
92
|
+
end
|
|
93
|
+
end
|
|
94
|
+
end
|
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Agentilda
|
|
4
|
+
module Execution
|
|
5
|
+
# The advisory clock one invocation runs against.
|
|
6
|
+
#
|
|
7
|
+
# It never terminates the agent on its own. It writes into the control file
|
|
8
|
+
# the agent polls: a warning ten and five minutes out, a wrap-up request at
|
|
9
|
+
# one, STOP at zero. Only a process still alive {GRACE} seconds after STOP
|
|
10
|
+
# is killed, and that is the executor's act on this clock's say-so, so the
|
|
11
|
+
# agent has every chance to write its ledger line first.
|
|
12
|
+
#
|
|
13
|
+
# `now` is injectable so the moments can be tested without sleeping.
|
|
14
|
+
class Clock
|
|
15
|
+
# Seconds before the deadline, and what the control file says then.
|
|
16
|
+
# A moment already in the past when the clock starts is skipped: a five
|
|
17
|
+
# minute agent told "10 minutes left" at t=0 has been lied to.
|
|
18
|
+
WARNINGS = [
|
|
19
|
+
[600, "WARN: 10 minutes left"],
|
|
20
|
+
[300, "WARN: 5 minutes left"],
|
|
21
|
+
[60, "WRAP_UP: 1 minute left, write to disk now"]
|
|
22
|
+
].freeze
|
|
23
|
+
|
|
24
|
+
# Seconds after STOP before {#on_expire} fires.
|
|
25
|
+
GRACE = 60
|
|
26
|
+
|
|
27
|
+
# @param seconds [Integer] the budget
|
|
28
|
+
# @param control [String] the invocation's control file
|
|
29
|
+
# @param on_expire [Proc] called once, GRACE seconds after STOP
|
|
30
|
+
# @param now [Proc] a monotonic clock reading
|
|
31
|
+
def initialize(seconds:, control:, on_expire:, now: -> { UI.monotonic })
|
|
32
|
+
@control = control
|
|
33
|
+
@on_expire = on_expire
|
|
34
|
+
@now = now
|
|
35
|
+
@mutex = Mutex.new
|
|
36
|
+
arm(seconds)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @return [Integer] whole seconds left, floored at zero
|
|
40
|
+
def remaining = [@deadline - @now.call, 0].max.round
|
|
41
|
+
|
|
42
|
+
# @return [Symbol] :calm, :warned, :wrap_up, :stopped or :expired
|
|
43
|
+
attr_reader :phase
|
|
44
|
+
|
|
45
|
+
# Evaluate every moment once. Idempotent within a phase.
|
|
46
|
+
#
|
|
47
|
+
# @return [void]
|
|
48
|
+
def tick
|
|
49
|
+
@mutex.synchronize do
|
|
50
|
+
left = @deadline - @now.call
|
|
51
|
+
WARNINGS.each do |before, text|
|
|
52
|
+
next if @fired.include?(before) || left > before
|
|
53
|
+
|
|
54
|
+
@fired << before
|
|
55
|
+
Control.write(@control, text)
|
|
56
|
+
@phase = before == 60 ? :wrap_up : :warned
|
|
57
|
+
end
|
|
58
|
+
if left <= 0 && !@fired.include?(:stop)
|
|
59
|
+
@fired << :stop
|
|
60
|
+
Control.write(@control, Control::STOP)
|
|
61
|
+
@phase = :stopped
|
|
62
|
+
end
|
|
63
|
+
if @fired.include?(:stop) && !@fired.include?(:expired) && left <= -GRACE
|
|
64
|
+
@fired << :expired
|
|
65
|
+
@phase = :expired
|
|
66
|
+
@on_expire.call
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
# @return [self]
|
|
72
|
+
def start
|
|
73
|
+
@thread ||= Thread.new do
|
|
74
|
+
Thread.current.report_on_exception = false
|
|
75
|
+
loop do
|
|
76
|
+
tick
|
|
77
|
+
break if phase == :expired
|
|
78
|
+
|
|
79
|
+
sleep(1)
|
|
80
|
+
end
|
|
81
|
+
end
|
|
82
|
+
self
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
# @return [void]
|
|
86
|
+
def stop
|
|
87
|
+
@thread&.kill
|
|
88
|
+
@thread = nil
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# Ten more minutes, or however many. The file is emptied so the next poll
|
|
92
|
+
# reads a reprieve, and every moment is re-armed against the new deadline.
|
|
93
|
+
#
|
|
94
|
+
# @param seconds [Integer]
|
|
95
|
+
# @return [void]
|
|
96
|
+
def extend!(seconds)
|
|
97
|
+
@mutex.synchronize do
|
|
98
|
+
@deadline += seconds
|
|
99
|
+
@fired = []
|
|
100
|
+
@phase = :calm
|
|
101
|
+
Control.write(@control, "")
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
private
|
|
106
|
+
|
|
107
|
+
# @param seconds [Integer]
|
|
108
|
+
# @return [void]
|
|
109
|
+
def arm(seconds)
|
|
110
|
+
@deadline = @now.call + seconds
|
|
111
|
+
@fired = WARNINGS.map(&:first).select { |before| before >= seconds }
|
|
112
|
+
@phase = :calm
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
116
|
+
end
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
|
|
5
|
+
module Agentilda
|
|
6
|
+
module Execution
|
|
7
|
+
# The side channel between a keypress and a running agent.
|
|
8
|
+
#
|
|
9
|
+
# `claude -p` takes no input once it starts, so the only way to reach an
|
|
10
|
+
# agent mid-flight is a file it has been told to poll: each invocation gets
|
|
11
|
+
# a control file of its own, named in its prompt, and a keypress writes a
|
|
12
|
+
# word into every file currently registered. Like everything else about the
|
|
13
|
+
# prompt this is a request — an agent deep in a tool call reads the file at
|
|
14
|
+
# its next step, not instantly — which is why {#quit!} also arms a deadline
|
|
15
|
+
# the {Executor} checks, so "quit" is eventually a guarantee too.
|
|
16
|
+
module Control
|
|
17
|
+
# What a keypress writes into a control file. One word, one line, so an
|
|
18
|
+
# agent can act on `File.read(path).strip` and nothing subtler.
|
|
19
|
+
WRAP_UP = "WRAP_UP"
|
|
20
|
+
STOP = "STOP"
|
|
21
|
+
|
|
22
|
+
# Ctrl-C: STOP, and the run will be started again, so the agent leaves
|
|
23
|
+
# a resume note before it goes. The prompt says what that note is.
|
|
24
|
+
INTERRUPT = "INTERRUPT"
|
|
25
|
+
|
|
26
|
+
# The clock's warnings start with this; the agent's prompt explains them.
|
|
27
|
+
WARN = "WARN"
|
|
28
|
+
|
|
29
|
+
# Seconds between {#quit!} and the harness terminating whatever is still
|
|
30
|
+
# running. Long enough to write files and a handoff note; short enough
|
|
31
|
+
# that q means quit rather than "quit eventually".
|
|
32
|
+
GRACE = 60
|
|
33
|
+
|
|
34
|
+
@mutex = Mutex.new
|
|
35
|
+
@files = []
|
|
36
|
+
@quit = false
|
|
37
|
+
@interrupted = false
|
|
38
|
+
@deadline = nil
|
|
39
|
+
|
|
40
|
+
class << self
|
|
41
|
+
# Register a fresh control file for one invocation and hand back its
|
|
42
|
+
# path, to be named in the agent's prompt.
|
|
43
|
+
#
|
|
44
|
+
# @param dir [String] where the file lives (the trace dir — outside the
|
|
45
|
+
# repository for the same reason traces are)
|
|
46
|
+
# @param name [String] something findable: plan ordinal and agent name
|
|
47
|
+
# @return [String]
|
|
48
|
+
def register(dir, name)
|
|
49
|
+
FileUtils.mkdir_p(dir)
|
|
50
|
+
path = File.join(dir, "control-#{name}-#{Process.pid}-#{format("%04x", rand(0x10000))}")
|
|
51
|
+
File.write(path, "")
|
|
52
|
+
@mutex.synchronize { @files << path }
|
|
53
|
+
path
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
# Forget a finished invocation's file. The file itself is removed so a
|
|
57
|
+
# crashed run does not leave stale STOPs for the next one to find.
|
|
58
|
+
#
|
|
59
|
+
# @param path [String]
|
|
60
|
+
# @return [void]
|
|
61
|
+
def release(path)
|
|
62
|
+
@mutex.synchronize { @files.delete(path) }
|
|
63
|
+
FileUtils.rm_f(path)
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
# One line into one file, from the clock. Not a broadcast: a warning is
|
|
67
|
+
# about one agent's own deadline.
|
|
68
|
+
#
|
|
69
|
+
# @param path [String]
|
|
70
|
+
# @param text [String]
|
|
71
|
+
# @return [void]
|
|
72
|
+
def write(path, text)
|
|
73
|
+
File.write(path, text.empty? ? "" : "#{text}\n")
|
|
74
|
+
rescue SystemCallError
|
|
75
|
+
# The invocation finished and released its file; nothing to warn.
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
# `w`: every running agent is asked to finish the essential remainder
|
|
79
|
+
# as fast as it can. The loop itself keeps going.
|
|
80
|
+
#
|
|
81
|
+
# @return [void]
|
|
82
|
+
def wrap_up! = broadcast(WRAP_UP)
|
|
83
|
+
|
|
84
|
+
# `n`: every running agent is asked to write out what it has and end
|
|
85
|
+
# its turn. The loop keeps going — under chaining that is exactly what
|
|
86
|
+
# hands the plan to the next agent.
|
|
87
|
+
#
|
|
88
|
+
# @return [void]
|
|
89
|
+
def stop! = broadcast(STOP)
|
|
90
|
+
|
|
91
|
+
# `q`: {#stop!}, plus the loop ends after this round, plus a deadline —
|
|
92
|
+
# {GRACE} seconds from now — after which the {Executor} aborts whatever
|
|
93
|
+
# is still running rather than waiting on its goodwill.
|
|
94
|
+
#
|
|
95
|
+
# @return [void]
|
|
96
|
+
def quit!
|
|
97
|
+
stop!
|
|
98
|
+
@mutex.synchronize {
|
|
99
|
+
@quit = true
|
|
100
|
+
@deadline ||= UI.monotonic + GRACE
|
|
101
|
+
}
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
# Ctrl-C, the first time: {#quit!}, except that every running agent is
|
|
105
|
+
# told the run is being interrupted rather than stopped, so it writes
|
|
106
|
+
# down where it got to. A restart then reads that note instead of
|
|
107
|
+
# redoing, or half-redoing, the work.
|
|
108
|
+
#
|
|
109
|
+
# @return [void]
|
|
110
|
+
def interrupt!
|
|
111
|
+
broadcast(INTERRUPT)
|
|
112
|
+
@mutex.synchronize {
|
|
113
|
+
@quit = true
|
|
114
|
+
@interrupted = true
|
|
115
|
+
@deadline ||= UI.monotonic + GRACE
|
|
116
|
+
}
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
# @return [Boolean] whether `q` has been pressed
|
|
120
|
+
def quit? = @mutex.synchronize { @quit }
|
|
121
|
+
|
|
122
|
+
# @return [Boolean] whether Ctrl-C has been pressed
|
|
123
|
+
def interrupted? = @mutex.synchronize { @interrupted }
|
|
124
|
+
|
|
125
|
+
# Ctrl-C where no keyboard reads it: a run with no terminal to put in
|
|
126
|
+
# raw mode still gets the graceful first press and the hard second.
|
|
127
|
+
#
|
|
128
|
+
# A trap handler may not take a mutex, so the first press hands
|
|
129
|
+
# {#interrupt!} to a thread; the second raises, as Ctrl-C always has.
|
|
130
|
+
#
|
|
131
|
+
# @yieldreturn [Object]
|
|
132
|
+
# @return [Object] the block's value
|
|
133
|
+
def on_interrupt
|
|
134
|
+
previous = Signal.trap("INT") do
|
|
135
|
+
# A plain read, not {#interrupted?}: a trap handler may not lock.
|
|
136
|
+
raise Interrupt if @interrupted || @pending_interrupt
|
|
137
|
+
|
|
138
|
+
@pending_interrupt = true
|
|
139
|
+
Thread.new { interrupt! }
|
|
140
|
+
end
|
|
141
|
+
yield
|
|
142
|
+
ensure
|
|
143
|
+
@pending_interrupt = false
|
|
144
|
+
Signal.trap("INT", previous || "DEFAULT")
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
# @return [Boolean] whether the grace period after `q` has run out
|
|
148
|
+
def overdue?
|
|
149
|
+
@mutex.synchronize { !@deadline.nil? && UI.monotonic > @deadline }
|
|
150
|
+
end
|
|
151
|
+
|
|
152
|
+
# Back to rest, for the next `Runner#call` — and for the suite, where
|
|
153
|
+
# one example's q must not quit every example after it.
|
|
154
|
+
#
|
|
155
|
+
# @return [void]
|
|
156
|
+
def reset!
|
|
157
|
+
@mutex.synchronize {
|
|
158
|
+
@files.each { |f| FileUtils.rm_f(f) }
|
|
159
|
+
@files.clear
|
|
160
|
+
@quit = false
|
|
161
|
+
@interrupted = false
|
|
162
|
+
@deadline = nil
|
|
163
|
+
}
|
|
164
|
+
end
|
|
165
|
+
|
|
166
|
+
private
|
|
167
|
+
|
|
168
|
+
# @param word [String]
|
|
169
|
+
# @return [void]
|
|
170
|
+
def broadcast(word)
|
|
171
|
+
@mutex.synchronize { @files.dup }.each do |path|
|
|
172
|
+
File.write(path, "#{word}\n")
|
|
173
|
+
rescue SystemCallError
|
|
174
|
+
# A file whose invocation just finished is not an error to miss.
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
end
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|