aidp 0.15.2 → 0.16.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/README.md +47 -0
- data/lib/aidp/analyze/error_handler.rb +14 -15
- data/lib/aidp/analyze/runner.rb +27 -5
- data/lib/aidp/analyze/steps.rb +4 -0
- data/lib/aidp/cli/jobs_command.rb +2 -1
- data/lib/aidp/cli.rb +812 -3
- data/lib/aidp/concurrency/backoff.rb +148 -0
- data/lib/aidp/concurrency/exec.rb +192 -0
- data/lib/aidp/concurrency/wait.rb +148 -0
- data/lib/aidp/concurrency.rb +71 -0
- data/lib/aidp/config.rb +20 -0
- data/lib/aidp/daemon/runner.rb +9 -8
- data/lib/aidp/debug_mixin.rb +1 -0
- data/lib/aidp/errors.rb +12 -0
- data/lib/aidp/execute/interactive_repl.rb +102 -11
- data/lib/aidp/execute/repl_macros.rb +776 -2
- data/lib/aidp/execute/runner.rb +27 -5
- data/lib/aidp/execute/steps.rb +2 -0
- data/lib/aidp/harness/config_loader.rb +24 -2
- data/lib/aidp/harness/enhanced_runner.rb +16 -2
- data/lib/aidp/harness/error_handler.rb +1 -1
- data/lib/aidp/harness/provider_info.rb +19 -15
- data/lib/aidp/harness/provider_manager.rb +47 -41
- data/lib/aidp/harness/runner.rb +3 -11
- data/lib/aidp/harness/state/persistence.rb +1 -6
- data/lib/aidp/harness/state_manager.rb +115 -7
- data/lib/aidp/harness/status_display.rb +11 -18
- data/lib/aidp/harness/ui/navigation/submenu.rb +1 -0
- data/lib/aidp/harness/ui/workflow_controller.rb +1 -1
- data/lib/aidp/harness/user_interface.rb +12 -15
- data/lib/aidp/jobs/background_runner.rb +15 -5
- data/lib/aidp/providers/codex.rb +0 -1
- data/lib/aidp/providers/cursor.rb +0 -1
- data/lib/aidp/providers/github_copilot.rb +0 -1
- data/lib/aidp/providers/opencode.rb +0 -1
- data/lib/aidp/skills/composer.rb +178 -0
- data/lib/aidp/skills/loader.rb +205 -0
- data/lib/aidp/skills/registry.rb +220 -0
- data/lib/aidp/skills/skill.rb +174 -0
- data/lib/aidp/skills.rb +30 -0
- data/lib/aidp/version.rb +1 -1
- data/lib/aidp/watch/build_processor.rb +93 -28
- data/lib/aidp/watch/runner.rb +3 -2
- data/lib/aidp/workstream_executor.rb +244 -0
- data/lib/aidp/workstream_state.rb +212 -0
- data/lib/aidp/worktree.rb +208 -0
- data/lib/aidp.rb +6 -0
- metadata +17 -4
@@ -0,0 +1,244 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "concurrent-ruby"
|
4
|
+
require "time"
|
5
|
+
require_relative "worktree"
|
6
|
+
require_relative "workstream_state"
|
7
|
+
require_relative "harness/runner"
|
8
|
+
require_relative "message_display"
|
9
|
+
|
10
|
+
module Aidp
|
11
|
+
# Executes multiple workstreams in parallel using concurrent-ruby.
|
12
|
+
# Provides true parallel execution with process isolation and status tracking.
|
13
|
+
class WorkstreamExecutor
|
14
|
+
include Aidp::MessageDisplay
|
15
|
+
|
16
|
+
# Result from executing a workstream
|
17
|
+
WorkstreamResult = Struct.new(
|
18
|
+
:slug,
|
19
|
+
:status,
|
20
|
+
:exit_code,
|
21
|
+
:started_at,
|
22
|
+
:completed_at,
|
23
|
+
:duration,
|
24
|
+
:error,
|
25
|
+
keyword_init: true
|
26
|
+
)
|
27
|
+
|
28
|
+
def initialize(project_dir: Dir.pwd, max_concurrent: 3)
|
29
|
+
@project_dir = project_dir
|
30
|
+
@max_concurrent = max_concurrent
|
31
|
+
@results = Concurrent::Hash.new
|
32
|
+
@start_times = Concurrent::Hash.new
|
33
|
+
end
|
34
|
+
|
35
|
+
# Execute multiple workstreams in parallel
|
36
|
+
#
|
37
|
+
# @param slugs [Array<String>] Workstream slugs to execute
|
38
|
+
# @param options [Hash] Execution options
|
39
|
+
# @option options [Array<String>] :selected_steps Steps to execute
|
40
|
+
# @option options [Symbol] :workflow_type Workflow type (:execute, :analyze, etc.)
|
41
|
+
# @option options [Hash] :user_input User input for harness
|
42
|
+
# @return [Array<WorkstreamResult>] Results for each workstream
|
43
|
+
def execute_parallel(slugs, options = {})
|
44
|
+
validate_workstreams!(slugs)
|
45
|
+
|
46
|
+
display_message("🚀 Starting parallel execution of #{slugs.size} workstreams (max #{@max_concurrent} concurrent)", type: :info)
|
47
|
+
|
48
|
+
# Create thread pool with max concurrent limit
|
49
|
+
pool = Concurrent::FixedThreadPool.new(@max_concurrent)
|
50
|
+
|
51
|
+
# Create futures for each workstream
|
52
|
+
futures = slugs.map do |slug|
|
53
|
+
Concurrent::Future.execute(executor: pool) do
|
54
|
+
execute_workstream(slug, options)
|
55
|
+
end
|
56
|
+
end
|
57
|
+
|
58
|
+
# Wait for all futures to complete
|
59
|
+
results = futures.map(&:value)
|
60
|
+
|
61
|
+
# Shutdown pool gracefully
|
62
|
+
pool.shutdown
|
63
|
+
pool.wait_for_termination(30)
|
64
|
+
|
65
|
+
display_execution_summary(results)
|
66
|
+
results
|
67
|
+
end
|
68
|
+
|
69
|
+
# Execute all active workstreams in parallel
|
70
|
+
#
|
71
|
+
# @param options [Hash] Execution options (same as execute_parallel)
|
72
|
+
# @return [Array<WorkstreamResult>] Results for each workstream
|
73
|
+
def execute_all(options = {})
|
74
|
+
workstreams = Aidp::Worktree.list(project_dir: @project_dir)
|
75
|
+
active_slugs = workstreams.select { |ws| ws[:active] }.map { |ws| ws[:slug] }
|
76
|
+
|
77
|
+
if active_slugs.empty?
|
78
|
+
display_message("⚠️ No active workstreams found", type: :warn)
|
79
|
+
return []
|
80
|
+
end
|
81
|
+
|
82
|
+
execute_parallel(active_slugs, options)
|
83
|
+
end
|
84
|
+
|
85
|
+
# Execute a single workstream (used by futures in parallel execution)
|
86
|
+
#
|
87
|
+
# @param slug [String] Workstream slug
|
88
|
+
# @param options [Hash] Execution options
|
89
|
+
# @return [WorkstreamResult] Execution result
|
90
|
+
def execute_workstream(slug, options = {})
|
91
|
+
started_at = Time.now
|
92
|
+
@start_times[slug] = started_at
|
93
|
+
|
94
|
+
workstream = Aidp::Worktree.info(slug: slug, project_dir: @project_dir)
|
95
|
+
unless workstream
|
96
|
+
return WorkstreamResult.new(
|
97
|
+
slug: slug,
|
98
|
+
status: "error",
|
99
|
+
exit_code: 1,
|
100
|
+
started_at: started_at,
|
101
|
+
completed_at: Time.now,
|
102
|
+
duration: 0,
|
103
|
+
error: "Workstream not found"
|
104
|
+
)
|
105
|
+
end
|
106
|
+
|
107
|
+
display_message("▶️ [#{slug}] Starting execution in #{workstream[:path]}", type: :info)
|
108
|
+
|
109
|
+
# Update workstream state to active
|
110
|
+
Aidp::WorkstreamState.update(
|
111
|
+
slug: slug,
|
112
|
+
project_dir: @project_dir,
|
113
|
+
status: "active",
|
114
|
+
started_at: started_at.utc.iso8601
|
115
|
+
)
|
116
|
+
|
117
|
+
# Execute in forked process for true isolation
|
118
|
+
pid = fork do
|
119
|
+
# Change to workstream directory
|
120
|
+
Dir.chdir(workstream[:path])
|
121
|
+
|
122
|
+
# Execute harness
|
123
|
+
runner = Aidp::Harness::Runner.new(
|
124
|
+
workstream[:path],
|
125
|
+
options[:mode] || :execute,
|
126
|
+
options
|
127
|
+
)
|
128
|
+
|
129
|
+
result = runner.run
|
130
|
+
|
131
|
+
# Update state on completion
|
132
|
+
exit_code = (result[:status] == "completed") ? 0 : 1
|
133
|
+
final_status = (result[:status] == "completed") ? "completed" : "failed"
|
134
|
+
|
135
|
+
Aidp::WorkstreamState.update(
|
136
|
+
slug: slug,
|
137
|
+
project_dir: @project_dir,
|
138
|
+
status: final_status,
|
139
|
+
completed_at: Time.now.utc.iso8601
|
140
|
+
)
|
141
|
+
|
142
|
+
exit(exit_code)
|
143
|
+
rescue => e
|
144
|
+
# Update state on error
|
145
|
+
Aidp::WorkstreamState.update(
|
146
|
+
slug: slug,
|
147
|
+
project_dir: @project_dir,
|
148
|
+
status: "failed",
|
149
|
+
completed_at: Time.now.utc.iso8601
|
150
|
+
)
|
151
|
+
|
152
|
+
# Log error and exit
|
153
|
+
warn("Error in workstream #{slug}: #{e.message}")
|
154
|
+
warn(e.backtrace.first(5).join("\n"))
|
155
|
+
exit(1)
|
156
|
+
end
|
157
|
+
|
158
|
+
# Wait for child process
|
159
|
+
_pid, status = Process.wait2(pid)
|
160
|
+
completed_at = Time.now
|
161
|
+
duration = completed_at - started_at
|
162
|
+
|
163
|
+
# Build result
|
164
|
+
result_status = status.success? ? "completed" : "failed"
|
165
|
+
result = WorkstreamResult.new(
|
166
|
+
slug: slug,
|
167
|
+
status: result_status,
|
168
|
+
exit_code: status.exitstatus,
|
169
|
+
started_at: started_at,
|
170
|
+
completed_at: completed_at,
|
171
|
+
duration: duration,
|
172
|
+
error: status.success? ? nil : "Process exited with code #{status.exitstatus}"
|
173
|
+
)
|
174
|
+
|
175
|
+
@results[slug] = result
|
176
|
+
|
177
|
+
display_message("#{status.success? ? "✅" : "❌"} [#{slug}] #{result_status.capitalize} in #{format_duration(duration)}", type: status.success? ? :success : :error)
|
178
|
+
|
179
|
+
result
|
180
|
+
rescue => e
|
181
|
+
completed_at = Time.now
|
182
|
+
duration = completed_at - started_at
|
183
|
+
|
184
|
+
WorkstreamResult.new(
|
185
|
+
slug: slug,
|
186
|
+
status: "error",
|
187
|
+
exit_code: 1,
|
188
|
+
started_at: started_at,
|
189
|
+
completed_at: completed_at,
|
190
|
+
duration: duration,
|
191
|
+
error: e.message
|
192
|
+
)
|
193
|
+
end
|
194
|
+
|
195
|
+
private
|
196
|
+
|
197
|
+
# Validate that all workstreams exist
|
198
|
+
def validate_workstreams!(slugs)
|
199
|
+
invalid = slugs.reject do |slug|
|
200
|
+
Aidp::Worktree.exists?(slug: slug, project_dir: @project_dir)
|
201
|
+
end
|
202
|
+
|
203
|
+
unless invalid.empty?
|
204
|
+
raise ArgumentError, "Workstreams not found: #{invalid.join(", ")}"
|
205
|
+
end
|
206
|
+
end
|
207
|
+
|
208
|
+
# Display execution summary
|
209
|
+
def display_execution_summary(results)
|
210
|
+
completed = results.count { |r| r.status == "completed" }
|
211
|
+
failed = results.count { |r| r.status == "failed" || r.status == "error" }
|
212
|
+
total_duration = results.sum(&:duration)
|
213
|
+
|
214
|
+
display_message("\n" + "=" * 60, type: :muted)
|
215
|
+
display_message("📊 Execution Summary", type: :info)
|
216
|
+
display_message("Total: #{results.size} | Completed: #{completed} | Failed: #{failed}", type: :info)
|
217
|
+
display_message("Total Duration: #{format_duration(total_duration)}", type: :info)
|
218
|
+
|
219
|
+
if failed > 0
|
220
|
+
display_message("\n❌ Failed Workstreams:", type: :error)
|
221
|
+
results.select { |r| r.status != "completed" }.each do |result|
|
222
|
+
display_message(" - #{result.slug}: #{result.error}", type: :error)
|
223
|
+
end
|
224
|
+
end
|
225
|
+
|
226
|
+
display_message("=" * 60, type: :muted)
|
227
|
+
end
|
228
|
+
|
229
|
+
# Format duration in human-readable format
|
230
|
+
def format_duration(seconds)
|
231
|
+
if seconds < 60
|
232
|
+
"#{seconds.round(1)}s"
|
233
|
+
elsif seconds < 3600
|
234
|
+
minutes = (seconds / 60).floor
|
235
|
+
secs = (seconds % 60).round
|
236
|
+
"#{minutes}m #{secs}s"
|
237
|
+
else
|
238
|
+
hours = (seconds / 3600).floor
|
239
|
+
minutes = ((seconds % 3600) / 60).floor
|
240
|
+
"#{hours}h #{minutes}m"
|
241
|
+
end
|
242
|
+
end
|
243
|
+
end
|
244
|
+
end
|
@@ -0,0 +1,212 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "json"
|
4
|
+
require "fileutils"
|
5
|
+
|
6
|
+
module Aidp
|
7
|
+
# Manages per-workstream state (task, iterations, timestamps, event log)
|
8
|
+
# Stored under: .aidp/workstreams/<slug>/state.json and history.jsonl
|
9
|
+
module WorkstreamState
|
10
|
+
class Error < StandardError; end
|
11
|
+
|
12
|
+
class << self
|
13
|
+
def root_dir(project_dir)
|
14
|
+
File.join(project_dir, ".aidp", "workstreams")
|
15
|
+
end
|
16
|
+
|
17
|
+
def workstream_dir(slug, project_dir)
|
18
|
+
File.join(root_dir(project_dir), slug)
|
19
|
+
end
|
20
|
+
|
21
|
+
def state_file(slug, project_dir)
|
22
|
+
File.join(workstream_dir(slug, project_dir), "state.json")
|
23
|
+
end
|
24
|
+
|
25
|
+
def history_file(slug, project_dir)
|
26
|
+
File.join(workstream_dir(slug, project_dir), "history.jsonl")
|
27
|
+
end
|
28
|
+
|
29
|
+
# Per-worktree state files (mirrored for local inspection)
|
30
|
+
def worktree_state_file(slug, project_dir)
|
31
|
+
worktree_path = File.join(project_dir, ".worktrees", slug)
|
32
|
+
return nil unless Dir.exist?(worktree_path)
|
33
|
+
File.join(worktree_path, ".aidp", "workstreams", slug, "state.json")
|
34
|
+
end
|
35
|
+
|
36
|
+
def worktree_history_file(slug, project_dir)
|
37
|
+
worktree_path = File.join(project_dir, ".worktrees", slug)
|
38
|
+
return nil unless Dir.exist?(worktree_path)
|
39
|
+
File.join(worktree_path, ".aidp", "workstreams", slug, "history.jsonl")
|
40
|
+
end
|
41
|
+
|
42
|
+
# Initialize state for a new workstream
|
43
|
+
def init(slug:, project_dir:, task: nil)
|
44
|
+
dir = workstream_dir(slug, project_dir)
|
45
|
+
FileUtils.mkdir_p(dir)
|
46
|
+
now = Time.now.utc
|
47
|
+
state = {
|
48
|
+
slug: slug,
|
49
|
+
status: "active",
|
50
|
+
task: task,
|
51
|
+
started_at: now.iso8601,
|
52
|
+
updated_at: now.iso8601,
|
53
|
+
iterations: 0
|
54
|
+
}
|
55
|
+
write_json(state_file(slug, project_dir), state)
|
56
|
+
# Mirror to worktree if it exists
|
57
|
+
mirror_to_worktree(slug, project_dir, state)
|
58
|
+
append_event(slug: slug, project_dir: project_dir, type: "created", data: {task: task})
|
59
|
+
state
|
60
|
+
end
|
61
|
+
|
62
|
+
# Read current state (returns hash or nil)
|
63
|
+
def read(slug:, project_dir:)
|
64
|
+
file = state_file(slug, project_dir)
|
65
|
+
return nil unless File.exist?(file)
|
66
|
+
JSON.parse(File.read(file), symbolize_names: true)
|
67
|
+
rescue JSON::ParserError
|
68
|
+
nil
|
69
|
+
end
|
70
|
+
|
71
|
+
# Update selected attributes; updates updated_at automatically
|
72
|
+
def update(slug:, project_dir:, **attrs)
|
73
|
+
state = read(slug: slug, project_dir: project_dir) || init(slug: slug, project_dir: project_dir)
|
74
|
+
state.merge!(attrs.transform_keys(&:to_sym))
|
75
|
+
state[:updated_at] = Time.now.utc.iso8601
|
76
|
+
write_json(state_file(slug, project_dir), state)
|
77
|
+
# Mirror to worktree if it exists
|
78
|
+
mirror_to_worktree(slug, project_dir, state)
|
79
|
+
state
|
80
|
+
end
|
81
|
+
|
82
|
+
# Increment iteration counter and record event
|
83
|
+
def increment_iteration(slug:, project_dir:)
|
84
|
+
state = read(slug: slug, project_dir: project_dir) || init(slug: slug, project_dir: project_dir)
|
85
|
+
state[:iterations] = (state[:iterations] || 0) + 1
|
86
|
+
state[:updated_at] = Time.now.utc.iso8601
|
87
|
+
# Update status to active if paused (auto-resume on iteration)
|
88
|
+
state[:status] = "active" if state[:status] == "paused"
|
89
|
+
write_json(state_file(slug, project_dir), state)
|
90
|
+
# Mirror to worktree if it exists
|
91
|
+
mirror_to_worktree(slug, project_dir, state)
|
92
|
+
append_event(slug: slug, project_dir: project_dir, type: "iteration", data: {count: state[:iterations]})
|
93
|
+
state
|
94
|
+
end
|
95
|
+
|
96
|
+
# Append event to history.jsonl
|
97
|
+
def append_event(slug:, project_dir:, type:, data: {})
|
98
|
+
file = history_file(slug, project_dir)
|
99
|
+
FileUtils.mkdir_p(File.dirname(file))
|
100
|
+
event = {
|
101
|
+
timestamp: Time.now.utc.iso8601,
|
102
|
+
type: type,
|
103
|
+
data: data
|
104
|
+
}
|
105
|
+
File.open(file, "a") { |f| f.puts(JSON.generate(event)) }
|
106
|
+
# Mirror to worktree if it exists
|
107
|
+
wt_file = worktree_history_file(slug, project_dir)
|
108
|
+
if wt_file
|
109
|
+
FileUtils.mkdir_p(File.dirname(wt_file))
|
110
|
+
File.open(wt_file, "a") { |f| f.puts(JSON.generate(event)) }
|
111
|
+
end
|
112
|
+
event
|
113
|
+
end
|
114
|
+
|
115
|
+
# Read recent N events
|
116
|
+
def recent_events(slug:, project_dir:, limit: 5)
|
117
|
+
file = history_file(slug, project_dir)
|
118
|
+
return [] unless File.exist?(file)
|
119
|
+
lines = File.readlines(file, chomp: true)
|
120
|
+
lines.last(limit).map do |line|
|
121
|
+
JSON.parse(line, symbolize_names: true)
|
122
|
+
rescue JSON::ParserError
|
123
|
+
nil
|
124
|
+
end.compact
|
125
|
+
end
|
126
|
+
|
127
|
+
def elapsed_seconds(slug:, project_dir:)
|
128
|
+
state = read(slug: slug, project_dir: project_dir)
|
129
|
+
return 0 unless state && state[:started_at]
|
130
|
+
(Time.now.utc - Time.parse(state[:started_at])).to_i
|
131
|
+
end
|
132
|
+
|
133
|
+
# Check if workstream appears stalled (no activity for threshold seconds)
|
134
|
+
def stalled?(slug:, project_dir:, threshold_seconds: 3600)
|
135
|
+
state = read(slug: slug, project_dir: project_dir)
|
136
|
+
return false unless state && state[:updated_at]
|
137
|
+
return false if state[:status] != "active" # Only check active workstreams
|
138
|
+
(Time.now.utc - Time.parse(state[:updated_at])).to_i > threshold_seconds
|
139
|
+
end
|
140
|
+
|
141
|
+
# Auto-complete stalled workstreams
|
142
|
+
def auto_complete_stalled(slug:, project_dir:, threshold_seconds: 3600)
|
143
|
+
return unless stalled?(slug: slug, project_dir: project_dir, threshold_seconds: threshold_seconds)
|
144
|
+
complete(slug: slug, project_dir: project_dir)
|
145
|
+
append_event(slug: slug, project_dir: project_dir, type: "auto_completed", data: {reason: "stalled"})
|
146
|
+
end
|
147
|
+
|
148
|
+
def mark_removed(slug:, project_dir:)
|
149
|
+
state = read(slug: slug, project_dir: project_dir)
|
150
|
+
# Auto-complete if active when removing
|
151
|
+
if state && state[:status] == "active"
|
152
|
+
complete(slug: slug, project_dir: project_dir)
|
153
|
+
end
|
154
|
+
update(slug: slug, project_dir: project_dir, status: "removed")
|
155
|
+
append_event(slug: slug, project_dir: project_dir, type: "removed", data: {})
|
156
|
+
end
|
157
|
+
|
158
|
+
# Pause workstream (stop iteration without completion)
|
159
|
+
def pause(slug:, project_dir:)
|
160
|
+
state = read(slug: slug, project_dir: project_dir)
|
161
|
+
return {error: "Workstream not found"} unless state
|
162
|
+
return {error: "Already paused"} if state[:status] == "paused"
|
163
|
+
|
164
|
+
now = Time.now.utc.iso8601
|
165
|
+
update(slug: slug, project_dir: project_dir, status: "paused", paused_at: now)
|
166
|
+
append_event(slug: slug, project_dir: project_dir, type: "paused", data: {})
|
167
|
+
{status: "paused"}
|
168
|
+
end
|
169
|
+
|
170
|
+
# Resume workstream (return to active status)
|
171
|
+
def resume(slug:, project_dir:)
|
172
|
+
state = read(slug: slug, project_dir: project_dir)
|
173
|
+
return {error: "Workstream not found"} unless state
|
174
|
+
return {error: "Not paused"} unless state[:status] == "paused"
|
175
|
+
|
176
|
+
now = Time.now.utc.iso8601
|
177
|
+
update(slug: slug, project_dir: project_dir, status: "active", resumed_at: now)
|
178
|
+
append_event(slug: slug, project_dir: project_dir, type: "resumed", data: {})
|
179
|
+
{status: "active"}
|
180
|
+
end
|
181
|
+
|
182
|
+
# Mark workstream as completed
|
183
|
+
def complete(slug:, project_dir:)
|
184
|
+
state = read(slug: slug, project_dir: project_dir)
|
185
|
+
return {error: "Workstream not found"} unless state
|
186
|
+
return {error: "Already completed"} if state[:status] == "completed"
|
187
|
+
|
188
|
+
now = Time.now.utc.iso8601
|
189
|
+
update(slug: slug, project_dir: project_dir, status: "completed", completed_at: now)
|
190
|
+
append_event(slug: slug, project_dir: project_dir, type: "completed", data: {iterations: state[:iterations]})
|
191
|
+
{status: "completed"}
|
192
|
+
end
|
193
|
+
|
194
|
+
private
|
195
|
+
|
196
|
+
def write_json(path, obj)
|
197
|
+
File.write(path, JSON.pretty_generate(obj))
|
198
|
+
end
|
199
|
+
|
200
|
+
# Mirror state to worktree's .aidp directory for local visibility
|
201
|
+
def mirror_to_worktree(slug, project_dir, state)
|
202
|
+
wt_file = worktree_state_file(slug, project_dir)
|
203
|
+
return unless wt_file
|
204
|
+
FileUtils.mkdir_p(File.dirname(wt_file))
|
205
|
+
write_json(wt_file, state)
|
206
|
+
rescue
|
207
|
+
# Silently ignore mirroring errors to not disrupt main operation
|
208
|
+
nil
|
209
|
+
end
|
210
|
+
end
|
211
|
+
end
|
212
|
+
end
|
@@ -0,0 +1,208 @@
|
|
1
|
+
# frozen_string_literal: true
|
2
|
+
|
3
|
+
require "fileutils"
|
4
|
+
require "json"
|
5
|
+
require_relative "workstream_state"
|
6
|
+
|
7
|
+
module Aidp
|
8
|
+
# Manages git worktree operations for parallel workstreams.
|
9
|
+
# Each workstream gets an isolated git worktree with its own branch,
|
10
|
+
# allowing multiple agents to work concurrently without conflicts.
|
11
|
+
module Worktree
|
12
|
+
class Error < StandardError; end
|
13
|
+
class NotInGitRepo < Error; end
|
14
|
+
class WorktreeExists < Error; end
|
15
|
+
class WorktreeNotFound < Error; end
|
16
|
+
|
17
|
+
class << self
|
18
|
+
# Create a new git worktree for a workstream
|
19
|
+
#
|
20
|
+
# @param slug [String] Short identifier for this workstream (e.g., "iss-123-fix-login")
|
21
|
+
# @param project_dir [String] Project root directory
|
22
|
+
# @param branch [String, nil] Branch name (defaults to "aidp/#{slug}")
|
23
|
+
# @param base_branch [String] Branch to create from (defaults to current branch)
|
24
|
+
# @return [Hash] Worktree info: { path:, branch:, slug: }
|
25
|
+
def create(slug:, project_dir: Dir.pwd, branch: nil, base_branch: nil, task: nil)
|
26
|
+
ensure_git_repo!(project_dir)
|
27
|
+
|
28
|
+
branch ||= "aidp/#{slug}"
|
29
|
+
worktree_path = worktree_path_for(slug, project_dir)
|
30
|
+
|
31
|
+
if Dir.exist?(worktree_path)
|
32
|
+
raise WorktreeExists, "Worktree already exists at #{worktree_path}"
|
33
|
+
end
|
34
|
+
|
35
|
+
# Create the worktree
|
36
|
+
cmd = ["git", "worktree", "add", "-b", branch, worktree_path]
|
37
|
+
cmd << base_branch if base_branch
|
38
|
+
|
39
|
+
Dir.chdir(project_dir) do
|
40
|
+
success = system(*cmd, out: File::NULL, err: File::NULL)
|
41
|
+
unless success
|
42
|
+
raise Error, "Failed to create worktree: #{$?.exitstatus}"
|
43
|
+
end
|
44
|
+
end
|
45
|
+
|
46
|
+
# Initialize .aidp directory in the worktree
|
47
|
+
ensure_aidp_dir(worktree_path)
|
48
|
+
|
49
|
+
# Register the worktree
|
50
|
+
register_worktree(slug, worktree_path, branch, project_dir)
|
51
|
+
|
52
|
+
# Initialize per-workstream state (task, counters, events)
|
53
|
+
Aidp::WorkstreamState.init(slug: slug, project_dir: project_dir, task: task)
|
54
|
+
|
55
|
+
{
|
56
|
+
slug: slug,
|
57
|
+
path: worktree_path,
|
58
|
+
branch: branch
|
59
|
+
}
|
60
|
+
end
|
61
|
+
|
62
|
+
# List all active worktrees for this project
|
63
|
+
#
|
64
|
+
# @param project_dir [String] Project root directory
|
65
|
+
# @return [Array<Hash>] Array of worktree info hashes
|
66
|
+
def list(project_dir: Dir.pwd)
|
67
|
+
registry = load_registry(project_dir)
|
68
|
+
registry.map do |slug, info|
|
69
|
+
{
|
70
|
+
slug: slug,
|
71
|
+
path: info["path"],
|
72
|
+
branch: info["branch"],
|
73
|
+
created_at: info["created_at"],
|
74
|
+
active: Dir.exist?(info["path"])
|
75
|
+
}
|
76
|
+
end
|
77
|
+
end
|
78
|
+
|
79
|
+
# Remove a worktree and optionally its branch
|
80
|
+
#
|
81
|
+
# @param slug [String] Workstream identifier
|
82
|
+
# @param project_dir [String] Project root directory
|
83
|
+
# @param delete_branch [Boolean] Whether to delete the git branch
|
84
|
+
def remove(slug:, project_dir: Dir.pwd, delete_branch: false)
|
85
|
+
registry = load_registry(project_dir)
|
86
|
+
info = registry[slug]
|
87
|
+
|
88
|
+
raise WorktreeNotFound, "Worktree '#{slug}' not found" unless info
|
89
|
+
|
90
|
+
worktree_path = info["path"]
|
91
|
+
branch = info["branch"]
|
92
|
+
|
93
|
+
# Remove the git worktree
|
94
|
+
if Dir.exist?(worktree_path)
|
95
|
+
Dir.chdir(project_dir) do
|
96
|
+
system("git", "worktree", "remove", worktree_path, "--force", out: File::NULL, err: File::NULL)
|
97
|
+
end
|
98
|
+
end
|
99
|
+
|
100
|
+
# Remove the branch if requested
|
101
|
+
if delete_branch
|
102
|
+
Dir.chdir(project_dir) do
|
103
|
+
system("git", "branch", "-D", branch, out: File::NULL, err: File::NULL)
|
104
|
+
end
|
105
|
+
end
|
106
|
+
|
107
|
+
# Mark state removed (if exists) then unregister
|
108
|
+
if Aidp::WorkstreamState.read(slug: slug, project_dir: project_dir)
|
109
|
+
Aidp::WorkstreamState.mark_removed(slug: slug, project_dir: project_dir)
|
110
|
+
end
|
111
|
+
# Unregister the worktree
|
112
|
+
unregister_worktree(slug, project_dir)
|
113
|
+
|
114
|
+
true
|
115
|
+
end
|
116
|
+
|
117
|
+
# Get info for a specific worktree
|
118
|
+
#
|
119
|
+
# @param slug [String] Workstream identifier
|
120
|
+
# @param project_dir [String] Project root directory
|
121
|
+
# @return [Hash, nil] Worktree info or nil if not found
|
122
|
+
def info(slug:, project_dir: Dir.pwd)
|
123
|
+
registry = load_registry(project_dir)
|
124
|
+
data = registry[slug]
|
125
|
+
return nil unless data
|
126
|
+
|
127
|
+
{
|
128
|
+
slug: slug,
|
129
|
+
path: data["path"],
|
130
|
+
branch: data["branch"],
|
131
|
+
created_at: data["created_at"],
|
132
|
+
active: Dir.exist?(data["path"])
|
133
|
+
}
|
134
|
+
end
|
135
|
+
|
136
|
+
# Check if a worktree exists
|
137
|
+
#
|
138
|
+
# @param slug [String] Workstream identifier
|
139
|
+
# @param project_dir [String] Project root directory
|
140
|
+
# @return [Boolean]
|
141
|
+
def exists?(slug:, project_dir: Dir.pwd)
|
142
|
+
!info(slug: slug, project_dir: project_dir).nil?
|
143
|
+
end
|
144
|
+
|
145
|
+
private
|
146
|
+
|
147
|
+
# Ensure we're in a git repository
|
148
|
+
def ensure_git_repo!(project_dir)
|
149
|
+
Dir.chdir(project_dir) do
|
150
|
+
unless system("git", "rev-parse", "--git-dir", out: File::NULL, err: File::NULL)
|
151
|
+
raise NotInGitRepo, "Not in a git repository: #{project_dir}"
|
152
|
+
end
|
153
|
+
end
|
154
|
+
end
|
155
|
+
|
156
|
+
# Get the worktree path for a slug
|
157
|
+
def worktree_path_for(slug, project_dir)
|
158
|
+
File.join(project_dir, ".worktrees", slug)
|
159
|
+
end
|
160
|
+
|
161
|
+
# Ensure the .aidp directory exists in a worktree
|
162
|
+
def ensure_aidp_dir(worktree_path)
|
163
|
+
aidp_dir = File.join(worktree_path, ".aidp")
|
164
|
+
FileUtils.mkdir_p(aidp_dir) unless Dir.exist?(aidp_dir)
|
165
|
+
end
|
166
|
+
|
167
|
+
# Load the worktree registry
|
168
|
+
def load_registry(project_dir)
|
169
|
+
registry_file = registry_file_path(project_dir)
|
170
|
+
return {} unless File.exist?(registry_file)
|
171
|
+
|
172
|
+
JSON.parse(File.read(registry_file))
|
173
|
+
rescue JSON::ParserError
|
174
|
+
{}
|
175
|
+
end
|
176
|
+
|
177
|
+
# Save the worktree registry
|
178
|
+
def save_registry(registry, project_dir)
|
179
|
+
registry_file = registry_file_path(project_dir)
|
180
|
+
FileUtils.mkdir_p(File.dirname(registry_file))
|
181
|
+
File.write(registry_file, JSON.pretty_generate(registry))
|
182
|
+
end
|
183
|
+
|
184
|
+
# Register a new worktree
|
185
|
+
def register_worktree(slug, path, branch, project_dir)
|
186
|
+
registry = load_registry(project_dir)
|
187
|
+
registry[slug] = {
|
188
|
+
"path" => path,
|
189
|
+
"branch" => branch,
|
190
|
+
"created_at" => Time.now.utc.iso8601
|
191
|
+
}
|
192
|
+
save_registry(registry, project_dir)
|
193
|
+
end
|
194
|
+
|
195
|
+
# Unregister a worktree
|
196
|
+
def unregister_worktree(slug, project_dir)
|
197
|
+
registry = load_registry(project_dir)
|
198
|
+
registry.delete(slug)
|
199
|
+
save_registry(registry, project_dir)
|
200
|
+
end
|
201
|
+
|
202
|
+
# Path to the worktree registry file
|
203
|
+
def registry_file_path(project_dir)
|
204
|
+
File.join(project_dir, ".aidp", "worktrees.json")
|
205
|
+
end
|
206
|
+
end
|
207
|
+
end
|
208
|
+
end
|
data/lib/aidp.rb
CHANGED
@@ -8,6 +8,7 @@ require_relative "aidp/version"
|
|
8
8
|
require_relative "aidp/config"
|
9
9
|
require_relative "aidp/util"
|
10
10
|
require_relative "aidp/message_display"
|
11
|
+
require_relative "aidp/concurrency"
|
11
12
|
require_relative "aidp/setup/wizard"
|
12
13
|
require_relative "aidp/init"
|
13
14
|
require_relative "aidp/watch"
|
@@ -69,6 +70,11 @@ require_relative "aidp/logger"
|
|
69
70
|
require_relative "aidp/daemon/process_manager"
|
70
71
|
require_relative "aidp/daemon/runner"
|
71
72
|
|
73
|
+
# Workstream/worktree management
|
74
|
+
require_relative "aidp/worktree"
|
75
|
+
require_relative "aidp/workstream_state"
|
76
|
+
require_relative "aidp/workstream_executor"
|
77
|
+
|
72
78
|
# Harness mode
|
73
79
|
require_relative "aidp/harness/configuration"
|
74
80
|
require_relative "aidp/harness/config_schema"
|