ace-task 0.38.1 → 0.39.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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/github_issue_sync_adapter"
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
- github_issue: nil
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 = creator.create(
62
- title,
63
- status: status,
64
- priority: priority,
65
- tags: tags,
66
- dependencies: dependencies,
67
- use_llm_slug: use_llm_slug,
68
- time: Time.now.utc + ((attempts - 1) * 2),
69
- estimate: estimate,
70
- github_issue: github_issue
71
- )
72
- sync_linked_issues_for(created_task, reason: "create")
73
- created_task
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
- desired_issue = extract_desired_github_issue(task, set: set, remove: remove)
151
- ensure_github_issue_linkable!(desired_issue, previous_task: task) if desired_issue
152
- if has_field_updates
153
- Ace::Support::Items::Molecules::FieldUpdater.update(
154
- task.file_path, set: set, add: add, remove: remove
155
- )
156
- end
157
-
158
- # Apply move if requested
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
- sync_linked_issues_for(reparented, reason: "reparent", previous_task: task)
191
- return reparented
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
- # Auto-archive hook: if a subtask status was set to terminal,
195
- # check if all siblings are terminal and auto-move parent to archive
196
- if set && set.key?("status")
197
- check_auto_archive(task, set["status"], loader)
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
- # Reload and return updated task
201
- updated_task = loader.load(current_path, id: current_id, special_folder: current_special)
202
- if sync_needed_after_update?(task, updated_task, set: set, add: add, remove: remove, move_to: move_to)
203
- sync_linked_issues_for(updated_task, reason: "update", previous_task: task)
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
- updated_task
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, github_issue: 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
- ensure_github_issue_linkable!(github_issue)
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 = subtask_creator.create(
222
- parent,
223
- title,
224
- status: status,
225
- priority: priority,
226
- tags: tags,
227
- estimate: estimate,
228
- github_issue: github_issue
229
- )
230
- sync_linked_issues_for(created_subtask, reason: "create")
231
- created_subtask
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 github_sync(ref: nil, all: false, pending: false)
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 = list(in_folder: "all")
239
- tasks = tasks.select { |t| t.metadata["github_sync_pending"] } if pending
240
- linked_tasks = tasks.select { |t| linked_issue_id(t) }
241
- results = linked_tasks.map { |task| sync_linked_issues_for(task, reason: "manual-sync") }
242
- return summarize_manual_sync_results(results, skipped: tasks.length - linked_tasks.length)
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 linked_issue_id(task)
249
- return {synced: 0, failed: 0, skipped: 1, task_id: task.id, failures: []}
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
- result = sync_linked_issues_for(task, reason: "manual-sync")
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 linked_issue_id(before_task) != linked_issue_id(after_task)
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" || key == "github_issue"
977
+ key == "title" || key == "status"
484
978
  end
485
979
  end
486
980
 
487
- def linked_issue_id(task)
488
- return nil unless task&.metadata
981
+ def linked_issue(task)
982
+ task&.metadata&.[]("remote_issue")
983
+ end
489
984
 
490
- issue_id = task.metadata["github_issue"]
491
- return nil unless issue_id.to_i.positive?
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
- issue_id.to_i
995
+ def issue_adapter
996
+ require_relative "../molecules/issue_sync_adapter"
997
+ Molecules::IssueSyncAdapter.new
494
998
  end
495
999
 
496
- def extract_desired_github_issue(task, set:, remove:)
497
- return nil if set&.key?("github_issue") && !set["github_issue"].to_i.positive?
498
- return set["github_issue"].to_i if set&.key?("github_issue") && set["github_issue"].to_i.positive?
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
- linked_issue_id(task)
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
- def ensure_github_issue_linkable!(github_issue, previous_task: nil)
505
- return unless github_issue
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
- Molecules::GithubIssueSyncAdapter.new.validate_link!(issue_id: github_issue, previous_task: previous_task)
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
- issue_ids = [linked_issue_id(task), linked_issue_id(previous_task)].compact.uniq
512
- return sync_result_for(task: task, issues: issue_ids, success: true, reason: reason) if issue_ids.empty?
1079
+ identity = linked_issue(task)
1080
+ return sync_result_for(task: task, issues: [], success: true, reason: reason) unless identity
513
1081
 
514
- adapter = Molecules::GithubIssueSyncAdapter.new
515
- unless adapter.available?
516
- mark_github_sync_pending(task)
517
- @last_update_note = "GitHub sync skipped (gh unavailable); flagged for 'ace-task github-sync --pending'"
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
- adapter.sync_task(task: task, reason: reason, previous_task: previous_task)
522
- clear_github_sync_pending(task)
523
- sync_result_for(task: task, issues: issue_ids, success: true, reason: reason)
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
- mark_github_sync_pending(task)
526
- @last_update_note = "GitHub sync warning for task #{task&.id}: #{e.message}; flagged for 'ace-task github-sync --pending'"
527
- sync_result_for(task: task, issues: issue_ids, success: false, reason: reason, error: e.message)
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
- # Flag the task so the missed GitHub sync survives offline work and can be
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: {"github_sync_pending" => true}
1145
+ task.file_path, set: {"issue_sync_pending" => true}
537
1146
  )
538
- rescue StandardError
539
- nil
540
1147
  end
541
1148
 
542
- def clear_github_sync_pending(task)
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, set: {"github_sync_pending" => nil}
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 sync_result_for(task:, issues:, success:, reason:, error: nil)
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] }.map do |result|
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
- issue_ids: result[:issue_ids],
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 - pending,
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: failures
1251
+ failures: failures_detail
579
1252
  }
580
1253
  end
581
1254
  end