letsdo 0.3.0 → 0.5.0

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 (50) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +179 -0
  3. data/README.md +147 -17
  4. data/bin/letsdo +5 -0
  5. data/docs/config.md +171 -0
  6. data/docs/prompts.md +209 -0
  7. data/docs/task-selection.md +239 -0
  8. data/docs/usage.md +295 -0
  9. data/letsdo.gemspec +63 -0
  10. data/lib/letsdo/agent.rb +29 -17
  11. data/lib/letsdo/agent_identity.rb +42 -0
  12. data/lib/letsdo/agent_loop/tasks.rb +91 -13
  13. data/lib/letsdo/agent_loop.rb +43 -4
  14. data/lib/letsdo/backends/backend.rb +137 -0
  15. data/lib/letsdo/backends/pi/events.rb +77 -0
  16. data/lib/letsdo/backends/pi.rb +88 -0
  17. data/lib/letsdo/cli/builder.rb +102 -0
  18. data/lib/letsdo/cli/builder_assembly.rb +151 -0
  19. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  20. data/lib/letsdo/cli/doctor.rb +16 -0
  21. data/lib/letsdo/cli.rb +17 -56
  22. data/lib/letsdo/config.rb +133 -0
  23. data/lib/letsdo/control/reader.rb +131 -0
  24. data/lib/letsdo/control.rb +6 -3
  25. data/lib/letsdo/doctor/checks.rb +98 -0
  26. data/lib/letsdo/doctor.rb +42 -0
  27. data/lib/letsdo/duration.rb +23 -0
  28. data/lib/letsdo/errors.rb +7 -0
  29. data/lib/letsdo/metrics/fanout.rb +47 -0
  30. data/lib/letsdo/prompt_store.rb +48 -1
  31. data/lib/letsdo/providers/backlog.rb +124 -0
  32. data/lib/letsdo/providers/task.rb +48 -0
  33. data/lib/letsdo/retry_policy.rb +98 -0
  34. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  35. data/lib/letsdo/session_recorder.rb +188 -0
  36. data/lib/letsdo/task_time_writeback.rb +106 -0
  37. data/lib/letsdo/tui/metrics.rb +7 -2
  38. data/lib/letsdo/tui/session/terminal.rb +47 -0
  39. data/lib/letsdo/tui/session/view.rb +10 -2
  40. data/lib/letsdo/tui/session.rb +27 -22
  41. data/lib/letsdo/tui/window_title.rb +133 -0
  42. data/lib/letsdo/tui.rb +4 -0
  43. data/lib/letsdo/version.rb +1 -1
  44. data/lib/letsdo.rb +17 -5
  45. metadata +37 -12
  46. data/lib/letsdo/backlog_tasks.rb +0 -59
  47. data/lib/letsdo/cli/launch.rb +0 -87
  48. data/lib/letsdo/pi_runner/events.rb +0 -75
  49. data/lib/letsdo/pi_runner/process.rb +0 -68
  50. data/lib/letsdo/pi_runner.rb +0 -101
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Metrics
5
+ # A small fan-out facade that forwards the loop-driver events to a
6
+ # list of observers. Used in TUI mode so the session recorder and
7
+ # the header metrics facade both receive the same callbacks from
8
+ # Letsdo::AgentLoop.
9
+ #
10
+ # Each observer must respond to provider_result, run_started and
11
+ # run_finished (the same interface Tui::Metrics and SessionRecorder
12
+ # implement). The exit code is forwarded to every observer so the
13
+ # session recorder can classify outcomes; Tui::Metrics ignores it.
14
+ class Fanout
15
+ # @param observers [Array<Object>] each must implement the three
16
+ # callback methods (provider_result, run_started, run_finished)
17
+ def initialize(*observers)
18
+ @observers = observers
19
+ end
20
+
21
+ # Forwards the provider open-task count to every observer.
22
+ #
23
+ # @param count [Integer, nil] number of open tasks; nil = the
24
+ # backlog state is unreadable
25
+ # @return [void]
26
+ def provider_result(count)
27
+ @observers.each { |observer| observer.provider_result(count) }
28
+ end
29
+
30
+ # Forwards a run-started event (task label) to every observer.
31
+ #
32
+ # @param task [String] task label
33
+ # @return [void]
34
+ def run_started(task)
35
+ @observers.each { |observer| observer.run_started(task) }
36
+ end
37
+
38
+ # Forwards a run-finished event (exit code) to every observer.
39
+ #
40
+ # @param exit_code [Integer, nil] the run exit code
41
+ # @return [void]
42
+ def run_finished(exit_code = nil)
43
+ @observers.each { |observer| observer.run_finished(exit_code) }
44
+ end
45
+ end
46
+ end
47
+ end
@@ -1,6 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'fileutils'
4
+ require 'yaml'
4
5
 
5
6
  module Letsdo
6
7
  # Access to agent prompts: the agents/<name>.md directory in the project
@@ -40,7 +41,22 @@ module Letsdo
40
41
  path = agent_path(name)
41
42
  return nil unless File.file?(path)
42
43
 
43
- File.read(path)
44
+ content = File.read(path)
45
+ self.class.strip_front_matter(content)
46
+ end
47
+
48
+ # Parsed launch configuration for an agent.
49
+ #
50
+ # Returns a hash with symbolised keys. An empty hash when the file
51
+ # is missing or has no YAML front matter.
52
+ #
53
+ # @param name [String] agent name
54
+ # @return [Hash{Symbol => Object}]
55
+ def config(name)
56
+ path = agent_path(name)
57
+ return {} unless File.file?(path)
58
+
59
+ self.class.parse_front_matter(File.read(path))
44
60
  end
45
61
 
46
62
  # Absolute path of an agent's prompt file, whether or not it exists.
@@ -69,6 +85,37 @@ module Letsdo
69
85
  true
70
86
  end
71
87
 
88
+ # Strips an optional YAML front matter block delimited by `---` at
89
+ # the very start of a prompt file. Returns the remaining content.
90
+ #
91
+ # @param content [String] full file content
92
+ # @return [String] content without front matter
93
+ def self.strip_front_matter(content)
94
+ content.sub(/\A---\n.+?\n---\n?/m, '')
95
+ end
96
+
97
+ FRONT_MATTER = /\A---\n?\n(.+?)\n?\n---\n?\n/m
98
+
99
+ # Parses an optional YAML front matter block delimited by `---` at
100
+ # the very start of a prompt file. Returns the keys symbolised;
101
+ # returns an empty hash when there is no front matter or it is
102
+ # invalid.
103
+ #
104
+ # @param content [String] full file content
105
+ # @return [Hash{Symbol => Object}]
106
+ def self.parse_front_matter(content)
107
+ match = content.match(FRONT_MATTER)
108
+ return {} unless match
109
+
110
+ symbolize_keys(YAML.safe_load(match[1], permitted_classes: []))
111
+ rescue StandardError
112
+ {}
113
+ end
114
+
115
+ def self.symbolize_keys(parsed)
116
+ parsed.is_a?(Hash) ? parsed.transform_keys(&:to_sym) : {}
117
+ end
118
+
72
119
  private
73
120
 
74
121
  def agents_dir
@@ -0,0 +1,124 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'shellwords'
5
+ require_relative 'task'
6
+
7
+ module Letsdo
8
+ module Providers
9
+ # Task provider for Letsdo::Loop backed by the real backlog CLI:
10
+ #
11
+ # backlog task list --assignee <handle> --exclude-status Done \
12
+ # --ready --sort priority --json
13
+ #
14
+ # Returns the runnable tasks assigned to the handle in the
15
+ # authoritative run order (an Array of Letsdo::Providers::Task), or nil
16
+ # when the backlog state is unreadable — the CLI is not on PATH, failed,
17
+ # or its output is not the expected JSON. The loop treats nil as "pause
18
+ # and retry, do not run the agent".
19
+ #
20
+ # The command runs in the project root (cwd), where the backlog CLI finds
21
+ # the backlog/ folder — the same context as a single agent run.
22
+ class Backlog
23
+ # Keys of the normalized Letsdo::Providers::Task shape. The backlog CLI
24
+ # emits more (reporter, parentTaskId, createdAt, updatedAt); the adapter
25
+ # projects onto these and ignores the rest so a growing CLI schema
26
+ # cannot crash the loop. Beyond the identity fields, the shape keeps
27
+ # what deterministic selection needs: ordinal (the stable tie-break) and
28
+ # type/labels/milestone (optional capability routing).
29
+ TASK_FIELDS = %w[id title status priority assignees ordinal type labels milestone].freeze
30
+
31
+ # Sort ranks for the deterministic batch order. Known priorities are
32
+ # compared case-insensitively; anything else (nil or a new label) ranks
33
+ # last instead of crashing the comparator.
34
+ PRIORITY_RANKS = { 'high' => 0, 'medium' => 1, 'low' => 2 }.freeze
35
+ UNKNOWN_RANK = PRIORITY_RANKS.size
36
+
37
+ # @param handle [String] assignee handle to filter by (e.g. "@developer")
38
+ # @param command [String] backlog CLI command (overridable for tests)
39
+ # @param cwd [String, nil] project root for the CLI; nil = inherit cwd
40
+ # @param env [Hash, nil] environment for the CLI child (nil = inherit
41
+ # the process environment; injected in tests to control the
42
+ # fake backlog scenarios)
43
+ def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
44
+ @handle = handle
45
+ @command = command
46
+ @cwd = cwd
47
+ @env = env
48
+ end
49
+
50
+ # Reads the runnable open tasks once, in deterministic run order.
51
+ #
52
+ # @return [Array<Task>, nil] runnable open tasks; nil when the backlog is
53
+ # unreadable; empty array when there are no open tasks
54
+ def call
55
+ args = @env ? [@env, *command_line] : command_line
56
+ out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
57
+ return nil unless status.success?
58
+
59
+ tasks = JSON.parse(out)['tasks']
60
+ tasks.is_a?(Array) ? sort(tasks.map { |raw| normalize(raw) }) : nil
61
+ rescue Errno::ENOENT, JSON::ParserError, TypeError
62
+ nil
63
+ end
64
+
65
+ private
66
+
67
+ # Deterministic run order: In Progress first (resume before starting),
68
+ # then priority High > Medium > Low, then ordinal ascending, then id
69
+ # ascending. The CLI's --sort priority does not put In Progress first
70
+ # and cannot express the full tie-break, so the adapter sorts the batch
71
+ # itself. The original index is the final tie-break, so equal keys keep
72
+ # the provider's (deterministic) order.
73
+ def sort(tasks)
74
+ tasks.each_with_index.sort_by do |task, index|
75
+ [in_progress_rank(task), priority_rank(task.priority),
76
+ ordinal_rank(task.ordinal), id_rank(task.id), index]
77
+ end.map(&:first)
78
+ end
79
+
80
+ def in_progress_rank(task)
81
+ task.status.to_s.casecmp('In Progress').zero? ? 0 : 1
82
+ end
83
+
84
+ def priority_rank(priority)
85
+ PRIORITY_RANKS[priority.to_s.downcase] || UNKNOWN_RANK
86
+ end
87
+
88
+ # Unknown/nil ordinals sort last (their id still orders them).
89
+ def ordinal_rank(ordinal)
90
+ value = Integer(ordinal, exception: false)
91
+ value.nil? ? [1, 0] : [0, value]
92
+ end
93
+
94
+ # An absent id (malformed task) sorts after identified ones.
95
+ def id_rank(id)
96
+ [id.to_s.empty? ? 1 : 0, id.to_s]
97
+ end
98
+
99
+ # Projects one raw CLI task onto the normalized shape. Unknown keys are
100
+ # ignored (the CLI schema grows over time); a non-Hash entry means the
101
+ # payload is not the expected schema and raises TypeError, which #call
102
+ # turns into nil.
103
+ def normalize(raw)
104
+ raise TypeError, "task is not an object: #{raw.class}" unless raw.is_a?(Hash)
105
+
106
+ Task.new(**TASK_FIELDS.to_h { |field| [field.to_sym, raw[field]] })
107
+ end
108
+
109
+ # [command..., task, list, --assignee <handle>, --exclude-status Done,
110
+ # --ready, --sort priority, --json]
111
+ def command_line
112
+ [
113
+ *Shellwords.split(@command),
114
+ 'task', 'list',
115
+ '--assignee', @handle,
116
+ '--exclude-status', 'Done',
117
+ '--ready',
118
+ '--sort', 'priority',
119
+ '--json'
120
+ ]
121
+ end
122
+ end
123
+ end
124
+ end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ module Providers
5
+ # Immutable value object representing a normalized task.
6
+ #
7
+ # Adapters map tracker-specific JSON onto this shape. Business logic
8
+ # consumes only this interface, not the raw tracker schema.
9
+ #
10
+ # The constructor is strict on purpose: adapters project the tracker
11
+ # payload onto these keywords instead of forwarding it, so extra tracker
12
+ # fields never reach here and a wrong key is caught as a programming
13
+ # error instead of silently ignored.
14
+ #
15
+ # Besides the identity/routing fields, the shape carries the fields a
16
+ # deterministic selector needs: +ordinal+ (the stable tie-break), and
17
+ # +type+/+labels+/+milestone+ for optional capability routing.
18
+ class Task
19
+ attr_reader :id, :title, :status, :priority, :assignees,
20
+ :ordinal, :type, :labels, :milestone
21
+
22
+ # rubocop:disable Metrics/ParameterLists -- the keywords are the
23
+ # normalized shape's explicit contract; adapters project tracker
24
+ # payloads onto exactly these and the strict list rejects a typo.
25
+ def initialize(id:, title: nil, status: nil, priority: nil, assignees: [],
26
+ ordinal: nil, type: nil, labels: [], milestone: nil)
27
+ @id = id
28
+ @title = title
29
+ @status = status
30
+ @priority = priority
31
+ @assignees = Array(assignees)
32
+ @ordinal = ordinal
33
+ @type = type
34
+ @labels = Array(labels)
35
+ @milestone = milestone
36
+ freeze
37
+ end
38
+ # rubocop:enable Metrics/ParameterLists
39
+
40
+ def to_s
41
+ identifier = id.to_s
42
+ return identifier unless identifier.empty?
43
+
44
+ title.to_s
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,98 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Pure policy object for per-task retry, exponential backoff and give-up.
5
+ #
6
+ # Letsdo::Loop stays generic and untouched; this object is wired at the
7
+ # AgentLoop level. The loop filters attempt batches through #cooldown? and
8
+ # records outcomes with #record_failure / #record_success.
9
+ #
10
+ # State is per-task (keyed by task id) and in-memory only: a fresh letsdo
11
+ # session starts with zero failures, so a broken task yields instead of
12
+ # hammering, and a temporarily-failing task is retried next session.
13
+ #
14
+ # The clock is injectable (monotonic, as in Tui::Metrics) for
15
+ # deterministic tests.
16
+ class RetryPolicy
17
+ DEFAULT_MAX_RETRIES = 3
18
+ DEFAULT_CAP = 300.0
19
+ DEFAULT_BASE = 10.0
20
+
21
+ # Tunables exposed for the reconciliation logic (give-up checks).
22
+ attr_reader :max_retries
23
+
24
+ # @param base [Float] base backoff seconds; default wait_seconds (10.0)
25
+ # @param cap [Float] maximum backoff seconds (default 300)
26
+ # @param max_retries [Integer] give-up after N consecutive failures
27
+ # @param clock [Proc] callable → monotonic epoch seconds
28
+ def initialize(base: nil, cap: nil, max_retries: nil, clock: nil)
29
+ @base = base || DEFAULT_BASE
30
+ @cap = cap || DEFAULT_CAP
31
+ @max_retries = max_retries || DEFAULT_MAX_RETRIES
32
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
33
+ @state = {}
34
+ end
35
+
36
+ # Consecutive failures recorded for task_key (0 when none).
37
+ def failures(task_key)
38
+ info = @state[task_key]
39
+ info ? info[:failures] : 0
40
+ end
41
+
42
+ # Records a failed run: bumps the failure count and (re)arms the cooldown
43
+ # deadline for exponential backoff.
44
+ def record_failure(task_key)
45
+ info = (@state[task_key] ||= { failures: 0, cool_until: 0 })
46
+ info[:failures] += 1
47
+ info[:cool_until] = cooldown_deadline(info[:failures])
48
+ info
49
+ end
50
+
51
+ # Records a successful run: clears the per-task retry state.
52
+ def record_success(task_key)
53
+ @state.delete(task_key)
54
+ end
55
+
56
+ # Clears all per-task retry state (fresh session).
57
+ def reset
58
+ @state.clear
59
+ end
60
+
61
+ # Whether the task is cooling down and must be excluded from attempt
62
+ # batches. Tasks skipped by cooldown are NOT re-recorded as failed.
63
+ def cooldown?(task_key, now = nil)
64
+ info = @state[task_key]
65
+ return false unless info
66
+
67
+ cool_until(info) > current_time(now)
68
+ end
69
+
70
+ # Whether the task has hit the give-up limit: it must not be attempted
71
+ # again for the rest of the session.
72
+ def gave_up?(task_key)
73
+ failures(task_key) >= @max_retries
74
+ end
75
+
76
+ # Earliest cooldown deadline across all tasks (for a bounded wait); nil
77
+ # when nothing is cooling down.
78
+ def earliest_cooldown(now = nil)
79
+ current = current_time(now)
80
+ deadlines = @state.values.map { |info| info[:cool_until] }.select { |d| d > current }
81
+ deadlines.empty? ? nil : deadlines.min
82
+ end
83
+
84
+ private
85
+
86
+ def current_time(now)
87
+ now.nil? ? @clock.call : now
88
+ end
89
+
90
+ def cooldown_deadline(failure_count)
91
+ @clock.call + [@base * (2**(failure_count - 1)), @cap].min
92
+ end
93
+
94
+ def cool_until(info)
95
+ info[:cool_until]
96
+ end
97
+ end
98
+ end
@@ -0,0 +1,73 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'time'
5
+
6
+ module Letsdo
7
+ class SessionRecorder
8
+ # Writes the optional JSON Lines metrics stream for a session recorder:
9
+ # a session_start line on construction, one run_finished line per closed
10
+ # run and a session_stop line at the end. With a nil IO the writer is a
11
+ # no-op, so callers need no nil checks.
12
+ class JsonlWriter
13
+ # @param io [IO, nil] append-only target; nil disables the writer
14
+ # @param name [String] agent name
15
+ # @param handle [String] assignee handle
16
+ # @param wall_clock [Proc] -> ISO8601 UTC timestamp string
17
+ def initialize(io, name:, handle:, wall_clock:)
18
+ @io = io
19
+ @name = name
20
+ @handle = handle
21
+ @wall_clock = wall_clock
22
+ session_start if enabled?
23
+ end
24
+
25
+ # @return [Boolean] whether an output target is configured
26
+ def enabled?
27
+ !@io.nil?
28
+ end
29
+
30
+ # Writes the opening session_start line (no-op when disabled).
31
+ #
32
+ # @return [void]
33
+ def session_start
34
+ write(session_start_payload)
35
+ end
36
+
37
+ # @param run [SessionRecorder::Run] a closed run record
38
+ # @return [void]
39
+ def run_finished(run)
40
+ write(
41
+ 'event' => 'run_finished', 'task' => run.task_id, 'exit' => run.exit_code,
42
+ 'outcome' => run.outcome.to_s, 'elapsed_s' => run.elapsed_s,
43
+ 'ts' => @wall_clock.call
44
+ )
45
+ end
46
+
47
+ # @param summary [SessionRecorder::Summary] the final aggregate
48
+ # @return [void]
49
+ def session_stop(summary)
50
+ write(
51
+ 'event' => 'session_stop', 'agent' => @name, 'handle' => @handle,
52
+ 'ts' => @wall_clock.call, 'done' => summary.done, 'failed' => summary.failed,
53
+ 'interrupted' => summary.interrupted, 'left' => summary.left,
54
+ 'session_s' => summary.session_s, 'runs_s' => summary.active_s
55
+ )
56
+ end
57
+
58
+ private
59
+
60
+ def session_start_payload
61
+ { 'event' => 'session_start', 'agent' => @name, 'handle' => @handle,
62
+ 'ts' => @wall_clock.call }
63
+ end
64
+
65
+ def write(payload)
66
+ return unless enabled?
67
+
68
+ @io.puts(JSON.generate(payload))
69
+ @io.flush if @io.respond_to?(:flush)
70
+ end
71
+ end
72
+ end
73
+ end
@@ -0,0 +1,188 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'duration'
4
+ require_relative 'session_recorder/jsonl_writer'
5
+
6
+ module Letsdo
7
+ # A mode-independent session metrics recorder. It receives the same
8
+ # loop-driver events as the TUI metrics facade, records per-run outcomes
9
+ # and durations, and can optionally emit a JSONL event stream.
10
+ #
11
+ # The recorder is intentionally free of TUI dependencies so it can be
12
+ # used in plain mode, TUI mode, or embedded contexts.
13
+ class SessionRecorder
14
+ Run = Struct.new(:task_id, :started_mono, :finished_mono, :elapsed_s,
15
+ :exit_code, :outcome, keyword_init: true)
16
+
17
+ Summary = Struct.new(:done, :failed, :interrupted, :left, :session_s,
18
+ :active_s, :waiting_s, :avg_s, :runs,
19
+ keyword_init: true)
20
+
21
+ MAX_SUMMARY_RUNS = 10
22
+
23
+ HEADLINE = 'letsdo: session: %<done>d done, %<failed>d failed, ' \
24
+ '%<interrupted>d interrupted, %<left>s left open, %<session>s ' \
25
+ '(%<active>s in runs, %<waiting>s waiting, avg %<avg>s)'
26
+
27
+ # Rendering of the human-readable stop summary (TASK-63 C3). Kept nested
28
+ # so SessionRecorder stays within the class-length limit.
29
+ module SummaryFormat
30
+ private
31
+
32
+ def headline(summary)
33
+ left = summary.left.nil? ? 'unknown' : summary.left
34
+ format(HEADLINE, done: summary.done, failed: summary.failed,
35
+ interrupted: summary.interrupted, left: left,
36
+ session: format_duration(summary.session_s),
37
+ active: format_duration(summary.active_s),
38
+ waiting: format_duration(summary.waiting_s),
39
+ avg: format_duration(summary.avg_s))
40
+ end
41
+
42
+ def run_lines(summary)
43
+ lines = summary.runs.first(MAX_SUMMARY_RUNS).map { |run| format_run(run) }
44
+ return lines unless summary.runs.length > MAX_SUMMARY_RUNS
45
+
46
+ lines << "letsdo: … and #{summary.runs.length - MAX_SUMMARY_RUNS} more"
47
+ end
48
+
49
+ def format_run(run)
50
+ return "letsdo: #{run.task_id} interrupted" if run.finished_mono.nil?
51
+
52
+ outcome = run.outcome == :done ? 'done' : 'failed'
53
+ "letsdo: #{run.task_id} #{outcome} in #{format_duration(run.elapsed_s)}"
54
+ end
55
+
56
+ # Shared with the task-time write-back so summary and task record agree.
57
+ def format_duration(seconds)
58
+ Duration.format(seconds)
59
+ end
60
+ end
61
+ include SummaryFormat
62
+
63
+ # @param name [String] agent name
64
+ # @param handle [String] assignee handle
65
+ # @param clock [Proc] monotonic clock -> seconds; default
66
+ # Process.clock_gettime(CLOCK_MONOTONIC)
67
+ # @param wall_clock [Proc] wall clock -> ISO8601 UTC string; default
68
+ # Time.now.utc.iso8601
69
+ # @param metrics_io [IO, nil] optional append-only JSONL target
70
+ def initialize(name:, handle:, clock: nil, wall_clock: nil, metrics_io: nil)
71
+ @name = name
72
+ @handle = handle
73
+ @clock = clock || -> { Process.clock_gettime(Process::CLOCK_MONOTONIC) }
74
+ @wall_clock = wall_clock || -> { Time.now.utc.iso8601 }
75
+ @writer = JsonlWriter.new(metrics_io, name: name, handle: handle, wall_clock: @wall_clock)
76
+ @mutex = Mutex.new
77
+ @runs = []
78
+ @left = nil
79
+ @session_started = @clock.call
80
+ @session_stopped = false
81
+ end
82
+
83
+ # A run started for a task.
84
+ #
85
+ # @param task_id [String] task label
86
+ # @return [void]
87
+ def run_started(task_id)
88
+ @mutex.synchronize do
89
+ @runs << Run.new(task_id: task_id, started_mono: @clock.call)
90
+ end
91
+ end
92
+
93
+ # A run finished. The last still-open run is closed with the given
94
+ # exit code. If no run is open, this is a no-op.
95
+ #
96
+ # @param exit_code [Integer, nil] the run exit code
97
+ # @return [void]
98
+ def run_finished(exit_code = nil)
99
+ @mutex.synchronize do
100
+ run = @runs.reverse.find { |candidate| candidate.finished_mono.nil? }
101
+ return unless run
102
+
103
+ close_run(run, exit_code)
104
+ @writer.run_finished(run)
105
+ end
106
+ end
107
+
108
+ # The latest open-task count from the backlog provider.
109
+ #
110
+ # @param count [Integer, nil] number of open tasks; nil = the backlog
111
+ # state is unreadable
112
+ # @return [void]
113
+ def provider_result(count)
114
+ @mutex.synchronize { @left = count }
115
+ end
116
+
117
+ # A point-in-time summary of the session metrics.
118
+ #
119
+ # waiting_s is a derived approximation: session time minus the sum of
120
+ # run durations. It therefore also includes polling, backlog reads and
121
+ # stop overhead, not only idle waiting for new tasks.
122
+ #
123
+ # @return [Summary] aggregate counts and durations
124
+ def summary
125
+ @mutex.synchronize { build_summary }
126
+ end
127
+
128
+ # Writes the session_stop JSONL event and returns the summary.
129
+ # The event is written only once per recorder instance.
130
+ #
131
+ # @return [Summary]
132
+ def session_stop
133
+ emit = claim_stop_event
134
+ result = summary
135
+ @writer.session_stop(result) if emit
136
+ result
137
+ end
138
+
139
+ # A human-readable summary suitable for stderr.
140
+ #
141
+ # @return [String]
142
+ def summary_line
143
+ s = summary
144
+ lines = [headline(s)] + run_lines(s)
145
+ lines.join("\n")
146
+ end
147
+
148
+ private
149
+
150
+ def close_run(run, exit_code)
151
+ run.finished_mono = @clock.call
152
+ run.elapsed_s = run.finished_mono - run.started_mono
153
+ run.exit_code = exit_code
154
+ run.outcome = exit_code&.zero? ? :done : :failed
155
+ end
156
+
157
+ def build_summary
158
+ session_s = @clock.call - @session_started
159
+ active_s = @runs.sum { |run| run.elapsed_s || 0.0 }
160
+ Summary.new(
161
+ done: count_outcome(:done), failed: count_outcome(:failed),
162
+ interrupted: @runs.count { |run| run.finished_mono.nil? }, left: @left,
163
+ session_s: session_s, active_s: active_s, waiting_s: session_s - active_s,
164
+ avg_s: average_done_run, runs: @runs.dup
165
+ )
166
+ end
167
+
168
+ def count_outcome(outcome)
169
+ @runs.count { |run| run.outcome == outcome }
170
+ end
171
+
172
+ def average_done_run
173
+ done = @runs.select { |run| run.outcome == :done }
174
+ return 0.0 if done.empty?
175
+
176
+ done.sum { |run| run.elapsed_s || 0.0 } / done.size
177
+ end
178
+
179
+ def claim_stop_event
180
+ @mutex.synchronize do
181
+ return false if @session_stopped
182
+
183
+ @session_stopped = true
184
+ @writer.enabled?
185
+ end
186
+ end
187
+ end
188
+ end