letsdo 0.4.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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +39 -2
- data/README.md +141 -12
- data/bin/letsdo +5 -0
- data/docs/config.md +171 -0
- data/docs/prompts.md +209 -0
- data/docs/task-selection.md +239 -0
- data/docs/usage.md +295 -0
- data/letsdo.gemspec +3 -1
- data/lib/letsdo/agent.rb +29 -17
- data/lib/letsdo/agent_identity.rb +42 -0
- data/lib/letsdo/agent_loop/tasks.rb +91 -13
- data/lib/letsdo/agent_loop.rb +36 -3
- data/lib/letsdo/backends/backend.rb +137 -0
- data/lib/letsdo/backends/pi/events.rb +77 -0
- data/lib/letsdo/backends/pi.rb +88 -0
- data/lib/letsdo/cli/builder.rb +47 -95
- data/lib/letsdo/cli/builder_assembly.rb +151 -0
- data/lib/letsdo/cli/builder_metrics.rb +82 -0
- data/lib/letsdo/cli/doctor.rb +16 -0
- data/lib/letsdo/cli.rb +15 -1
- data/lib/letsdo/config.rb +66 -4
- data/lib/letsdo/control/reader.rb +131 -0
- data/lib/letsdo/control.rb +6 -3
- data/lib/letsdo/doctor/checks.rb +98 -0
- data/lib/letsdo/doctor.rb +42 -0
- data/lib/letsdo/duration.rb +23 -0
- data/lib/letsdo/errors.rb +7 -0
- data/lib/letsdo/metrics/fanout.rb +47 -0
- data/lib/letsdo/prompt_store.rb +48 -1
- data/lib/letsdo/providers/backlog.rb +124 -0
- data/lib/letsdo/providers/task.rb +48 -0
- data/lib/letsdo/retry_policy.rb +98 -0
- data/lib/letsdo/session_recorder/jsonl_writer.rb +73 -0
- data/lib/letsdo/session_recorder.rb +188 -0
- data/lib/letsdo/task_time_writeback.rb +106 -0
- data/lib/letsdo/tui/metrics.rb +7 -2
- data/lib/letsdo/tui/session/terminal.rb +47 -0
- data/lib/letsdo/tui/session/view.rb +10 -2
- data/lib/letsdo/tui/session.rb +27 -22
- data/lib/letsdo/tui/window_title.rb +133 -0
- data/lib/letsdo/tui.rb +4 -0
- data/lib/letsdo/version.rb +1 -1
- data/lib/letsdo.rb +16 -5
- metadata +25 -5
- data/lib/letsdo/backlog_tasks.rb +0 -59
- data/lib/letsdo/pi_runner/events.rb +0 -75
- data/lib/letsdo/pi_runner/process.rb +0 -68
- data/lib/letsdo/pi_runner.rb +0 -101
data/lib/letsdo/prompt_store.rb
CHANGED
|
@@ -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
|