letsdo 0.3.0 → 0.6.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +232 -0
  3. data/README.md +156 -23
  4. data/bin/letsdo +8 -1
  5. data/docs/config.md +176 -0
  6. data/docs/prompts.md +210 -0
  7. data/docs/task-selection.md +244 -0
  8. data/docs/usage.md +296 -0
  9. data/letsdo.gemspec +63 -0
  10. data/lib/letsdo/agent.rb +30 -17
  11. data/lib/letsdo/agent_identity.rb +46 -0
  12. data/lib/letsdo/agent_loop/assignee_hints.rb +34 -0
  13. data/lib/letsdo/agent_loop/tasks.rb +88 -13
  14. data/lib/letsdo/agent_loop.rb +46 -4
  15. data/lib/letsdo/backends/backend.rb +137 -0
  16. data/lib/letsdo/backends/pi/events.rb +77 -0
  17. data/lib/letsdo/backends/pi.rb +88 -0
  18. data/lib/letsdo/cli/builder.rb +102 -0
  19. data/lib/letsdo/cli/builder_assembly.rb +154 -0
  20. data/lib/letsdo/cli/builder_metrics.rb +82 -0
  21. data/lib/letsdo/cli/doctor.rb +16 -0
  22. data/lib/letsdo/cli.rb +17 -56
  23. data/lib/letsdo/config.rb +152 -0
  24. data/lib/letsdo/control/reader.rb +131 -0
  25. data/lib/letsdo/control.rb +6 -3
  26. data/lib/letsdo/doctor/checks.rb +144 -0
  27. data/lib/letsdo/doctor.rb +42 -0
  28. data/lib/letsdo/duration.rb +23 -0
  29. data/lib/letsdo/errors.rb +7 -0
  30. data/lib/letsdo/metrics/fanout.rb +47 -0
  31. data/lib/letsdo/prompt_store.rb +48 -1
  32. data/lib/letsdo/providers/backlog.rb +180 -0
  33. data/lib/letsdo/providers/task.rb +48 -0
  34. data/lib/letsdo/retry_policy.rb +98 -0
  35. data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
  36. data/lib/letsdo/session_recorder.rb +188 -0
  37. data/lib/letsdo/task_time_writeback.rb +106 -0
  38. data/lib/letsdo/tui/metrics.rb +10 -3
  39. data/lib/letsdo/tui/renderer.rb +9 -1
  40. data/lib/letsdo/tui/session/terminal.rb +47 -0
  41. data/lib/letsdo/tui/session/view.rb +10 -2
  42. data/lib/letsdo/tui/session.rb +27 -22
  43. data/lib/letsdo/tui/window_title.rb +133 -0
  44. data/lib/letsdo/tui.rb +4 -0
  45. data/lib/letsdo/version.rb +1 -1
  46. data/lib/letsdo.rb +17 -5
  47. metadata +39 -13
  48. data/lib/letsdo/backlog_tasks.rb +0 -59
  49. data/lib/letsdo/cli/launch.rb +0 -87
  50. data/lib/letsdo/pi_runner/events.rb +0 -75
  51. data/lib/letsdo/pi_runner/process.rb +0 -68
  52. data/lib/letsdo/pi_runner.rb +0 -101
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative 'doctor/checks'
4
+
5
+ module Letsdo
6
+ # `letsdo doctor`: one line per environment check with a status tag and an
7
+ # actionable hint for every FAIL/WARN (TASK-71). Exits 0 when nothing
8
+ # FAILs and 1 otherwise, so it can gate scripts.
9
+ #
10
+ # Every input comes from Letsdo::Config (env policy) plus the injected
11
+ # streams, so the command is fully testable without touching process ENV.
12
+ class Doctor
13
+ include DoctorChecks
14
+
15
+ def initialize(config:, stdout:, stdin: $stdin)
16
+ @config = config
17
+ @stdout = stdout
18
+ @stdin = stdin
19
+ end
20
+
21
+ # Prints the report and returns the process exit code: 1 when at least
22
+ # one check FAILed, 0 otherwise.
23
+ def run
24
+ results = checks
25
+ results.each { |check| print_result(check) }
26
+ results.any? { |check| check[:status] == 'FAIL' } ? 1 : 0
27
+ end
28
+
29
+ private
30
+
31
+ def print_result(check)
32
+ line = "[#{tag(check[:status])}] #{check[:message]}"
33
+ line += " - #{check[:hint]}" if check[:hint]
34
+ @stdout.puts(line)
35
+ end
36
+
37
+ # All tags render six characters wide, so the columns line up.
38
+ def tag(status)
39
+ status == 'OK' ? ' OK ' : status
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,23 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Letsdo
4
+ # Shared rendering of a duration in seconds as "4m 12s" / "12s" / "0s".
5
+ #
6
+ # The stop summary (TASK-69) and the opt-in per-task elapsed write-back
7
+ # (TASK-70) both use this one formatter, so the number a session reports
8
+ # and the number written into the task record can never drift apart.
9
+ module Duration
10
+ module_function
11
+
12
+ # @param seconds [Numeric] duration in seconds (negative values clamp to 0)
13
+ # @return [String] human-readable duration, e.g. "4m 12s"
14
+ def format(seconds)
15
+ total = [seconds.to_f, 0.0].max.round
16
+ minutes, secs = total.divmod(60)
17
+ return '0s' if minutes.zero? && secs.zero?
18
+ return "#{secs}s" if minutes.zero?
19
+
20
+ "#{minutes}m #{secs}s"
21
+ end
22
+ end
23
+ end
data/lib/letsdo/errors.rb CHANGED
@@ -9,4 +9,11 @@ module Letsdo
9
9
  # cleanly. Not a StandardError — nothing rescues it accidentally.
10
10
  class Stopped < StandardError
11
11
  end
12
+
13
+ # Raised when the AI backend process cannot be started (missing or
14
+ # non-executable binary). This is a configuration error, not a loop
15
+ # problem: the CLI catches it, prints a clear message and exits 2 instead
16
+ # of dying with a Ruby backtrace.
17
+ class BackendUnavailableError < Error
18
+ end
12
19
  end
@@ -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,180 @@
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 --exclude-status Done --ready --sort priority --json
12
+ #
13
+ # Returns the runnable tasks assigned to the handle in the
14
+ # authoritative run order (an Array of Letsdo::Providers::Task), or nil
15
+ # when the backlog state is unreadable — the CLI is not on PATH, failed,
16
+ # or its output is not the expected JSON. The loop treats nil as "pause
17
+ # and retry, do not run the agent".
18
+ #
19
+ # The assignee filter is applied in Ruby, not via the CLI's --assignee:
20
+ # the CLI matches the assignee by exact string, so a task stored as
21
+ # '@developer' (legacy notation) would never reach a '--assignee
22
+ # developer' query and the loop would silently starve (TASK-96). The
23
+ # handle match here tolerates the leading '@', surrounding whitespace
24
+ # and case, so both notations keep working; the literal values that
25
+ # matched only after normalization are exposed by #assignee_variants
26
+ # for the doctor WARN and the once-per-run loop hint.
27
+ #
28
+ # The command runs in the project root (cwd), where the backlog CLI finds
29
+ # the backlog/ folder — the same context as a single agent run.
30
+ class Backlog
31
+ # Keys of the normalized Letsdo::Providers::Task shape. The backlog CLI
32
+ # emits more (reporter, parentTaskId, createdAt, updatedAt); the adapter
33
+ # projects onto these and ignores the rest so a growing CLI schema
34
+ # cannot crash the loop. Beyond the identity fields, the shape keeps
35
+ # what deterministic selection needs: ordinal (the stable tie-break) and
36
+ # type/labels/milestone (optional capability routing).
37
+ TASK_FIELDS = %w[id title status priority assignees ordinal type labels milestone].freeze
38
+
39
+ # Sort ranks for the deterministic batch order. Known priorities are
40
+ # compared case-insensitively; anything else (nil or a new label) ranks
41
+ # last instead of crashing the comparator.
42
+ PRIORITY_RANKS = { 'high' => 0, 'medium' => 1, 'low' => 2 }.freeze
43
+ UNKNOWN_RANK = PRIORITY_RANKS.size
44
+
45
+ # @param handle [String, nil] assignee name to filter by (canonical:
46
+ # 'developer'; a legacy '@developer' still matches the same
47
+ # tasks); nil disables the filter (doctor inspects all open
48
+ # tasks)
49
+ # @param command [String] backlog CLI command (overridable for tests)
50
+ # @param cwd [String, nil] project root for the CLI; nil = inherit cwd
51
+ # @param env [Hash, nil] environment for the CLI child (nil = inherit
52
+ # the process environment; injected in tests to control the
53
+ # fake backlog scenarios)
54
+ def initialize(handle:, command: 'backlog', cwd: nil, env: nil)
55
+ @handle = handle
56
+ @command = command
57
+ @cwd = cwd
58
+ @env = env
59
+ @assignee_variants = []
60
+ end
61
+
62
+ # Literal assignee values from the last successful batch that matched
63
+ # the handle only after normalization (e.g. '@developer' for the
64
+ # handle 'developer') — the mismatch signature the doctor check and
65
+ # the loop hint surface. Empty when the handle matched exactly, no
66
+ # handle is configured, or the last call failed.
67
+ def assignee_variants
68
+ @assignee_variants.dup
69
+ end
70
+
71
+ # Reads the runnable open tasks once, in deterministic run order.
72
+ #
73
+ # @return [Array<Task>, nil] runnable open tasks; nil when the backlog is
74
+ # unreadable; empty array when there are no open tasks
75
+ def call
76
+ @assignee_variants = []
77
+ args = @env ? [@env, *command_line] : command_line
78
+ out, _err, status = Letsdo::Capture.new(*args, chdir: @cwd).run
79
+ return nil unless status.success?
80
+
81
+ tasks = JSON.parse(out)['tasks']
82
+ return nil unless tasks.is_a?(Array)
83
+
84
+ sort(select_by_assignee(tasks.map { |raw| normalize(raw) }))
85
+ rescue Errno::ENOENT, JSON::ParserError, TypeError
86
+ nil
87
+ end
88
+
89
+ private
90
+
91
+ # Deterministic run order: In Progress first (resume before starting),
92
+ # then priority High > Medium > Low, then ordinal ascending, then id
93
+ # ascending. The CLI's --sort priority does not put In Progress first
94
+ # and cannot express the full tie-break, so the adapter sorts the batch
95
+ # itself. The original index is the final tie-break, so equal keys keep
96
+ # the provider's (deterministic) order.
97
+ def sort(tasks)
98
+ tasks.each_with_index.sort_by do |task, index|
99
+ [in_progress_rank(task), priority_rank(task.priority),
100
+ ordinal_rank(task.ordinal), id_rank(task.id), index]
101
+ end.map(&:first)
102
+ end
103
+
104
+ # Assignee filtering happens here, not in the CLI's --assignee: the
105
+ # CLI matches by exact string (TASK-96). The stored value may be the
106
+ # bare canonical name or the legacy '@'-prefixed one — both match.
107
+ # A nil handle (doctor) keeps every task and never records variants.
108
+ def select_by_assignee(tasks)
109
+ return tasks unless @handle
110
+
111
+ selected = tasks.select { |task| match_assignees(task) }
112
+ @assignee_variants = @assignee_variants.uniq.sort
113
+ selected
114
+ end
115
+
116
+ def match_assignees(task)
117
+ task.assignees.any? do |value|
118
+ next false if normalize_assignee(value) != normalized_handle
119
+
120
+ @assignee_variants << value unless value == @handle
121
+ true
122
+ end
123
+ end
124
+
125
+ def normalized_handle
126
+ normalize_assignee(@handle)
127
+ end
128
+
129
+ # '@Dev', ' dev ' and 'dev' are the same handle; only the leading '@'
130
+ # is stripped, so '@dev team' stays distinct from 'devteam'.
131
+ def normalize_assignee(value)
132
+ value.to_s.strip.sub(/\A@/, '').downcase
133
+ end
134
+
135
+ def in_progress_rank(task)
136
+ task.status.to_s.casecmp('In Progress').zero? ? 0 : 1
137
+ end
138
+
139
+ def priority_rank(priority)
140
+ PRIORITY_RANKS[priority.to_s.downcase] || UNKNOWN_RANK
141
+ end
142
+
143
+ # Unknown/nil ordinals sort last (their id still orders them).
144
+ def ordinal_rank(ordinal)
145
+ value = Integer(ordinal, exception: false)
146
+ value.nil? ? [1, 0] : [0, value]
147
+ end
148
+
149
+ # An absent id (malformed task) sorts after identified ones.
150
+ def id_rank(id)
151
+ [id.to_s.empty? ? 1 : 0, id.to_s]
152
+ end
153
+
154
+ # Projects one raw CLI task onto the normalized shape. Unknown keys are
155
+ # ignored (the CLI schema grows over time); a non-Hash entry means the
156
+ # payload is not the expected schema and raises TypeError, which #call
157
+ # turns into nil.
158
+ def normalize(raw)
159
+ raise TypeError, "task is not an object: #{raw.class}" unless raw.is_a?(Hash)
160
+
161
+ Task.new(**TASK_FIELDS.to_h { |field| [field.to_sym, raw[field]] })
162
+ end
163
+
164
+ # [command..., task, list, --exclude-status Done, --ready,
165
+ # --sort priority, --json] — no --assignee: the filter runs in Ruby
166
+ # (select_by_assignee), so exact-match deviations in the stored
167
+ # assignees cannot starve the loop (TASK-96).
168
+ def command_line
169
+ [
170
+ *Shellwords.split(@command),
171
+ 'task', 'list',
172
+ '--exclude-status', 'Done',
173
+ '--ready',
174
+ '--sort', 'priority',
175
+ '--json'
176
+ ]
177
+ end
178
+ end
179
+ end
180
+ 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