ace-overseer 0.26.0 → 0.27.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 +118 -1
- data/docs/usage.md +185 -1
- data/handbook/workflow-instructions/overseer/cold-cycle-acceptance.wf.md +95 -0
- data/handbook/workflow-instructions/overseer/lab-start.wf.md +50 -0
- data/handbook/workflow-instructions/overseer/start.wf.md +79 -0
- data/handbook/workflow-instructions/overseer.wf.md +16 -2
- data/lib/ace/overseer/atoms/start_contract.rb +115 -0
- data/lib/ace/overseer/cli/commands/start.rb +77 -0
- data/lib/ace/overseer/cli.rb +2 -0
- data/lib/ace/overseer/molecules/event_observation.rb +64 -5
- data/lib/ace/overseer/molecules/local_sources.rb +141 -0
- data/lib/ace/overseer/molecules/project_root_resolver.rb +66 -0
- data/lib/ace/overseer/molecules/remote_sources.rb +139 -0
- data/lib/ace/overseer/organisms/start_orchestrator.rb +589 -0
- data/lib/ace/overseer/version.rb +1 -1
- data/lib/ace/overseer.rb +25 -0
- metadata +13 -4
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Overseer
|
|
5
|
+
module Atoms
|
|
6
|
+
# Pure validation and normalization of the `start` contract
|
|
7
|
+
# (task 8xs.t.pu0.1): the `.ace/overseer/config.yml` cascade must
|
|
8
|
+
# carry an EXPLICIT start section — `scope: project|lab`, a
|
|
9
|
+
# `profile` reference into the same document's `profiles` map and
|
|
10
|
+
# an `instructions` source (wfi:// or an absolute file path).
|
|
11
|
+
# Nothing is guessed: a missing or ambiguous section is an error
|
|
12
|
+
# BEFORE any session is created, and no interim default model is
|
|
13
|
+
# applied anywhere. Pure — no IO, value-free messages.
|
|
14
|
+
class StartContract
|
|
15
|
+
SCOPES = %w[project lab].freeze
|
|
16
|
+
SECTION_KEYS = %w[scope profile instructions].freeze
|
|
17
|
+
PROFILE_KEYS = %w[harness model thinking].freeze
|
|
18
|
+
INSTRUCTION_PROTOCOLS = %w[wfi:// prompt:// tmpl://].freeze
|
|
19
|
+
|
|
20
|
+
# @param config [Hash] resolved overseer config (cascade output)
|
|
21
|
+
# @return [Hash] normalized start plan fragment:
|
|
22
|
+
# {scope:, profile_name:, profile: {harness:, model:,
|
|
23
|
+
# thinking:}, instructions:}
|
|
24
|
+
# @raise [Ace::Overseer::Error] missing/ambiguous/invalid start
|
|
25
|
+
# configuration
|
|
26
|
+
def self.normalize(config)
|
|
27
|
+
start = config["start"]
|
|
28
|
+
unless start.is_a?(Hash)
|
|
29
|
+
raise Ace::Overseer::Error,
|
|
30
|
+
"ace-overseer start requires an explicit 'start' section in " \
|
|
31
|
+
".ace/overseer/config.yml (scope, profile, instructions); " \
|
|
32
|
+
"none is configured for this directory"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
unknown = start.keys - SECTION_KEYS
|
|
36
|
+
unless unknown.empty?
|
|
37
|
+
raise Ace::Overseer::Error,
|
|
38
|
+
"start section has unknown key '#{unknown.sort.first}' " \
|
|
39
|
+
"(allowed: #{SECTION_KEYS.join(', ')})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
scope = required(start, "scope")
|
|
43
|
+
unless SCOPES.include?(scope)
|
|
44
|
+
raise Ace::Overseer::Error,
|
|
45
|
+
"start scope must be one of: #{SCOPES.join(', ')} (got '#{scope}')"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
profile_name = required(start, "profile")
|
|
49
|
+
profile = profile_for(config, profile_name)
|
|
50
|
+
|
|
51
|
+
instructions = required(start, "instructions")
|
|
52
|
+
unless INSTRUCTION_PROTOCOLS.any? { |prefix| instructions.start_with?(prefix) } ||
|
|
53
|
+
instructions.start_with?("/")
|
|
54
|
+
raise Ace::Overseer::Error,
|
|
55
|
+
"start instructions must be a wfi:// (or prompt://, tmpl://) " \
|
|
56
|
+
"source or an absolute file path"
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
{"scope" => scope, "profile_name" => profile_name,
|
|
60
|
+
"profile" => profile, "instructions" => instructions}
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
# The pane/tab label that OWNS this project's overseer session:
|
|
64
|
+
# derived from the project root so every start from the same
|
|
65
|
+
# repository (subdirectory, symlink, worktree) converges on the
|
|
66
|
+
# same owner identity.
|
|
67
|
+
def self.session_label(project_root)
|
|
68
|
+
"overseer-#{File.basename(project_root)}"
|
|
69
|
+
end
|
|
70
|
+
|
|
71
|
+
def self.profile_for(config, profile_name)
|
|
72
|
+
profiles = config["profiles"]
|
|
73
|
+
unless profiles.is_a?(Hash) && profiles[profile_name].is_a?(Hash)
|
|
74
|
+
raise Ace::Overseer::Error,
|
|
75
|
+
"start profile '#{profile_name}' is not configured; declare it " \
|
|
76
|
+
"under 'profiles' in .ace/overseer/config.yml"
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
raw = profiles[profile_name]
|
|
80
|
+
unknown = raw.keys - PROFILE_KEYS
|
|
81
|
+
unless unknown.empty?
|
|
82
|
+
raise Ace::Overseer::Error,
|
|
83
|
+
"profile '#{profile_name}' has unknown key '#{unknown.sort.first}' " \
|
|
84
|
+
"(allowed: #{PROFILE_KEYS.join(', ')})"
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
harness = required_value(raw, "harness", "profile '#{profile_name}'")
|
|
88
|
+
model = required_value(raw, "model", "profile '#{profile_name}'")
|
|
89
|
+
thinking = raw["thinking"]
|
|
90
|
+
|
|
91
|
+
{"harness" => harness, "model" => model, "thinking" => thinking}
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def self.required(start, key)
|
|
95
|
+
value = start[key]
|
|
96
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
97
|
+
raise Ace::Overseer::Error,
|
|
98
|
+
"start section key '#{key}' is required and must be a non-empty string"
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
value.strip
|
|
102
|
+
end
|
|
103
|
+
|
|
104
|
+
def self.required_value(raw, key, owner)
|
|
105
|
+
value = raw[key]
|
|
106
|
+
unless value.is_a?(String) && !value.strip.empty?
|
|
107
|
+
raise Ace::Overseer::Error, "#{owner} key '#{key}' is required"
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
value.strip
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
end
|
|
114
|
+
end
|
|
115
|
+
end
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Ace
|
|
6
|
+
module Overseer
|
|
7
|
+
module CLI
|
|
8
|
+
module Commands
|
|
9
|
+
# `ace-overseer start [--dry-run] [--format table|json]`
|
|
10
|
+
# (task 8xs.t.pu0.1): starts or reuses the project overseer
|
|
11
|
+
# session from the directory's explicit start configuration.
|
|
12
|
+
# Exit 0 only for started/reused (dry-run reports the plan with
|
|
13
|
+
# exit 0 but creates nothing); a blocked start is a CLI error.
|
|
14
|
+
class Start < Ace::Support::Cli::Command
|
|
15
|
+
include Ace::Support::Cli::Base
|
|
16
|
+
|
|
17
|
+
desc "Start or reuse the project overseer session from .ace/overseer/config.yml"
|
|
18
|
+
|
|
19
|
+
option :dry_run, type: :boolean, default: false, desc: "Show the start plan without creating a session"
|
|
20
|
+
option :format, default: "table", desc: "Output format (table, json)"
|
|
21
|
+
option :quiet, aliases: ["-q"], type: :boolean, default: false, desc: "Suppress non-essential output"
|
|
22
|
+
option :debug, aliases: ["-d"], type: :boolean, default: false, desc: "Show debug output"
|
|
23
|
+
|
|
24
|
+
def initialize(orchestrator: nil, config: nil)
|
|
25
|
+
super()
|
|
26
|
+
@orchestrator = orchestrator
|
|
27
|
+
@config = config
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def call(dry_run: false, format: "table", **options)
|
|
31
|
+
report = orchestrator.call(dry_run: dry_run)
|
|
32
|
+
|
|
33
|
+
if options[:quiet]
|
|
34
|
+
# quiet still fails loudly on a blocked start
|
|
35
|
+
elsif format == "json"
|
|
36
|
+
puts JSON.pretty_generate(report)
|
|
37
|
+
else
|
|
38
|
+
render_table(report)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
case report["status"]
|
|
42
|
+
when "started", "reused", "ready", "dry_run" then nil
|
|
43
|
+
else
|
|
44
|
+
raise Ace::Support::Cli::Error, report["message"] || "start blocked"
|
|
45
|
+
end
|
|
46
|
+
rescue Ace::Support::Cli::Error
|
|
47
|
+
raise
|
|
48
|
+
rescue => e
|
|
49
|
+
raise Ace::Support::Cli::Error.new(e.message)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def orchestrator
|
|
55
|
+
@orchestrator || Organisms::StartOrchestrator.new(config: @config || Ace::Overseer.config)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def render_table(report)
|
|
59
|
+
puts "ace-overseer start: #{report['status']}"
|
|
60
|
+
puts " scope: #{report['scope'] || '-'}"
|
|
61
|
+
puts " root: #{report['root'] || '-'}"
|
|
62
|
+
puts " label: #{report['label'] || '-'}"
|
|
63
|
+
puts " profile: #{report['profile'] || '-'}"
|
|
64
|
+
if report["instructions"]
|
|
65
|
+
puts " instructions: #{report['instructions']['source']} -> #{report['instructions']['path']}"
|
|
66
|
+
end
|
|
67
|
+
puts " workspace: #{report['workspace'] || '-'}"
|
|
68
|
+
puts " pane: #{report['pane'] || '-'}"
|
|
69
|
+
puts " agent: #{report['agent']}"
|
|
70
|
+
puts " replaced pane: #{report['replaced_pane'] || '-'}" if report["replaced_pane"]
|
|
71
|
+
puts " #{report['message']}" if report["message"]
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
|
75
|
+
end
|
|
76
|
+
end
|
|
77
|
+
end
|
data/lib/ace/overseer/cli.rb
CHANGED
|
@@ -10,6 +10,7 @@ require_relative "cli/commands/observe"
|
|
|
10
10
|
require_relative "cli/commands/prune"
|
|
11
11
|
require_relative "cli/commands/projects"
|
|
12
12
|
require_relative "cli/commands/agents"
|
|
13
|
+
require_relative "cli/commands/start"
|
|
13
14
|
|
|
14
15
|
module Ace
|
|
15
16
|
module Overseer
|
|
@@ -37,6 +38,7 @@ module Ace
|
|
|
37
38
|
"ace-overseer prune # Clean up finished tasks"
|
|
38
39
|
].freeze
|
|
39
40
|
|
|
41
|
+
register "start", Commands::Start
|
|
40
42
|
register "work-on", Commands::WorkOn
|
|
41
43
|
register "handoff", Commands::Handoff
|
|
42
44
|
register "process-reports", Commands::ProcessReports
|
|
@@ -1,20 +1,54 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
require "ace/hitl/molecules/event_observation"
|
|
3
|
+
require "digest"
|
|
3
4
|
|
|
4
5
|
module Ace
|
|
5
6
|
module Overseer
|
|
6
7
|
module Molecules
|
|
7
|
-
# Read-only
|
|
8
|
-
# private task tree owns report
|
|
8
|
+
# Read-only standing observation (task 8xs.t.pu0.3). HITL owns
|
|
9
|
+
# semantic revisions; the existing private task tree owns report
|
|
10
|
+
# adoption and continuation, never this view. The projection arms
|
|
11
|
+
# the started overseer with the six local source classes — HITL
|
|
12
|
+
# events plus LocalSources (project tasks, _overseer tasks,
|
|
13
|
+
# worktrees, coworker availability, assignment progress) — every
|
|
14
|
+
# row read from its state owner, with per-source health so a
|
|
15
|
+
# failing source degrades only itself and healthy silence projects
|
|
16
|
+
# identical revisions (no model turns).
|
|
9
17
|
class EventObservation
|
|
10
|
-
def initialize(hitl: nil, task_manager: nil
|
|
18
|
+
def initialize(hitl: nil, task_manager: nil, local_sources: nil, remote_sources: nil,
|
|
19
|
+
config: nil)
|
|
11
20
|
@hitl = hitl || Ace::Hitl::Molecules::EventObservation.new
|
|
12
21
|
@tasks = task_manager || Ace::Task::Organisms::TaskManager.new
|
|
22
|
+
@local_sources = local_sources || LocalSources.new(task_manager: @tasks)
|
|
23
|
+
@remote_sources = remote_sources || RemoteSources.new
|
|
24
|
+
@config = config || Ace::Overseer.config
|
|
13
25
|
end
|
|
14
26
|
|
|
15
27
|
def call(owner:, scope: "current", limit: 500)
|
|
16
|
-
projection =
|
|
28
|
+
projection = nil
|
|
29
|
+
hitl_error = nil
|
|
30
|
+
begin
|
|
31
|
+
projection = @hitl.call(owner: owner, scope: scope, limit: limit)
|
|
32
|
+
rescue StandardError => e
|
|
33
|
+
hitl_error = e
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
local = @local_sources.call
|
|
37
|
+
remote = remote_projection
|
|
17
38
|
managers = @tasks.list(in_folder: "all").select { |task| !task.metadata["project_task"].to_s.empty? }
|
|
39
|
+
|
|
40
|
+
if projection.nil?
|
|
41
|
+
# A degraded HITL source degrades only itself: the local
|
|
42
|
+
# sources keep projecting and the failure is visible data.
|
|
43
|
+
events = local.fetch("events") + remote.fetch("events") + [health_marker("hitl", hitl_error)]
|
|
44
|
+
sources = local.fetch("sources").merge(remote.fetch("sources")).merge(
|
|
45
|
+
"hitl" => {"status" => "degraded", "error" => "#{hitl_error.class}: #{hitl_error.message}"}
|
|
46
|
+
)
|
|
47
|
+
return {"schema" => "ace.hitl.observation/v1",
|
|
48
|
+
"events" => events.sort_by { |row| row["key"] },
|
|
49
|
+
"settled" => [], "sources" => sources}
|
|
50
|
+
end
|
|
51
|
+
|
|
18
52
|
settled, pending = projection.fetch("events").partition do |event|
|
|
19
53
|
next false unless event["type"] == "report"
|
|
20
54
|
verdict = Atoms::ReportInterpretation.evaluate(event: event, managers: managers)
|
|
@@ -25,7 +59,32 @@ module Ace
|
|
|
25
59
|
# the same attempt. Only completed trees suppress historical reports.
|
|
26
60
|
manager && ([manager] + Array(manager.subtasks)).all? { |task| Ace::Task::Atoms::TaskValidationRules.terminal_status?(task.status.to_s) }
|
|
27
61
|
end
|
|
28
|
-
|
|
62
|
+
|
|
63
|
+
projection.merge("events" => (pending + local.fetch("events") + remote.fetch("events"))
|
|
64
|
+
.sort_by { |row| row["key"] },
|
|
65
|
+
"settled" => settled,
|
|
66
|
+
"sources" => local.fetch("sources").merge(remote.fetch("sources"))
|
|
67
|
+
.merge("hitl" => {"status" => "ok"}))
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
# Configured remote observation (8xs.t.pu0.4): the project's own
|
|
73
|
+
# observe.remote.pull_requests list — never a scan of accessible
|
|
74
|
+
# accounts. No configuration means no remote rows and no remote
|
|
75
|
+
# source to degrade.
|
|
76
|
+
def remote_projection
|
|
77
|
+
entries = Array(@config.dig("observe", "remote", "pull_requests"))
|
|
78
|
+
return {"events" => [], "sources" => {}} if entries.empty?
|
|
79
|
+
|
|
80
|
+
@remote_sources.call(pull_requests: entries)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
def health_marker(name, error)
|
|
84
|
+
{"key" => "source:#{name}",
|
|
85
|
+
"revision" => Digest::SHA256.hexdigest("degraded:#{error.class}"),
|
|
86
|
+
"type" => "source-health", "status" => "degraded",
|
|
87
|
+
"error" => "#{error.class}: #{error.message}"}
|
|
29
88
|
end
|
|
30
89
|
end
|
|
31
90
|
end
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Overseer
|
|
8
|
+
module Molecules
|
|
9
|
+
# Standing local-state observation (task 8xs.t.pu0.3): projects the
|
|
10
|
+
# six local source classes the started overseer watches — project
|
|
11
|
+
# tasks, private _overseer tasks, worktrees, coworker availability,
|
|
12
|
+
# assignment progress and HITL events — each read from its STATE
|
|
13
|
+
# OWNER (ace-task, ace-git-worktree, ace-coworker probe, ace-assign
|
|
14
|
+
# inventory, ace-hitl). This molecule owns no second state: every
|
|
15
|
+
# row is a {key, revision, type} projection whose revision changes
|
|
16
|
+
# only when the owner's data changes, so a consumer (eyes) wakes on
|
|
17
|
+
# real revisions and stays silent on identical state.
|
|
18
|
+
#
|
|
19
|
+
# A failing source degrades ONLY itself: it contributes a
|
|
20
|
+
# `source-health` marker whose revision is stable per error class
|
|
21
|
+
# (an identical repeated failure never re-wakes; a new failure mode
|
|
22
|
+
# wakes once), while the healthy sources keep projecting.
|
|
23
|
+
class LocalSources
|
|
24
|
+
def initialize(task_manager: nil, worktree_manager: nil, probe_factory: nil)
|
|
25
|
+
@tasks = task_manager || Ace::Task::Organisms::TaskManager.new
|
|
26
|
+
@worktrees = worktree_manager || Ace::Git::Worktree::Organisms::WorktreeManager.new
|
|
27
|
+
@probe_factory = probe_factory || ->(path) {
|
|
28
|
+
Ace::Coworker::Molecules::WorktreeExecutorProbe.new(project_root: path)
|
|
29
|
+
}
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [Hash] {"events" => Array<{key, revision, type, ...}>,
|
|
33
|
+
# "sources" => {name => {"status" => "ok"|"degraded", "error"?}}}
|
|
34
|
+
def call
|
|
35
|
+
sources = {}
|
|
36
|
+
events = []
|
|
37
|
+
|
|
38
|
+
collect(sources, events, "project_tasks") { project_task_events }
|
|
39
|
+
collect(sources, events, "overseer_tasks") { overseer_task_events }
|
|
40
|
+
collect(sources, events, "worktrees") { worktree_events }
|
|
41
|
+
collect(sources, events, "coworkers") { coworker_events }
|
|
42
|
+
|
|
43
|
+
{"events" => events, "sources" => sources}
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def collect(sources, events, name)
|
|
49
|
+
rows = yield
|
|
50
|
+
sources[name] = {"status" => "ok"}
|
|
51
|
+
events.concat(rows)
|
|
52
|
+
rescue StandardError => e
|
|
53
|
+
sources[name] = {"status" => "degraded", "error" => "#{e.class}: #{e.message}"}
|
|
54
|
+
events.concat([health_marker(name, e)])
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# A degraded source surfaces as missing data — visibly, never as
|
|
58
|
+
# an empty healthy list. The revision is keyed by error CLASS so
|
|
59
|
+
# an identical repeated failure keeps the same revision (no
|
|
60
|
+
# wake spam) while a new failure mode revises once.
|
|
61
|
+
def health_marker(name, error)
|
|
62
|
+
{"key" => "source:#{name}",
|
|
63
|
+
"revision" => Digest::SHA256.hexdigest("degraded:#{error.class}"),
|
|
64
|
+
"type" => "source-health", "status" => "degraded",
|
|
65
|
+
"error" => "#{error.class}: #{error.message}"}
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def revision(payload)
|
|
69
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def project_task_events
|
|
73
|
+
task_events(@tasks.list(in_folder: "next"), "task")
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def overseer_task_events
|
|
77
|
+
task_events(@tasks.list(in_folder: "_overseer"), "overseer-task")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# Task rows revision on real lifecycle facts (status, dependency
|
|
81
|
+
# set, subtask statuses) — not on cosmetic frontmatter churn.
|
|
82
|
+
def task_events(tasks, type)
|
|
83
|
+
tasks.map do |task|
|
|
84
|
+
payload = {"id" => task.id, "status" => task.status,
|
|
85
|
+
"dependencies" => Array(task.dependencies).sort,
|
|
86
|
+
"subtasks" => Array(task.subtasks).map { |sub| [sub.id, sub.status] }.sort}
|
|
87
|
+
{"key" => "#{type}:#{task.id}", "revision" => revision(payload),
|
|
88
|
+
"type" => type, "id" => task.id, "status" => task.status,
|
|
89
|
+
"title" => task.title, "dependencies" => Array(task.dependencies).sort}
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def worktree_events
|
|
94
|
+
result = @worktrees.list_all
|
|
95
|
+
raise Error, result[:error] || "failed to list worktrees" unless result[:success]
|
|
96
|
+
|
|
97
|
+
Array(result[:worktrees]).reject(&:bare).map do |worktree|
|
|
98
|
+
payload = {"path" => worktree.path, "branch" => worktree.branch}
|
|
99
|
+
{"key" => "worktree:#{Digest::SHA256.hexdigest(worktree.path)}",
|
|
100
|
+
"revision" => revision(payload), "type" => "worktree",
|
|
101
|
+
"path" => worktree.path, "branch" => worktree.branch}
|
|
102
|
+
end
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
# Coworker availability AND assignment progress in one pass: the
|
|
106
|
+
# executor probe reads the worktree's commissioned config and the
|
|
107
|
+
# ace-assign inventory (existing run identifiers — assignment
|
|
108
|
+
# ids, never pane names), the ActiveExecutorRule decides live vs
|
|
109
|
+
# stale, and each in-flight assignment projects its own progress
|
|
110
|
+
# row so a step report revises that assignment only.
|
|
111
|
+
def coworker_events
|
|
112
|
+
result = @worktrees.list_all
|
|
113
|
+
raise Error, result[:error] || "failed to list worktrees" unless result[:success]
|
|
114
|
+
|
|
115
|
+
Array(result[:worktrees]).reject(&:bare).flat_map do |worktree|
|
|
116
|
+
probe = @probe_factory.call(worktree.path)
|
|
117
|
+
report = probe.probe(worktree.path)
|
|
118
|
+
verdict = Ace::Coworker::Atoms::ActiveExecutorRule.evaluate(
|
|
119
|
+
config: report[:config], assignments: report[:assignments]
|
|
120
|
+
)
|
|
121
|
+
identity = Digest::SHA256.hexdigest(worktree.path)
|
|
122
|
+
executor = {"key" => "executor:#{identity}",
|
|
123
|
+
"revision" => revision({"path" => worktree.path,
|
|
124
|
+
"active" => verdict[:active],
|
|
125
|
+
"evidence" => verdict[:evidence]}),
|
|
126
|
+
"type" => "executor", "path" => worktree.path,
|
|
127
|
+
"active" => verdict[:active], "evidence" => verdict[:evidence]}
|
|
128
|
+
assignments = report[:assignments].map do |assignment|
|
|
129
|
+
{"key" => "assignment:#{assignment["id"]}",
|
|
130
|
+
"revision" => revision({"id" => assignment["id"], "state" => assignment["state"],
|
|
131
|
+
"progress" => assignment["progress"]}),
|
|
132
|
+
"type" => "assignment", "id" => assignment["id"],
|
|
133
|
+
"state" => assignment["state"], "progress" => assignment["progress"]}
|
|
134
|
+
end
|
|
135
|
+
[executor] + assignments
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Ace
|
|
4
|
+
module Overseer
|
|
5
|
+
module Molecules
|
|
6
|
+
# Canonical project-root resolution for `ace-overseer start`
|
|
7
|
+
# (task 8xs.t.pu0.1): a subdirectory, a symlinked checkout and a
|
|
8
|
+
# linked WORKTREE of the same repository all resolve to the same
|
|
9
|
+
# owning root, so one repository never runs two whole-project
|
|
10
|
+
# overseers. Worktrees map to the main working tree's root through
|
|
11
|
+
# Git's own `--git-common-dir`; the resolved root is realpathed.
|
|
12
|
+
# All Git access runs through an injectable argv runner seam.
|
|
13
|
+
class ProjectRootResolver
|
|
14
|
+
def initialize(runner: nil)
|
|
15
|
+
@runner = runner || method(:run_argv)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# @param cwd [String] directory the command was invoked in
|
|
19
|
+
# @return [String] canonical project root (realpath)
|
|
20
|
+
# @raise [Ace::Overseer::Error] not a repository, or a bare repo
|
|
21
|
+
def resolve(cwd:)
|
|
22
|
+
toplevel = git_output(cwd, ["git", "-C", cwd, "rev-parse", "--show-toplevel"])
|
|
23
|
+
root = File.realpath(toplevel)
|
|
24
|
+
|
|
25
|
+
common = git_output(cwd, ["git", "-C", root, "rev-parse", "--git-common-dir"])
|
|
26
|
+
common_path = File.expand_path(common, root)
|
|
27
|
+
if File.realpath(common_path) != File.realpath(File.join(root, ".git"))
|
|
28
|
+
# a linked worktree: the main working tree owns the project
|
|
29
|
+
main_root = File.dirname(File.realpath(common_path))
|
|
30
|
+
root = File.realpath(main_root) if File.directory?(main_root)
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
root
|
|
34
|
+
rescue Errno::ENOENT, Errno::ENOTDIR
|
|
35
|
+
raise Ace::Overseer::Error, "cannot resolve the project root: '#{cwd}' does not exist"
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def git_output(cwd, argv)
|
|
41
|
+
result = @runner.call(argv)
|
|
42
|
+
unless result["status"] == "ok"
|
|
43
|
+
raise Ace::Overseer::Error,
|
|
44
|
+
"cannot resolve the project root under '#{cwd}': run " \
|
|
45
|
+
"ace-overseer start inside the configured project repository"
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
line = result["out"].to_s.lines.first.to_s.strip
|
|
49
|
+
raise Ace::Overseer::Error, "git returned no root for '#{cwd}'" if line.empty?
|
|
50
|
+
|
|
51
|
+
line
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def run_argv(argv)
|
|
55
|
+
out, err, status = Open3.capture3(*argv)
|
|
56
|
+
{"status" => status.success? ? "ok" : (status.exitstatus || "signal"),
|
|
57
|
+
"out" => out, "err" => err}
|
|
58
|
+
rescue Errno::ENOENT
|
|
59
|
+
{"status" => "enoent", "out" => "", "err" => ""}
|
|
60
|
+
rescue SystemCallError => e
|
|
61
|
+
{"status" => "exec_failed", "out" => "", "err" => e.class.name}
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "json"
|
|
5
|
+
|
|
6
|
+
module Ace
|
|
7
|
+
module Overseer
|
|
8
|
+
module Molecules
|
|
9
|
+
# Remote PR/repo observation (task 8xs.t.pu0.4): projects configured
|
|
10
|
+
# pull requests through the EXISTING forge provider adapters
|
|
11
|
+
# (Ace::Git::Providers.for — Forgejo and GitHub; no separate HTTP
|
|
12
|
+
# client here). Observed PRs come from the project's own
|
|
13
|
+
# configuration (`observe.remote.pull_requests` in the overseer
|
|
14
|
+
# cascade) — never a scan of every accessible account.
|
|
15
|
+
#
|
|
16
|
+
# Rows follow the standing-observation contract of LocalSources:
|
|
17
|
+
# stable key + SEMANTIC revision (state, head SHA, review/comment
|
|
18
|
+
# identity sets — never poll timestamps or list order), so repeated
|
|
19
|
+
# reads of identical state project identical revisions (zero model
|
|
20
|
+
# turns) while a new HEAD, review, approval, comment, merge, close
|
|
21
|
+
# or reopen revises exactly its row. The row carries the wake
|
|
22
|
+
# payload: forge, PR number/title, event kind, author, link.
|
|
23
|
+
#
|
|
24
|
+
# A failing forge (401/403, rate limit, timeout, malformed payload)
|
|
25
|
+
# degrades ONLY its source — a marker revision keyed by error
|
|
26
|
+
# class wakes once per failure mode, never as an empty healthy
|
|
27
|
+
# list — and recovery re-observes the real state.
|
|
28
|
+
class RemoteSources
|
|
29
|
+
def initialize(provider_factory: nil, timeout: nil)
|
|
30
|
+
@provider_factory = provider_factory || ->(server_name) {
|
|
31
|
+
server = Ace::Git::ServerRegistry.resolve(server_name)
|
|
32
|
+
Ace::Git::Providers.for(server, timeout: timeout)
|
|
33
|
+
}
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @param pull_requests [Array<Hash>] {"server" =>, "number" =>}
|
|
37
|
+
# entries from the project's observe.remote configuration
|
|
38
|
+
# @return [Hash] {"events" => Array, "sources" => {name => health}}
|
|
39
|
+
def call(pull_requests:)
|
|
40
|
+
sources = {}
|
|
41
|
+
events = []
|
|
42
|
+
|
|
43
|
+
# Validate entries BEFORE grouping: a malformed entry (non-Hash,
|
|
44
|
+
# missing server/number) must degrade its group to a health
|
|
45
|
+
# marker, never crash the whole observation pass.
|
|
46
|
+
valid, invalid = Array(pull_requests).partition do |entry|
|
|
47
|
+
entry.is_a?(Hash) && !entry["server"].to_s.empty? && !entry["number"].to_s.empty?
|
|
48
|
+
end
|
|
49
|
+
invalid.group_by { |entry| entry.is_a?(Hash) ? entry["server"].to_s : "" }.each do |server, entries|
|
|
50
|
+
if server.empty?
|
|
51
|
+
# Non-Hash or server-less entries still surface as health
|
|
52
|
+
# markers, never silently disappear.
|
|
53
|
+
sources["observe.remote"] ||= {"state" => "degraded",
|
|
54
|
+
"reason" => "invalid observe.remote.pull_requests entry: #{entries.first.inspect}"}
|
|
55
|
+
else
|
|
56
|
+
sources["pr:#{server}"] ||= {"state" => "degraded",
|
|
57
|
+
"reason" => "invalid observe.remote.pull_requests entry: #{entries.first.inspect}"}
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
valid.group_by { |entry| entry["server"] }.each do |server, entries|
|
|
62
|
+
collect(sources, events, "pr:#{server}") do
|
|
63
|
+
entries.flat_map { |entry| project(server, entry["number"]) }
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
{"events" => events, "sources" => sources}
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def collect(sources, events, name)
|
|
73
|
+
rows = yield
|
|
74
|
+
sources[name] = {"status" => "ok"}
|
|
75
|
+
events.concat(rows)
|
|
76
|
+
rescue StandardError => e
|
|
77
|
+
sources[name] = {"status" => "degraded", "error" => "#{e.class}: #{e.message}"}
|
|
78
|
+
events << {"key" => "source:#{name}",
|
|
79
|
+
"revision" => Digest::SHA256.hexdigest("degraded:#{e.class}"),
|
|
80
|
+
"type" => "source-health", "status" => "degraded",
|
|
81
|
+
"error" => "#{e.class}: #{e.message}"}
|
|
82
|
+
end
|
|
83
|
+
|
|
84
|
+
def project(server, number)
|
|
85
|
+
provider = @provider_factory.call(server)
|
|
86
|
+
pr = provider.pull_request(number: number)
|
|
87
|
+
return [] if pr.nil?
|
|
88
|
+
|
|
89
|
+
rows = [pr_row(server, pr)]
|
|
90
|
+
evidence = provider.pull_request_review_evidence(number: number, expected_head: pr.head_sha)
|
|
91
|
+
rows << activity_row(server, pr, evidence) if evidence
|
|
92
|
+
rows
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
# The PR's own lifecycle: new HEAD (head_sha), merge, close,
|
|
96
|
+
# reopen, title/draft changes. Read fresh before any merge
|
|
97
|
+
# decision — remote data stays data.
|
|
98
|
+
def pr_row(server, pr)
|
|
99
|
+
payload = {"number" => pr.number, "state" => pr.state.to_s,
|
|
100
|
+
"head_sha" => pr.head_sha, "draft" => pr.draft,
|
|
101
|
+
"title" => pr.title, "merged_at" => pr.merged_at,
|
|
102
|
+
"merge_commit_sha" => pr.merge_commit_sha}
|
|
103
|
+
{"key" => "pr:#{server}:#{pr.number}", "revision" => digest(payload),
|
|
104
|
+
"type" => "pr", "forge" => server, "number" => pr.number,
|
|
105
|
+
"title" => pr.title, "state" => pr.state.to_s, "head_sha" => pr.head_sha,
|
|
106
|
+
"author" => pr.author, "url" => pr.url}
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Review/comment/approval activity: the revision covers the
|
|
110
|
+
# SORTED identity set (id, author, state, head binding) of every
|
|
111
|
+
# review and comment — re-ordered payloads and volatile counters
|
|
112
|
+
# never revise; a new verdict, approval or comment revises once.
|
|
113
|
+
# The row's summary names the latest item for the wake payload.
|
|
114
|
+
def activity_row(server, pr, evidence)
|
|
115
|
+
reviews = Array(evidence.reviews).map { |review|
|
|
116
|
+
{"id" => review.id.to_s, "author" => review.author, "state" => review.state.to_s,
|
|
117
|
+
"head_sha" => review.head_sha, "kind" => "review"}
|
|
118
|
+
}
|
|
119
|
+
comments = Array(evidence.comments).map { |comment|
|
|
120
|
+
{"id" => comment.id.to_s, "author" => comment.author, "state" => nil,
|
|
121
|
+
"head_sha" => comment.head_sha, "kind" => "comment"}
|
|
122
|
+
}
|
|
123
|
+
items = (reviews + comments).sort_by { |item| item["id"] }
|
|
124
|
+
latest = items.last
|
|
125
|
+
|
|
126
|
+
{"key" => "pr-activity:#{server}:#{pr.number}", "revision" => digest(items),
|
|
127
|
+
"type" => "pr-activity", "forge" => server, "number" => pr.number,
|
|
128
|
+
"title" => pr.title, "event_kind" => latest && latest["kind"],
|
|
129
|
+
"event_id" => latest && latest["id"], "author" => latest && latest["author"],
|
|
130
|
+
"url" => pr.url, "items" => items.size}
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def digest(payload)
|
|
134
|
+
Digest::SHA256.hexdigest(JSON.generate(payload))
|
|
135
|
+
end
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
end
|
|
139
|
+
end
|