ace-task 0.38.1 → 0.39.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 +15 -0
- data/handbook/workflow-instructions/task/draft.wf.md +18 -18
- data/lib/ace/task/atoms/task_frontmatter_defaults.rb +5 -2
- data/lib/ace/task/cli/commands/create.rb +20 -13
- data/lib/ace/task/cli/commands/issue_link.rb +38 -0
- data/lib/ace/task/cli/commands/issue_sync.rb +42 -0
- data/lib/ace/task/cli/commands/update.rb +2 -0
- data/lib/ace/task/cli.rb +9 -5
- data/lib/ace/task/molecules/issue_link.rb +75 -0
- data/lib/ace/task/molecules/issue_sync_adapter.rb +99 -0
- data/lib/ace/task/molecules/subtask_creator.rb +2 -2
- data/lib/ace/task/molecules/task_creator.rb +2 -2
- data/lib/ace/task/molecules/task_frontmatter_validator.rb +44 -18
- data/lib/ace/task/molecules/task_plan_prompt_builder.rb +3 -3
- data/lib/ace/task/molecules/task_reparenter.rb +66 -0
- data/lib/ace/task/molecules/task_resolver.rb +31 -3
- data/lib/ace/task/organisms/task_manager.rb +780 -107
- data/lib/ace/task/version.rb +1 -1
- data/lib/ace/task.rb +0 -1
- metadata +24 -8
- data/lib/ace/task/cli/commands/github_sync.rb +0 -73
- data/lib/ace/task/molecules/github_issue_sync_adapter.rb +0 -88
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "digest"
|
|
4
|
+
require "tmpdir"
|
|
5
|
+
|
|
3
6
|
require_relative "../molecules/task_config_loader"
|
|
4
7
|
require_relative "../molecules/task_scanner"
|
|
5
8
|
require_relative "../molecules/task_resolver"
|
|
@@ -7,9 +10,8 @@ require_relative "../molecules/task_loader"
|
|
|
7
10
|
require_relative "../molecules/task_creator"
|
|
8
11
|
require_relative "../molecules/subtask_creator"
|
|
9
12
|
require_relative "../molecules/task_reparenter"
|
|
10
|
-
require_relative "../molecules/
|
|
13
|
+
require_relative "../molecules/issue_link"
|
|
11
14
|
require_relative "../atoms/task_validation_rules"
|
|
12
|
-
|
|
13
15
|
module Ace
|
|
14
16
|
module Task
|
|
15
17
|
module Organisms
|
|
@@ -49,28 +51,71 @@ module Ace
|
|
|
49
51
|
dependencies: [],
|
|
50
52
|
use_llm_slug: false,
|
|
51
53
|
estimate: nil,
|
|
52
|
-
|
|
54
|
+
remote_issue: nil
|
|
53
55
|
)
|
|
54
56
|
ensure_root_dir
|
|
55
|
-
ensure_github_issue_linkable!(github_issue)
|
|
56
57
|
creator = Molecules::TaskCreator.new(root_dir: @root_dir, config: @config)
|
|
57
58
|
attempts = 0
|
|
58
59
|
|
|
60
|
+
# Authoritative validation precedes the task write: an offline or
|
|
61
|
+
# conflicting link must not create a task artifact at all. Offline
|
|
62
|
+
# validation is the one accepted pre-write failure mode - the task
|
|
63
|
+
# is then created with the complete pending link for replay.
|
|
64
|
+
ensure_issue_linkable!(remote_issue) if remote_issue
|
|
59
65
|
begin
|
|
60
66
|
attempts += 1
|
|
61
|
-
created_task =
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
67
|
+
created_task = nil
|
|
68
|
+
sync_started = false
|
|
69
|
+
begin
|
|
70
|
+
result = nil
|
|
71
|
+
# The canonical issue lock covers the local duplicate scan, the
|
|
72
|
+
# task write, and the sync: two concurrent creates for one
|
|
73
|
+
# issue serialize instead of both writing and both deleting.
|
|
74
|
+
with_issue_identity_lock(remote_issue) do
|
|
75
|
+
ensure_issue_not_linked_elsewhere!(remote_issue) if remote_issue
|
|
76
|
+
created_task = creator.create(
|
|
77
|
+
title,
|
|
78
|
+
status: status,
|
|
79
|
+
priority: priority,
|
|
80
|
+
tags: tags,
|
|
81
|
+
dependencies: dependencies,
|
|
82
|
+
use_llm_slug: use_llm_slug,
|
|
83
|
+
time: Time.now.utc + ((attempts - 1) * 2),
|
|
84
|
+
estimate: estimate,
|
|
85
|
+
remote_issue: remote_issue
|
|
86
|
+
)
|
|
87
|
+
ensure_issue_not_linked_elsewhere!(remote_issue, exclude_id: created_task.id) if remote_issue
|
|
88
|
+
sync_started = true
|
|
89
|
+
result = remote_issue ? sync_linked_issues_for(created_task, reason: "create") : nil
|
|
90
|
+
end
|
|
91
|
+
if result && result[:success] == false && result[:error].to_s.start_with?("Ace::Git::ProviderIdentityMismatchError") &&
|
|
92
|
+
!result[:committed_create]
|
|
93
|
+
# Ownership was rejected before any remote mutation: the task
|
|
94
|
+
# must not survive as a second local claimant for the issue.
|
|
95
|
+
FileUtils.rm_rf(created_task.path)
|
|
96
|
+
raise Ace::Git::ProviderIdentityMismatchError, result[:error].to_s
|
|
97
|
+
end
|
|
98
|
+
if result && result[:success] == false &&
|
|
99
|
+
result[:error].to_s.start_with?("Ace::Git::ProviderIdentityMismatchError")
|
|
100
|
+
# The mismatch surfaced after the tracking comment committed
|
|
101
|
+
# (e.g. a concurrent external marker): the remote record
|
|
102
|
+
# exists, so the task and its pending identity stay as the
|
|
103
|
+
# cleanup record for replay.
|
|
104
|
+
raise Ace::Git::ProviderIdentityMismatchError, result[:error].to_s
|
|
105
|
+
end
|
|
106
|
+
rescue Ace::Git::ProviderUnreachableError
|
|
107
|
+
# Offline sync retains the complete local link + pending flag:
|
|
108
|
+
# offline replay is the spec-mandated recovery path.
|
|
109
|
+
raise
|
|
110
|
+
rescue StandardError
|
|
111
|
+
# Confirmed rejections (ownership conflict, unknown server) must
|
|
112
|
+
# not leave a second local claimant for the issue. Once sync has
|
|
113
|
+
# begun the remote marker may exist: retain the task and its
|
|
114
|
+
# pending identity as the recovery record instead of deleting it.
|
|
115
|
+
FileUtils.rm_rf(created_task.path) if created_task && !sync_started
|
|
116
|
+
raise
|
|
117
|
+
end
|
|
118
|
+
show_after_sync(created_task) || created_task
|
|
74
119
|
rescue Molecules::TaskCreator::IdCollisionError
|
|
75
120
|
retry if attempts < CREATE_RETRY_LIMIT
|
|
76
121
|
raise CreateRetriesExhaustedError,
|
|
@@ -147,19 +192,136 @@ module Ace
|
|
|
147
192
|
|
|
148
193
|
# Apply field updates if any
|
|
149
194
|
has_field_updates = [set, add, remove].any? { |h| h && !h.empty? }
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
#
|
|
195
|
+
reject_issue_metadata_update!(set, add, remove)
|
|
196
|
+
# Linked tasks defer remote sync: persist the pending flag in the
|
|
197
|
+
# same write as the local change so a crash cannot lose the replay.
|
|
198
|
+
# The trigger mirrors sync_needed_after_update?: only changes that
|
|
199
|
+
# require remote reconciliation (title/status/path/identity) count.
|
|
200
|
+
# Reparents additionally persist the outgoing ID so replay can prove
|
|
201
|
+
# ownership of a marker written under the previous task ID.
|
|
202
|
+
# The deferred decision is computed under the issue + task locks
|
|
203
|
+
# from reloaded state so a concurrent clear/link cannot leave a
|
|
204
|
+
# pending flag with no remote_issue target.
|
|
205
|
+
sync_relevant_keys = [set, add, remove].compact.flat_map(&:keys).map(&:to_s)
|
|
206
|
+
sync_planned = move_to || move_as_child_of || (sync_relevant_keys & %w[title status]).any?
|
|
159
207
|
current_path = task.path
|
|
160
208
|
current_special = task.special_folder
|
|
161
209
|
current_id = task.id
|
|
210
|
+
linked = linked_issue(task)
|
|
211
|
+
# Every linked participant of this relocation — the task itself and
|
|
212
|
+
# each linked descendant, including descendants under an unlinked
|
|
213
|
+
# parent — holds its identity lock across the pending writes, the
|
|
214
|
+
# move, and the post-move syncs, so no replay can clear a pending
|
|
215
|
+
# flag against the pre-move path mid-relocation. A subtask archive
|
|
216
|
+
# nests a parent update, so the parent's participants join the
|
|
217
|
+
# up-front sorted acquisition: every multi-lock path then follows
|
|
218
|
+
# one global order instead of child-then-parent hold-and-wait.
|
|
219
|
+
with_identity_locks(relocation_participants(task, move_to)) do
|
|
220
|
+
if linked && sync_planned
|
|
221
|
+
with_issue_identity_lock("task" => task.id) do
|
|
222
|
+
fresh = show(task.id) || task
|
|
223
|
+
linked_fresh = linked_issue(fresh)
|
|
224
|
+
# The participant locks were selected from the initially
|
|
225
|
+
# loaded task: a concurrent clear+re-link while this update
|
|
226
|
+
# waited must not proceed under the old issue's lock.
|
|
227
|
+
if linked_fresh != linked
|
|
228
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
229
|
+
"Task #{task.id} link changed during update; retry the command"
|
|
230
|
+
end
|
|
231
|
+
deferred_sync = linked_fresh && fresh.metadata["issue_sync_operation"] != "clear" && sync_planned
|
|
232
|
+
deferred_set = deferred_sync ? set.merge("issue_sync_pending" => true) : set
|
|
233
|
+
# A pending clear also needs the outgoing ID: its replay must
|
|
234
|
+
# prove ownership of the marker written under the previous
|
|
235
|
+
# task ID, even though the clear defers the pending write.
|
|
236
|
+
outgoing_id_save = linked_fresh && move_as_child_of
|
|
237
|
+
deferred_set = deferred_set.merge(
|
|
238
|
+
"issue_sync_previous_id" => fresh.metadata["issue_sync_previous_id"] || task.id
|
|
239
|
+
) if (deferred_sync || outgoing_id_save) && move_as_child_of
|
|
240
|
+
if has_field_updates || deferred_sync || outgoing_id_save
|
|
241
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
242
|
+
fresh.file_path, set: deferred_set, add: add, remove: remove
|
|
243
|
+
)
|
|
244
|
+
end
|
|
245
|
+
end
|
|
246
|
+
elsif has_field_updates
|
|
247
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
248
|
+
task.file_path, set: set, add: add, remove: remove
|
|
249
|
+
)
|
|
250
|
+
end
|
|
251
|
+
early, current_path, current_special, current_id = apply_relocation_phase(
|
|
252
|
+
task, loader, current_path: current_path, current_special: current_special,
|
|
253
|
+
current_id: current_id, move_to: move_to, move_as_child_of: move_as_child_of
|
|
254
|
+
)
|
|
255
|
+
return early if early
|
|
256
|
+
end
|
|
257
|
+
|
|
258
|
+
# Auto-archive hook: if a subtask status was set to terminal,
|
|
259
|
+
# check if all siblings are terminal and auto-move parent to archive
|
|
260
|
+
if set && set.key?("status")
|
|
261
|
+
check_auto_archive(task, set["status"], loader)
|
|
262
|
+
end
|
|
263
|
+
|
|
264
|
+
# Reload and return updated task
|
|
265
|
+
updated_task = loader.load(current_path, id: current_id, special_folder: current_special)
|
|
266
|
+
if sync_needed_after_update?(task, updated_task, set: set, add: add, remove: remove, move_to: move_to)
|
|
267
|
+
sync_failed_definitively = false
|
|
268
|
+
with_issue_identity_lock(linked_issue(updated_task) || {}) do
|
|
269
|
+
# Reload inside the lock: a concurrent clear may have removed
|
|
270
|
+
# the link while this update waited; syncing the stale snapshot
|
|
271
|
+
# would resurrect an orphaned remote marker.
|
|
272
|
+
fresh = show(updated_task.id)
|
|
273
|
+
if fresh.nil? || linked_issue(fresh).nil? ||
|
|
274
|
+
fresh.metadata["issue_sync_operation"] == "clear"
|
|
275
|
+
next show_after_sync(updated_task) || updated_task
|
|
276
|
+
end
|
|
277
|
+
# The lock was acquired for the pre-reload identity: a
|
|
278
|
+
# concurrent re-link must not have this update sync the new
|
|
279
|
+
# issue under the old issue's lock.
|
|
280
|
+
locked_identity = linked_issue(updated_task)
|
|
281
|
+
if locked_identity.is_a?(Hash) && linked_issue(fresh) != locked_identity
|
|
282
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
283
|
+
"Task #{updated_task.id} link changed during update; retry the command"
|
|
284
|
+
end
|
|
285
|
+
result = sync_linked_issues_for(fresh, reason: "update", previous_task: task)
|
|
286
|
+
if result[:success] == false &&
|
|
287
|
+
result[:error].to_s.start_with?("Ace::Git::ProviderIdentityMismatchError")
|
|
288
|
+
# Established links keep the stored identity for recovery and
|
|
289
|
+
# stay pending; the identity was written before this update,
|
|
290
|
+
# so there is no fresh mapping to roll back.
|
|
291
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
292
|
+
fresh.file_path, set: {"issue_sync_pending" => true}
|
|
293
|
+
)
|
|
294
|
+
sync_failed_definitively = true
|
|
295
|
+
end
|
|
296
|
+
end
|
|
297
|
+
raise Ace::Git::ProviderIdentityMismatchError, "Issue link rejected during update sync" if sync_failed_definitively
|
|
298
|
+
return show_after_sync(updated_task) || updated_task
|
|
299
|
+
end
|
|
300
|
+
updated_task
|
|
301
|
+
end
|
|
302
|
+
|
|
303
|
+
# Relocate spec files (move_to or move_as_child_of). Pending flags for
|
|
304
|
+
# every linked participant are written before the relocation and their
|
|
305
|
+
# comments resynced after it. Callers hold the participants' issue
|
|
306
|
+
# locks across the whole phase so a concurrent replay cannot clear a
|
|
307
|
+
# pending flag mid-move and strand a stale remote marker.
|
|
308
|
+
# Returns [early_result, current_path, current_special, current_id]:
|
|
309
|
+
# early_result is a relocation that ends the update (reparent), nil
|
|
310
|
+
# when the caller should continue with its own tail.
|
|
311
|
+
def apply_relocation_phase(task, loader, current_path:, current_special:, current_id:,
|
|
312
|
+
move_to:, move_as_child_of:)
|
|
162
313
|
if move_to
|
|
314
|
+
# Relocating a parent moves its children's files too: their issue
|
|
315
|
+
# comments embed repo-relative links that just went stale. Flag
|
|
316
|
+
# linked descendants pending (crash-safe, written pre-move) and
|
|
317
|
+
# sync their comments from the post-move locations.
|
|
318
|
+
linked_descendants_of(task.path, task.id).each do |child|
|
|
319
|
+
with_issue_identity_lock(linked_issue(child) || {}) do
|
|
320
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
321
|
+
child.file_path, set: {"issue_sync_pending" => true}
|
|
322
|
+
)
|
|
323
|
+
end
|
|
324
|
+
end
|
|
163
325
|
if archive_move_for_subtask?(task, move_to)
|
|
164
326
|
result = handle_subtask_archive_move(task, loader)
|
|
165
327
|
current_path = result[:path]
|
|
@@ -178,6 +340,16 @@ module Ace
|
|
|
178
340
|
new_path, root: @root_dir
|
|
179
341
|
)
|
|
180
342
|
end
|
|
343
|
+
linked_descendants_of(current_path, current_id).each do |child|
|
|
344
|
+
with_issue_identity_lock(linked_issue(child) || {}) do
|
|
345
|
+
fresh = show(child.id) || child
|
|
346
|
+
# The lock was acquired for the scanned identity: a
|
|
347
|
+
# concurrent re-link leaves the refresh to the new owner's
|
|
348
|
+
# own pending replay.
|
|
349
|
+
next if linked_issue(child) && linked_issue(fresh) != linked_issue(child)
|
|
350
|
+
sync_linked_issues_for(fresh, reason: "move") if linked_issue(fresh)
|
|
351
|
+
end
|
|
352
|
+
end
|
|
181
353
|
end
|
|
182
354
|
|
|
183
355
|
# Reparent if requested (mutually exclusive with move_to)
|
|
@@ -186,23 +358,147 @@ module Ace
|
|
|
186
358
|
resolve_fn = ->(r) { show(r) }
|
|
187
359
|
# Reload task from current path before reparenting (may have been field-updated)
|
|
188
360
|
task_for_reparent = loader.load(current_path, id: task.id, special_folder: current_special)
|
|
361
|
+
# Demoting a parent relocates its descendants' files too; their
|
|
362
|
+
# issue comment links need the same pending-and-sync treatment.
|
|
363
|
+
linked_descendants_of(current_path, current_id).each do |descendant|
|
|
364
|
+
with_issue_identity_lock(linked_issue(descendant) || {}) do
|
|
365
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
366
|
+
descendant.file_path,
|
|
367
|
+
set: {"issue_sync_pending" => true,
|
|
368
|
+
"issue_sync_previous_id" => descendant.metadata["issue_sync_previous_id"] || descendant.id}
|
|
369
|
+
)
|
|
370
|
+
end
|
|
371
|
+
end
|
|
189
372
|
reparented = reparenter.reparent(task_for_reparent, target: move_as_child_of, resolve_ref: resolve_fn)
|
|
190
|
-
|
|
191
|
-
|
|
373
|
+
if linked_issue(task) && linked_issue(reparented).nil?
|
|
374
|
+
# Orchestrator conversion: the linked spec became a child that
|
|
375
|
+
# retains the remote_issue mapping (and the pending transfer
|
|
376
|
+
# metadata). Sync that child, not the newly created parent.
|
|
377
|
+
scanner = Molecules::TaskScanner.new(@root_dir)
|
|
378
|
+
converted_child = scanner.scan_subtasks(reparented.path, parent_id: reparented.id)
|
|
379
|
+
.filter_map { |sr| loader.load(sr.dir_path, id: sr.id, special_folder: sr.special_folder) }
|
|
380
|
+
.find { |t| linked_issue(t) }
|
|
381
|
+
if converted_child
|
|
382
|
+
with_issue_identity_lock(linked_issue(converted_child) || {}) do
|
|
383
|
+
sync_linked_issues_for(converted_child, reason: "reparent", previous_task: task)
|
|
384
|
+
end
|
|
385
|
+
# Other linked descendants of the converted parent keep their
|
|
386
|
+
# identities; refresh their comments from the new layout too.
|
|
387
|
+
linked_descendants_of(reparented.path, reparented.id)
|
|
388
|
+
.reject { |descendant| descendant.id == converted_child.id }
|
|
389
|
+
.each do |descendant|
|
|
390
|
+
with_issue_identity_lock(linked_issue(descendant) || {}) do
|
|
391
|
+
fresh = show(descendant.id) || descendant
|
|
392
|
+
# The lock was acquired for the scanned identity: a
|
|
393
|
+
# concurrent re-link leaves the refresh to the new
|
|
394
|
+
# owner's own pending replay.
|
|
395
|
+
next if linked_issue(descendant) && linked_issue(fresh) != linked_issue(descendant)
|
|
396
|
+
sync_linked_issues_for(fresh, reason: "reparent") if linked_issue(fresh)
|
|
397
|
+
end
|
|
398
|
+
end
|
|
399
|
+
return [show_after_sync(converted_child) || converted_child,
|
|
400
|
+
current_path, current_special, current_id]
|
|
401
|
+
end
|
|
402
|
+
end
|
|
403
|
+
with_issue_identity_lock(linked_issue(reparented) || {}) do
|
|
404
|
+
sync_linked_issues_for(reparented, reason: "reparent", previous_task: task)
|
|
405
|
+
end
|
|
406
|
+
linked_descendants_of(reparented.path, reparented.id).each do |descendant|
|
|
407
|
+
with_issue_identity_lock(linked_issue(descendant) || {}) do
|
|
408
|
+
fresh = show(descendant.id) || descendant
|
|
409
|
+
# The lock was acquired for the scanned identity: a
|
|
410
|
+
# concurrent re-link leaves the refresh to the new owner's
|
|
411
|
+
# own pending replay.
|
|
412
|
+
next if linked_issue(descendant) && linked_issue(fresh) != linked_issue(descendant)
|
|
413
|
+
sync_linked_issues_for(fresh, reason: "reparent") if linked_issue(fresh)
|
|
414
|
+
end
|
|
415
|
+
end
|
|
416
|
+
return [show_after_sync(reparented) || reparented, current_path, current_special, current_id]
|
|
192
417
|
end
|
|
193
418
|
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
419
|
+
[nil, current_path, current_special, current_id]
|
|
420
|
+
end
|
|
421
|
+
|
|
422
|
+
# Every linked identity a relocation of this task touches: the task's
|
|
423
|
+
# own link plus each linked descendant's — extended with the parent's
|
|
424
|
+
# participants when the relocation is a subtask archive, whose nested
|
|
425
|
+
# parent update would otherwise acquire parent locks while holding
|
|
426
|
+
# this task's.
|
|
427
|
+
def relocation_participants(task, move_to)
|
|
428
|
+
participants = participant_identities(task)
|
|
429
|
+
if move_to && archive_move_for_subtask?(task, move_to)
|
|
430
|
+
parent = show(task.parent_id)
|
|
431
|
+
participants += participant_identities(parent) if parent
|
|
198
432
|
end
|
|
433
|
+
participants
|
|
434
|
+
end
|
|
199
435
|
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
436
|
+
# Every linked identity a relocation of this task touches: the task's
|
|
437
|
+
# own link plus each linked descendant's.
|
|
438
|
+
def participant_identities(task)
|
|
439
|
+
[linked_issue(task)] + linked_descendants_of(task.path, task.id)
|
|
440
|
+
.filter_map { |child| linked_issue(child) }
|
|
441
|
+
end
|
|
442
|
+
|
|
443
|
+
# Canonical lock/ownership key for an issue identity: a digest over
|
|
444
|
+
# provider, normalized web repository, and number. Aliased server
|
|
445
|
+
# names and SSH/HTTPS/case variants of one repository normalize to
|
|
446
|
+
# the same key (same convention as ServerUrl.normalize); the digest
|
|
447
|
+
# keeps distinct repositories unambiguous. The stored link keeps the
|
|
448
|
+
# selected server name for replay authentication.
|
|
449
|
+
def canonical_issue_key(identity)
|
|
450
|
+
Digest::SHA256.hexdigest(
|
|
451
|
+
[
|
|
452
|
+
canonical_value(identity, "provider"),
|
|
453
|
+
Ace::Git::Atoms::ServerUrl.normalize(
|
|
454
|
+
Ace::Git::Atoms::ServerUrl.web_base(canonical_value(identity, "repository_url"))
|
|
455
|
+
),
|
|
456
|
+
canonical_value(identity, "number")
|
|
457
|
+
].join("\x1F")
|
|
458
|
+
)
|
|
459
|
+
end
|
|
460
|
+
|
|
461
|
+
def canonical_value(identity, key)
|
|
462
|
+
(identity[key] || identity[key.to_sym]).to_s
|
|
463
|
+
end
|
|
464
|
+
|
|
465
|
+
# Acquire identity locks in one stable sorted order so concurrent
|
|
466
|
+
# relocations cannot deadlock on opposite orders; nested acquisitions
|
|
467
|
+
# of held identities are re-entrant no-ops.
|
|
468
|
+
def with_identity_locks(identities, &block)
|
|
469
|
+
ordered = identities.compact.sort_by { |identity| canonical_issue_key(identity) }
|
|
470
|
+
return block.call if ordered.empty?
|
|
471
|
+
|
|
472
|
+
with_issue_identity_lock(ordered.first) do
|
|
473
|
+
with_identity_locks(ordered[1..], &block)
|
|
204
474
|
end
|
|
205
|
-
|
|
475
|
+
end
|
|
476
|
+
|
|
477
|
+
# Reload persisted metadata after synchronization: the in-memory task
|
|
478
|
+
# predates the pending-flag clear that a successful sync performs.
|
|
479
|
+
def show_after_sync(task)
|
|
480
|
+
return nil unless task
|
|
481
|
+
|
|
482
|
+
show(task.id)
|
|
483
|
+
end
|
|
484
|
+
|
|
485
|
+
# Direct linked subtasks of a task directory (used to refresh their
|
|
486
|
+
# issue comment links after the parent directory relocates).
|
|
487
|
+
# Linked descendants at any depth (a grandchild under an unlinked
|
|
488
|
+
# child still holds a remote comment whose path went stale).
|
|
489
|
+
def linked_descendants_of(path, id)
|
|
490
|
+
scanner = Molecules::TaskScanner.new(@root_dir)
|
|
491
|
+
loader = Molecules::TaskLoader.new
|
|
492
|
+
collect = lambda do |p, i|
|
|
493
|
+
scanner.scan_subtasks(p, parent_id: i).flat_map do |sr|
|
|
494
|
+
child = loader.load(sr.dir_path, id: sr.id, special_folder: sr.special_folder)
|
|
495
|
+
next [] unless child
|
|
496
|
+
|
|
497
|
+
deeper = collect.call(sr.dir_path, sr.id)
|
|
498
|
+
linked_issue(child) ? [child] + deeper : deeper
|
|
499
|
+
end
|
|
500
|
+
end
|
|
501
|
+
collect.call(path, id)
|
|
206
502
|
end
|
|
207
503
|
|
|
208
504
|
# Create a subtask within a parent task.
|
|
@@ -212,48 +508,246 @@ module Ace
|
|
|
212
508
|
# @param priority [String, nil] Priority level
|
|
213
509
|
# @param tags [Array<String>] Tags
|
|
214
510
|
# @return [Models::Task, nil] Created subtask or nil if parent not found
|
|
215
|
-
def create_subtask(parent_ref, title, status: nil, priority: nil, tags: [], estimate: nil,
|
|
511
|
+
def create_subtask(parent_ref, title, status: nil, priority: nil, tags: [], estimate: nil, remote_issue: nil)
|
|
216
512
|
parent = show(parent_ref)
|
|
217
513
|
return nil unless parent
|
|
218
514
|
|
|
219
|
-
|
|
515
|
+
# Authoritative validation precedes the write: the spec forbids
|
|
516
|
+
# leaving an unvalidated offline link after a failed command.
|
|
517
|
+
ensure_issue_linkable!(remote_issue) if remote_issue
|
|
220
518
|
subtask_creator = Molecules::SubtaskCreator.new(config: @config)
|
|
221
|
-
created_subtask =
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
519
|
+
created_subtask = nil
|
|
520
|
+
sync_started = false
|
|
521
|
+
begin
|
|
522
|
+
result = nil
|
|
523
|
+
# The canonical issue lock covers the local duplicate scan, the
|
|
524
|
+
# subtask write, and the sync: two concurrent creates for one
|
|
525
|
+
# issue serialize instead of both writing and both deleting.
|
|
526
|
+
with_issue_identity_lock(remote_issue) do
|
|
527
|
+
ensure_issue_linkable!(remote_issue) if remote_issue
|
|
528
|
+
ensure_issue_not_linked_elsewhere!(remote_issue) if remote_issue
|
|
529
|
+
created_subtask = subtask_creator.create(
|
|
530
|
+
parent,
|
|
531
|
+
title,
|
|
532
|
+
status: status,
|
|
533
|
+
priority: priority,
|
|
534
|
+
tags: tags,
|
|
535
|
+
estimate: estimate,
|
|
536
|
+
remote_issue: remote_issue
|
|
537
|
+
)
|
|
538
|
+
ensure_issue_not_linked_elsewhere!(remote_issue, exclude_id: created_subtask.id) if remote_issue
|
|
539
|
+
sync_started = true
|
|
540
|
+
result = remote_issue ? sync_linked_issues_for(created_subtask, reason: "create") : nil
|
|
541
|
+
end
|
|
542
|
+
if result && result[:success] == false && result[:error].to_s.start_with?("Ace::Git::ProviderIdentityMismatchError") &&
|
|
543
|
+
!result[:committed_create]
|
|
544
|
+
# Ownership was rejected before any remote mutation: the subtask
|
|
545
|
+
# must not survive as a second local claimant for the issue.
|
|
546
|
+
FileUtils.rm_rf(created_subtask.path)
|
|
547
|
+
raise Ace::Git::ProviderIdentityMismatchError, result[:error].to_s
|
|
548
|
+
end
|
|
549
|
+
if result && result[:success] == false &&
|
|
550
|
+
result[:error].to_s.start_with?("Ace::Git::ProviderIdentityMismatchError")
|
|
551
|
+
# Post-create rejection (e.g. a concurrent external marker):
|
|
552
|
+
# the committed marker needs a local cleanup record, so the
|
|
553
|
+
# subtask and its pending identity stay for replay.
|
|
554
|
+
raise Ace::Git::ProviderIdentityMismatchError, result[:error].to_s
|
|
555
|
+
end
|
|
556
|
+
rescue StandardError
|
|
557
|
+
# Any pre-sync failure (unreachable validation included) must not
|
|
558
|
+
# leave an artifact claiming a link that was never validated; once
|
|
559
|
+
# sync has begun the remote marker may exist, so retain the task
|
|
560
|
+
# and its pending identity as the recovery record.
|
|
561
|
+
FileUtils.rm_rf(created_subtask.path) if created_subtask && !sync_started
|
|
562
|
+
raise
|
|
563
|
+
end
|
|
564
|
+
show_after_sync(created_subtask) || created_subtask
|
|
232
565
|
end
|
|
233
566
|
|
|
234
|
-
def
|
|
567
|
+
def issue_sync(ref: nil, all: false, pending: false)
|
|
235
568
|
raise ArgumentError, "Provide --all or a task reference" if !all && !pending && (ref.nil? || ref.strip.empty?)
|
|
569
|
+
raise ArgumentError, "--all and --pending are mutually exclusive" if all && pending
|
|
570
|
+
raise ArgumentError, "REF cannot be combined with --all or --pending" if ref && (all || pending)
|
|
236
571
|
|
|
237
572
|
if all || pending
|
|
238
|
-
tasks =
|
|
239
|
-
tasks = tasks.select { |t| t.metadata["
|
|
240
|
-
linked_tasks = tasks.select { |t|
|
|
241
|
-
results = linked_tasks.map
|
|
242
|
-
|
|
573
|
+
tasks = all_tasks_including_subtasks
|
|
574
|
+
tasks = tasks.select { |t| t.metadata["issue_sync_pending"] } if pending
|
|
575
|
+
linked_tasks = tasks.select { |t| linked_issue(t) }
|
|
576
|
+
results = linked_tasks.map do |task|
|
|
577
|
+
locked_identity = linked_issue(task)
|
|
578
|
+
with_issue_identity_lock(locked_identity || {}) do
|
|
579
|
+
# Reload inside the lock: another process may have cleared,
|
|
580
|
+
# synced, or REPLACED this link while this replay waited. A
|
|
581
|
+
# replaced identity must not be mutated under the old lock.
|
|
582
|
+
fresh = show(task.id)
|
|
583
|
+
if fresh.nil?
|
|
584
|
+
# A concurrent reparent changed the task's ID; report a
|
|
585
|
+
# retryable failure rather than claiming a sync that never
|
|
586
|
+
# happened.
|
|
587
|
+
next sync_result_for(task: task, issues: [locked_identity].compact, success: false,
|
|
588
|
+
reason: "manual-sync", error: "Task relocated during replay; retry the command")
|
|
589
|
+
end
|
|
590
|
+
|
|
591
|
+
if linked_issue(fresh) != locked_identity
|
|
592
|
+
next sync_result_for(task: fresh, issues: [locked_identity].compact, success: false,
|
|
593
|
+
reason: "manual-sync", error: "Issue link changed during replay; retry the command")
|
|
594
|
+
end
|
|
595
|
+
|
|
596
|
+
sync_or_clear_linked_issue(fresh, reason: "manual-sync")
|
|
597
|
+
end
|
|
598
|
+
end
|
|
599
|
+
# An unlinked task with a pending flag is inconsistent state: it
|
|
600
|
+
# fails in every mode (matching REF and --pending semantics).
|
|
601
|
+
inconsistent = (tasks - linked_tasks).select { |t| t.metadata["issue_sync_pending"] }
|
|
602
|
+
results.concat(inconsistent.map do |task|
|
|
603
|
+
sync_result_for(task: task, issues: [], success: false,
|
|
604
|
+
reason: "manual-sync", error: "Pending task has no remote_issue recovery identity")
|
|
605
|
+
end)
|
|
606
|
+
skipped = tasks.length - linked_tasks.length - inconsistent.length
|
|
607
|
+
return summarize_manual_sync_results(results, skipped: pending ? 0 : skipped)
|
|
243
608
|
end
|
|
244
609
|
|
|
245
610
|
task = show(ref)
|
|
246
611
|
return nil unless task
|
|
247
612
|
|
|
248
|
-
unless
|
|
249
|
-
|
|
613
|
+
unless linked_issue(task)
|
|
614
|
+
if task.metadata["issue_sync_pending"]
|
|
615
|
+
# Inconsistent state: pending without a recovery identity is a
|
|
616
|
+
# failure, not a skip (the CLI must exit nonzero).
|
|
617
|
+
return {synced: 0, failed: 1, pending: 0, skipped: 0, task_id: task.id,
|
|
618
|
+
failures: [{task_id: task.id, remote_issues: [], error: "Pending task has no remote_issue recovery identity"}]}
|
|
619
|
+
end
|
|
620
|
+
return {synced: 0, failed: 0, pending: 0, skipped: 1, task_id: task.id, failures: []}
|
|
250
621
|
end
|
|
251
622
|
|
|
252
|
-
|
|
623
|
+
locked_identity = linked_issue(task)
|
|
624
|
+
result = with_issue_identity_lock(locked_identity || {}) do
|
|
625
|
+
fresh = show(task.id)
|
|
626
|
+
if fresh.nil?
|
|
627
|
+
return {synced: 0, failed: 1, pending: 0, skipped: 0, task_id: task.id,
|
|
628
|
+
failures: [{task_id: task.id, remote_issues: [locked_identity].compact,
|
|
629
|
+
error: "Task relocated during replay; retry the command"}]}
|
|
630
|
+
end
|
|
631
|
+
if linked_issue(fresh) != locked_identity
|
|
632
|
+
return {synced: 0, failed: 1, pending: 0, skipped: 0, task_id: task.id,
|
|
633
|
+
failures: [{task_id: task.id, remote_issues: [locked_identity].compact,
|
|
634
|
+
error: "Issue link changed during replay; retry the command"}]}
|
|
635
|
+
end
|
|
636
|
+
sync_or_clear_linked_issue(fresh || task, reason: "manual-sync")
|
|
637
|
+
end
|
|
253
638
|
summary = summarize_manual_sync_results([result], skipped: 0)
|
|
254
639
|
summary.merge(task_id: task.id)
|
|
255
640
|
end
|
|
256
641
|
|
|
642
|
+
def issue_link(ref, issue: nil, clear: false, server_name: nil, use_default: false)
|
|
643
|
+
raise ArgumentError, "Choose --issue or --clear" if (issue.nil? && !clear) || (issue && clear)
|
|
644
|
+
task = show(ref)
|
|
645
|
+
return nil unless task
|
|
646
|
+
|
|
647
|
+
current = linked_issue(task)
|
|
648
|
+
if clear
|
|
649
|
+
raise ArgumentError, "Task #{task.id} has no remote issue" unless current
|
|
650
|
+
clear_issue_link(task)
|
|
651
|
+
return show(ref)
|
|
652
|
+
end
|
|
653
|
+
|
|
654
|
+
identity = Molecules::IssueLink.from_input(issue, server_name: server_name, use_default: use_default)
|
|
655
|
+
# Lock order is issue-first everywhere; the task transition lock
|
|
656
|
+
# nests inside so concurrent link/clear/replay serialize.
|
|
657
|
+
with_issue_identity_lock(identity) do
|
|
658
|
+
with_issue_identity_lock("task" => task.id) do
|
|
659
|
+
# Reload: a concurrent link may have changed this task's state
|
|
660
|
+
# while this call waited for the locks.
|
|
661
|
+
task = show(ref) || task
|
|
662
|
+
current = linked_issue(task)
|
|
663
|
+
issue_link_locked(task, identity, current, ref: ref,
|
|
664
|
+
server_name: server_name, use_default: use_default)
|
|
665
|
+
end
|
|
666
|
+
end
|
|
667
|
+
show(ref)
|
|
668
|
+
end
|
|
669
|
+
|
|
670
|
+
def issue_link_locked(task, identity, current, ref:, server_name:, use_default:)
|
|
671
|
+
if current
|
|
672
|
+
if current == identity
|
|
673
|
+
if task.metadata["issue_sync_operation"] == "clear"
|
|
674
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
675
|
+
"Task #{task.id} has a pending clear; complete it before linking again"
|
|
676
|
+
end
|
|
677
|
+
# A completed identical link (synced, no pending operations)
|
|
678
|
+
# is idempotent: return the current task without touching the
|
|
679
|
+
# forge. Guarded (reconcile-create) and pending-clear links
|
|
680
|
+
# keep the recovery paths below.
|
|
681
|
+
if task.metadata["issue_sync_pending"] != true &&
|
|
682
|
+
task.metadata["issue_sync_operation"] != "clear" &&
|
|
683
|
+
task.metadata["issue_sync_operation"] != "reconcile-create"
|
|
684
|
+
return show(ref)
|
|
685
|
+
end
|
|
686
|
+
# An explicit identical-link retry is the documented recovery
|
|
687
|
+
# for a create that never committed. Reconcile first so a slow
|
|
688
|
+
# commit is adopted rather than duplicated; only after the
|
|
689
|
+
# reconcile window finds no marker does the retry authorize a
|
|
690
|
+
# fresh create. Failed validation retains the guard.
|
|
691
|
+
if task.metadata["issue_sync_operation"] == "reconcile-create"
|
|
692
|
+
reconciled = sync_linked_issues_for(task, reason: "link-retry-reconcile")
|
|
693
|
+
if reconciled[:success]
|
|
694
|
+
return show_after_sync(task) || task
|
|
695
|
+
end
|
|
696
|
+
# Only authoritative absence (reads all completed, marker never
|
|
697
|
+
# appeared) authorizes a fresh create. Any other uncertainty —
|
|
698
|
+
# read failures, unresolved mutations — keeps the guard so a
|
|
699
|
+
# later pending replay can adopt a slow commit.
|
|
700
|
+
unless reconciled[:error].to_s.start_with?("Ace::Git::ProviderReconcileAbsenceError")
|
|
701
|
+
raise Ace::Git::ProviderUnreachableError, reconciled[:error]
|
|
702
|
+
end
|
|
703
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
704
|
+
task.file_path, set: {"issue_sync_operation" => nil}
|
|
705
|
+
)
|
|
706
|
+
task = show(ref)
|
|
707
|
+
end
|
|
708
|
+
ensure_issue_linkable!(identity, task_id: task.id,
|
|
709
|
+
previous_task_id: task.metadata["issue_sync_previous_id"])
|
|
710
|
+
result = sync_linked_issues_for(task, reason: "link-retry")
|
|
711
|
+
raise Ace::Git::ProviderUnreachableError, result[:error] unless result[:success]
|
|
712
|
+
return show(ref)
|
|
713
|
+
end
|
|
714
|
+
|
|
715
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
716
|
+
"Task #{task.id} already links another issue; clear it before linking a different issue"
|
|
717
|
+
end
|
|
718
|
+
with_issue_identity_lock(identity) do
|
|
719
|
+
ensure_issue_linkable!(identity, task_id: task.id)
|
|
720
|
+
ensure_issue_not_linked_elsewhere!(identity, exclude_id: task.id)
|
|
721
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
722
|
+
task.file_path, set: {"remote_issue" => identity, "issue_sync_pending" => true}
|
|
723
|
+
)
|
|
724
|
+
linked = show(ref)
|
|
725
|
+
result = sync_linked_issues_for(linked, reason: "link")
|
|
726
|
+
if result[:success] == false &&
|
|
727
|
+
result[:error].to_s.start_with?("Ace::Git::ProviderIdentityMismatchError") &&
|
|
728
|
+
!result[:committed_create]
|
|
729
|
+
# Definitive pre-mutation rejection (sync validates ownership
|
|
730
|
+
# before touching the forge): roll back the freshly written
|
|
731
|
+
# mapping so no local claim survives for an issue this task
|
|
732
|
+
# never owned.
|
|
733
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
734
|
+
linked.file_path, set: {"remote_issue" => nil, "issue_sync_pending" => nil}
|
|
735
|
+
)
|
|
736
|
+
raise Ace::Git::ProviderIdentityMismatchError, result[:error].to_s
|
|
737
|
+
end
|
|
738
|
+
if result[:success] == false &&
|
|
739
|
+
result[:error].to_s.start_with?("Ace::Git::ProviderIdentityMismatchError")
|
|
740
|
+
# Post-create rejection (e.g. a concurrent external marker):
|
|
741
|
+
# the committed marker needs a local cleanup record, so the
|
|
742
|
+
# link and pending flag are retained for replay.
|
|
743
|
+
raise Ace::Git::ProviderIdentityMismatchError, result[:error].to_s
|
|
744
|
+
end
|
|
745
|
+
raise Ace::Git::ProviderUnreachableError, result[:error] unless result[:success]
|
|
746
|
+
end
|
|
747
|
+
|
|
748
|
+
show_after_sync(show(ref) || task) || show(ref) || task
|
|
749
|
+
end
|
|
750
|
+
|
|
257
751
|
# Get the root directory.
|
|
258
752
|
# @return [String] Absolute path to tasks root
|
|
259
753
|
attr_reader :root_dir
|
|
@@ -476,106 +970,285 @@ module Ace
|
|
|
476
970
|
return false unless after_task
|
|
477
971
|
return true if move_to
|
|
478
972
|
return true if before_task.path != after_task.path
|
|
479
|
-
return true if
|
|
973
|
+
return true if linked_issue(before_task) != linked_issue(after_task)
|
|
480
974
|
|
|
481
975
|
touched_keys = [set, add, remove].compact.flat_map(&:keys).map(&:to_s)
|
|
482
976
|
touched_keys.any? do |key|
|
|
483
|
-
key == "title" || key == "status"
|
|
977
|
+
key == "title" || key == "status"
|
|
484
978
|
end
|
|
485
979
|
end
|
|
486
980
|
|
|
487
|
-
def
|
|
488
|
-
|
|
981
|
+
def linked_issue(task)
|
|
982
|
+
task&.metadata&.[]("remote_issue")
|
|
983
|
+
end
|
|
489
984
|
|
|
490
|
-
|
|
491
|
-
|
|
985
|
+
def reject_issue_metadata_update!(*changes)
|
|
986
|
+
keys = changes.compact.flat_map(&:keys).map(&:to_s)
|
|
987
|
+
forbidden = keys.find do |key|
|
|
988
|
+
key == "remote_issue" || key.start_with?("remote_issue.") ||
|
|
989
|
+
%w[issue_sync_pending issue_sync_previous_id issue_sync_operation
|
|
990
|
+
issue_sync_reconcile_create github_issue github_sync_pending].include?(key)
|
|
991
|
+
end
|
|
992
|
+
raise ArgumentError, "Use ace-task issue-link to change #{forbidden}" if forbidden
|
|
993
|
+
end
|
|
492
994
|
|
|
493
|
-
|
|
995
|
+
def issue_adapter
|
|
996
|
+
require_relative "../molecules/issue_sync_adapter"
|
|
997
|
+
Molecules::IssueSyncAdapter.new
|
|
494
998
|
end
|
|
495
999
|
|
|
496
|
-
def
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
return nil if Array(remove&.keys).map(&:to_s).include?("github_issue")
|
|
1000
|
+
def ensure_issue_linkable!(identity, task_id: nil, previous_task_id: nil)
|
|
1001
|
+
issue_adapter.validate_link!(identity: identity, task_id: task_id, previous_task_id: previous_task_id)
|
|
1002
|
+
end
|
|
500
1003
|
|
|
501
|
-
|
|
1004
|
+
# One task owns at most one exact issue: reject a second local task
|
|
1005
|
+
# holding the same identity even when the first link is still pending
|
|
1006
|
+
# and has therefore written no remote owner marker yet. Identity
|
|
1007
|
+
# comparison is canonical (provider + web repository + number), so a
|
|
1008
|
+
# second configured server name for the same repository cannot claim
|
|
1009
|
+
# the issue across the marker's absence window.
|
|
1010
|
+
def ensure_issue_not_linked_elsewhere!(identity, exclude_id: nil)
|
|
1011
|
+
target_key = canonical_issue_key(identity)
|
|
1012
|
+
all_tasks_including_subtasks.each do |task|
|
|
1013
|
+
next if exclude_id && task.id == exclude_id.to_s
|
|
1014
|
+
|
|
1015
|
+
existing = task.metadata["remote_issue"]
|
|
1016
|
+
next unless existing.is_a?(Hash)
|
|
1017
|
+
next unless canonical_issue_key(existing) == target_key
|
|
1018
|
+
|
|
1019
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
1020
|
+
"Issue ##{canonical_value(identity, 'number')} on " \
|
|
1021
|
+
"#{canonical_value(identity, 'server_name')} is already linked to task #{task.id}"
|
|
1022
|
+
end
|
|
502
1023
|
end
|
|
503
1024
|
|
|
504
|
-
|
|
505
|
-
|
|
1025
|
+
# Serialize link operations per canonical issue identity: ownership
|
|
1026
|
+
# validation, the local write, and the remote marker creation must not
|
|
1027
|
+
# interleave across processes, or two tasks can claim one issue —
|
|
1028
|
+
# including through two configured names for the same repository.
|
|
1029
|
+
def with_issue_identity_lock(identity)
|
|
1030
|
+
return yield unless identity.is_a?(Hash)
|
|
1031
|
+
|
|
1032
|
+
key = if identity["task"]
|
|
1033
|
+
"task-#{canonical_value(identity, 'task')}"
|
|
1034
|
+
else
|
|
1035
|
+
canonical_issue_key(identity)
|
|
1036
|
+
end
|
|
1037
|
+
held = (Thread.current[:ace_task_identity_locks] ||= [])
|
|
1038
|
+
return yield if held.include?(key)
|
|
1039
|
+
|
|
1040
|
+
lock_path = File.join(Dir.tmpdir, "ace-task-issue-#{key}.lock")
|
|
1041
|
+
File.open(lock_path, File::CREAT | File::RDWR) do |lock|
|
|
1042
|
+
lock.flock(File::LOCK_EX)
|
|
1043
|
+
held << key
|
|
1044
|
+
begin
|
|
1045
|
+
yield
|
|
1046
|
+
ensure
|
|
1047
|
+
held.delete(key)
|
|
1048
|
+
lock.flock(File::LOCK_UN)
|
|
1049
|
+
end
|
|
1050
|
+
end
|
|
1051
|
+
end
|
|
506
1052
|
|
|
507
|
-
|
|
1053
|
+
# Bulk issue sync must see linked subtasks too; TaskScanner#scan
|
|
1054
|
+
# excludes subtask folders, which would hide deferred child replays.
|
|
1055
|
+
# Descendants are traversed recursively (grandchildren included).
|
|
1056
|
+
def all_tasks_including_subtasks
|
|
1057
|
+
scanner = Molecules::TaskScanner.new(@root_dir)
|
|
1058
|
+
loader = Molecules::TaskLoader.new
|
|
1059
|
+
collect = lambda do |path, id, special_folder|
|
|
1060
|
+
primary = loader.load(path, id: id, special_folder: special_folder)
|
|
1061
|
+
descendants = scanner.scan_subtasks(path, parent_id: id).flat_map do |sub|
|
|
1062
|
+
collect.call(sub.dir_path, sub.id, sub.special_folder)
|
|
1063
|
+
end
|
|
1064
|
+
primary ? [primary] + descendants : descendants
|
|
1065
|
+
end
|
|
1066
|
+
scanner.scan.flat_map { |sr| collect.call(sr.dir_path, sr.id, sr.special_folder) }
|
|
508
1067
|
end
|
|
509
1068
|
|
|
1069
|
+
# Provider outcomes that conclusively prove a create POST never
|
|
1070
|
+
# committed. Everything else (unknown outcome, unreachable reads)
|
|
1071
|
+
# keeps the reconcile-create guard so replay reconciles instead of
|
|
1072
|
+
# duplicating the comment.
|
|
1073
|
+
DEFINITIVE_CREATE_OUTCOME_ERRORS = [
|
|
1074
|
+
Ace::Git::ProviderAuthenticationError,
|
|
1075
|
+
Ace::Git::ProviderObjectNotFoundError
|
|
1076
|
+
].freeze
|
|
1077
|
+
|
|
510
1078
|
def sync_linked_issues_for(task, reason:, previous_task: nil)
|
|
511
|
-
|
|
512
|
-
return sync_result_for(task: task, issues:
|
|
1079
|
+
identity = linked_issue(task)
|
|
1080
|
+
return sync_result_for(task: task, issues: [], success: true, reason: reason) unless identity
|
|
513
1081
|
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
return sync_result_for(task: task, issues: issue_ids, success: true, reason: reason).merge(offline: true)
|
|
1082
|
+
if task.metadata["issue_sync_operation"] == "clear"
|
|
1083
|
+
@last_update_note = "Issue clear pending for task #{task.id}; replay with 'ace-task issue-sync --pending'"
|
|
1084
|
+
return sync_result_for(task: task, issues: [identity], success: false,
|
|
1085
|
+
reason: reason, error: "Pending clear must be replayed")
|
|
519
1086
|
end
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
1087
|
+
previous_id = task.metadata["issue_sync_previous_id"] || previous_task&.id
|
|
1088
|
+
reconcile_only = task.metadata["issue_sync_operation"] == "reconcile-create"
|
|
1089
|
+
reached_post = false
|
|
1090
|
+
locked_identity = linked_issue(task)
|
|
1091
|
+
issue_adapter.sync_task(
|
|
1092
|
+
task: task, previous_task_id: previous_id,
|
|
1093
|
+
before_create: lambda do
|
|
1094
|
+
mark_issue_sync_pending(task)
|
|
1095
|
+
reached_post = true
|
|
1096
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
1097
|
+
task.file_path, set: {"issue_sync_operation" => "reconcile-create"}
|
|
1098
|
+
)
|
|
1099
|
+
end
|
|
1100
|
+
)
|
|
1101
|
+
# The caller holds the locked identity's lock; a link changed to a
|
|
1102
|
+
# different issue mid-sync would have mutated B without its lock.
|
|
1103
|
+
if locked_identity && linked_issue(task) != locked_identity
|
|
1104
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
1105
|
+
"Task #{task.id} link changed during sync; retry the command"
|
|
1106
|
+
end
|
|
1107
|
+
clear_issue_sync_pending(task)
|
|
1108
|
+
sync_result_for(task: task, issues: [identity], success: true, reason: reason)
|
|
1109
|
+
rescue Ace::Git::ProviderUnknownOutcomeError => e
|
|
1110
|
+
# Only an uncertain comment creation blocks a second POST; label or
|
|
1111
|
+
# state updates are idempotent and need no create guard.
|
|
1112
|
+
mark_issue_sync_pending(task)
|
|
1113
|
+
if reached_post
|
|
1114
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
1115
|
+
task.file_path, set: {"issue_sync_operation" => "reconcile-create"}
|
|
1116
|
+
)
|
|
1117
|
+
end
|
|
1118
|
+
@last_update_note = "Issue sync warning for task #{task&.id}: #{e.class}: #{e.message}; " \
|
|
1119
|
+
"flagged for 'ace-task issue-sync --pending'"
|
|
1120
|
+
sync_result_for(task: task, issues: [identity].compact, success: false,
|
|
1121
|
+
reason: reason, error: "#{e.class}: #{e.message}")
|
|
524
1122
|
rescue StandardError => e
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
1123
|
+
mark_issue_sync_pending(task)
|
|
1124
|
+
# Only a definitive provider rejection of the create POST itself
|
|
1125
|
+
# (auth failure, object-not-found) proves it did not commit; the
|
|
1126
|
+
# guard then clears so replay may retry. Unknown outcomes and
|
|
1127
|
+
# unreachable reconciliation reads keep the guard: the first POST
|
|
1128
|
+
# may still commit and a second one would duplicate the comment.
|
|
1129
|
+
if reached_post && !reconcile_only &&
|
|
1130
|
+
DEFINITIVE_CREATE_OUTCOME_ERRORS.any? { |klass| e.is_a?(klass) }
|
|
1131
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
1132
|
+
task.file_path, set: {"issue_sync_operation" => nil}
|
|
1133
|
+
)
|
|
1134
|
+
end
|
|
1135
|
+
@last_update_note = "Issue sync warning for task #{task&.id}: #{e.class}: #{e.message}; " \
|
|
1136
|
+
"flagged for 'ace-task issue-sync --pending'"
|
|
1137
|
+
sync_result_for(task: task, issues: [identity].compact, success: false,
|
|
1138
|
+
reason: reason, error: "#{e.class}: #{e.message}", committed_create: reached_post)
|
|
528
1139
|
end
|
|
529
1140
|
|
|
530
|
-
|
|
531
|
-
# replayed with 'ace-task github-sync --pending'.
|
|
532
|
-
def mark_github_sync_pending(task)
|
|
1141
|
+
def mark_issue_sync_pending(task)
|
|
533
1142
|
return unless task&.file_path && File.exist?(task.file_path)
|
|
534
1143
|
|
|
535
1144
|
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
536
|
-
task.file_path, set: {"
|
|
1145
|
+
task.file_path, set: {"issue_sync_pending" => true}
|
|
537
1146
|
)
|
|
538
|
-
rescue StandardError
|
|
539
|
-
nil
|
|
540
1147
|
end
|
|
541
1148
|
|
|
542
|
-
def
|
|
1149
|
+
def clear_issue_sync_pending(task)
|
|
543
1150
|
return unless task&.file_path && File.exist?(task.file_path)
|
|
544
1151
|
|
|
545
1152
|
Ace::Support::Items::Molecules::FieldUpdater.update(
|
|
546
|
-
task.file_path,
|
|
1153
|
+
task.file_path,
|
|
1154
|
+
set: {"issue_sync_pending" => nil, "issue_sync_previous_id" => nil,
|
|
1155
|
+
"issue_sync_operation" => nil}
|
|
547
1156
|
)
|
|
548
|
-
rescue StandardError
|
|
549
|
-
nil
|
|
550
1157
|
end
|
|
551
1158
|
|
|
552
|
-
def
|
|
1159
|
+
def clear_issue_link(task)
|
|
1160
|
+
# Lock order is issue-first everywhere (link, sync, clear) so
|
|
1161
|
+
# concurrent clear and replay cannot deadlock on opposite orders.
|
|
1162
|
+
locked_identity = linked_issue(task)
|
|
1163
|
+
with_issue_identity_lock(locked_identity || {}) do
|
|
1164
|
+
with_issue_identity_lock("task" => task.id) do
|
|
1165
|
+
fresh = show(task.id) || task
|
|
1166
|
+
# If the link changed while this clear waited (A cleared, B
|
|
1167
|
+
# linked), reject the stale request so the new link's marker is
|
|
1168
|
+
# only cleared under its own lock.
|
|
1169
|
+
if locked_identity.is_a?(Hash) && linked_issue(fresh) != locked_identity
|
|
1170
|
+
raise Ace::Git::ProviderIdentityMismatchError,
|
|
1171
|
+
"Task #{task.id} link changed during clear; retry the command"
|
|
1172
|
+
end
|
|
1173
|
+
clear_issue_link_locked(fresh)
|
|
1174
|
+
end
|
|
1175
|
+
end
|
|
1176
|
+
end
|
|
1177
|
+
|
|
1178
|
+
def clear_issue_link_locked(task)
|
|
1179
|
+
# Persist the clear intent (retaining the create guard) before any
|
|
1180
|
+
# forge call: a stop after reconciliation mutated the remote must
|
|
1181
|
+
# still replay as a clear, never silently restore tracking.
|
|
1182
|
+
reconcile_first = task.metadata["issue_sync_operation"] == "reconcile-create" ||
|
|
1183
|
+
task.metadata["issue_sync_reconcile_create"] == true
|
|
1184
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(task.file_path,
|
|
1185
|
+
set: {"issue_sync_pending" => true, "issue_sync_operation" => "clear",
|
|
1186
|
+
"issue_sync_reconcile_create" => (true if reconcile_first)})
|
|
1187
|
+
# A tracking comment may still be committing forge-side: reconcile
|
|
1188
|
+
# comment ownership before the clear — clear must never change
|
|
1189
|
+
# issue state as a side effect. Authoritative absence (the guarded
|
|
1190
|
+
# create never committed) means there is no marker to reconcile,
|
|
1191
|
+
# so the clear proceeds; any other uncertainty retains the intent
|
|
1192
|
+
# for replay.
|
|
1193
|
+
begin
|
|
1194
|
+
issue_adapter.reconcile_comment(task: task) if reconcile_first
|
|
1195
|
+
rescue Ace::Git::ProviderReconcileAbsenceError
|
|
1196
|
+
nil
|
|
1197
|
+
end
|
|
1198
|
+
issue_adapter.clear_task(task: task, previous_task_id: task.metadata["issue_sync_previous_id"])
|
|
1199
|
+
Ace::Support::Items::Molecules::FieldUpdater.update(task.file_path,
|
|
1200
|
+
set: {"remote_issue" => nil, "issue_sync_pending" => nil,
|
|
1201
|
+
"issue_sync_operation" => nil, "issue_sync_previous_id" => nil,
|
|
1202
|
+
"issue_sync_reconcile_create" => nil})
|
|
1203
|
+
end
|
|
1204
|
+
|
|
1205
|
+
def sync_or_clear_linked_issue(task, reason:)
|
|
1206
|
+
return sync_linked_issues_for(task, reason: reason) unless task.metadata["issue_sync_operation"] == "clear"
|
|
1207
|
+
|
|
1208
|
+
identity = linked_issue(task)
|
|
1209
|
+
clear_issue_link(task)
|
|
1210
|
+
sync_result_for(task: task, issues: [identity], success: true, reason: reason)
|
|
1211
|
+
rescue StandardError => e
|
|
1212
|
+
sync_result_for(task: task, issues: [identity].compact, success: false,
|
|
1213
|
+
reason: reason, error: "#{e.class}: #{e.message}")
|
|
1214
|
+
end
|
|
1215
|
+
|
|
1216
|
+
def sync_result_for(task:, issues:, success:, reason:, error: nil, committed_create: false)
|
|
553
1217
|
{
|
|
554
1218
|
task_id: task&.id,
|
|
555
1219
|
issue_ids: issues,
|
|
556
1220
|
success: success,
|
|
557
1221
|
reason: reason,
|
|
558
|
-
error: error
|
|
1222
|
+
error: error,
|
|
1223
|
+
# True when the error surfaced after the create guard was armed:
|
|
1224
|
+
# the tracking comment POST may have committed, so callers must
|
|
1225
|
+
# retain the local identity as the cleanup record instead of
|
|
1226
|
+
# rolling it back.
|
|
1227
|
+
committed_create: committed_create
|
|
559
1228
|
}
|
|
560
1229
|
end
|
|
561
1230
|
|
|
562
1231
|
def summarize_manual_sync_results(results, skipped:)
|
|
563
|
-
failures = results.reject { |result| result[:success] }
|
|
1232
|
+
failures = results.reject { |result| result[:success] }
|
|
1233
|
+
# A failed sync persists issue_sync_pending; classify it as pending
|
|
1234
|
+
# (recoverable) rather than failed so replay counts stay truthful.
|
|
1235
|
+
pending = failures.count do |result|
|
|
1236
|
+
show(result[:task_id])&.metadata&.[]("issue_sync_pending") == true
|
|
1237
|
+
end
|
|
1238
|
+
failures_detail = failures.map do |result|
|
|
564
1239
|
{
|
|
565
1240
|
task_id: result[:task_id],
|
|
566
|
-
|
|
1241
|
+
remote_issues: result[:issue_ids],
|
|
567
1242
|
error: result[:error]
|
|
568
1243
|
}
|
|
569
1244
|
end
|
|
570
1245
|
|
|
571
|
-
pending = results.count { |result| result[:offline] }
|
|
572
|
-
|
|
573
1246
|
{
|
|
574
|
-
synced: results.length - failures.length
|
|
575
|
-
failed: failures.length,
|
|
1247
|
+
synced: results.length - failures.length,
|
|
1248
|
+
failed: failures.length - pending,
|
|
576
1249
|
pending: pending,
|
|
577
1250
|
skipped: skipped,
|
|
578
|
-
failures:
|
|
1251
|
+
failures: failures_detail
|
|
579
1252
|
}
|
|
580
1253
|
end
|
|
581
1254
|
end
|